First commit, phased 1 and 2 created but untested
This commit is contained in:
+81
@@ -0,0 +1,81 @@
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const { createSessionMiddleware, requireAuth } = require('./middleware/session');
|
||||
const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth');
|
||||
const { routes: characterRoutes } = require('./routes/character');
|
||||
const { AppError } = require('./utils/errors');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
|
||||
// Trust proxy (for rate limiting behind reverse proxy)
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// JSON body parser with small limit
|
||||
app.use(express.json({ limit: '10kb' }));
|
||||
|
||||
// Session middleware
|
||||
app.use(createSessionMiddleware());
|
||||
|
||||
// Static files
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Health endpoint (no auth)
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Auth routes (public)
|
||||
app.post('/api/auth/register', registerLimiter, authRoutes.postRegister);
|
||||
app.post('/api/auth/login', loginLimiter, authRoutes.postLogin);
|
||||
|
||||
// Auth routes (require session)
|
||||
app.post('/api/auth/logout', requireAuth, authRoutes.postLogout);
|
||||
app.get('/api/auth/me', requireAuth, authRoutes.getMe);
|
||||
|
||||
// Character routes (require session)
|
||||
app.post('/api/character', requireAuth, characterRoutes.postCharacter);
|
||||
app.get('/api/character', requireAuth, characterRoutes.getCharacter);
|
||||
|
||||
// Central error handler
|
||||
app.use((err, _req, res, _next) => {
|
||||
// Log stack traces server-side only
|
||||
if (err.stack) {
|
||||
console.error(err.stack);
|
||||
}
|
||||
|
||||
if (err instanceof AppError) {
|
||||
return res.status(err.status).json({
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check for Zod-like validation errors
|
||||
if (err.name === 'ZodError' || (err.errors && Array.isArray(err.errors))) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Generic error
|
||||
res.status(500).json({
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Something went wrong.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
@@ -0,0 +1,27 @@
|
||||
const path = require('path');
|
||||
|
||||
// Load .env from project root
|
||||
const projectRoot = path.resolve(__dirname, '..', '..');
|
||||
process.loadEnvFile(path.join(projectRoot, '.env'));
|
||||
|
||||
const requiredVars = ['PORT', 'NODE_ENV', 'SESSION_SECRET', 'DB_PATH'];
|
||||
|
||||
for (const varName of requiredVars) {
|
||||
if (!process.env[varName]) {
|
||||
throw new Error(`Missing required env var: ${varName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// In production, SESSION_SECRET must not be a placeholder
|
||||
if (process.env.NODE_ENV === 'production' && process.env.SESSION_SECRET === 'change-me-to-a-long-random-string') {
|
||||
throw new Error('SESSION_SECRET in production must not be the default placeholder');
|
||||
}
|
||||
|
||||
const config = Object.freeze({
|
||||
port: parseInt(process.env.PORT, 10),
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
sessionSecret: process.env.SESSION_SECRET,
|
||||
dbPath: path.resolve(projectRoot, process.env.DB_PATH),
|
||||
});
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,28 @@
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const config = require('../config');
|
||||
|
||||
let db;
|
||||
|
||||
function getDb() {
|
||||
if (!db) {
|
||||
const dbDir = path.dirname(config.dbPath);
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(config.dbPath, {
|
||||
wal: true,
|
||||
nativeTimeout: 'session',
|
||||
});
|
||||
|
||||
// Configure journal mode and enable foreign keys
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
module.exports = { getDb };
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER,
|
||||
is_banned INTEGER NOT NULL DEFAULT 0 CHECK (is_banned IN (0,1))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_users_username_nocase ON users(username COLLATE NOCASE);
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE characters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
class TEXT NOT NULL CHECK (class IN ('warrior','mage','ranger')),
|
||||
level INTEGER NOT NULL DEFAULT 1 CHECK (level >= 1),
|
||||
xp INTEGER NOT NULL DEFAULT 0 CHECK (xp >= 0),
|
||||
gold INTEGER NOT NULL DEFAULT 50 CHECK (gold >= 0),
|
||||
str INTEGER NOT NULL,
|
||||
agi INTEGER NOT NULL,
|
||||
int INTEGER NOT NULL,
|
||||
vit INTEGER NOT NULL,
|
||||
unspent_points INTEGER NOT NULL DEFAULT 0 CHECK (unspent_points >= 0),
|
||||
hp_current INTEGER NOT NULL CHECK (hp_current >= 0),
|
||||
energy INTEGER NOT NULL CHECK (energy >= 0),
|
||||
energy_updated_at INTEGER NOT NULL,
|
||||
kills INTEGER NOT NULL DEFAULT 0,
|
||||
deaths INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_characters_name_nocase ON characters(name COLLATE NOCASE);
|
||||
CREATE INDEX idx_characters_level ON characters(level DESC, xp DESC);
|
||||
CREATE INDEX idx_characters_gold ON characters(gold DESC);
|
||||
CREATE INDEX idx_characters_kills ON characters(kills DESC);
|
||||
@@ -0,0 +1,44 @@
|
||||
// Class definitions: base stats and fantasy role for each class.
|
||||
|
||||
const CLASSES = Object.freeze({
|
||||
warrior: {
|
||||
name: 'Warrior',
|
||||
fantasy: 'Armoured frontliner',
|
||||
base: { str: 8, agi: 4, int: 2, vit: 8 },
|
||||
// Primary stat for attack calculation
|
||||
primary: 'str',
|
||||
// Class-specific bonuses (applied to derived stats)
|
||||
bonuses: { defensePct: 0.10 },
|
||||
},
|
||||
mage: {
|
||||
name: 'Mage',
|
||||
fantasy: 'Glass-cannon spellcaster',
|
||||
base: { str: 2, agi: 4, int: 9, vit: 5 },
|
||||
primary: 'int',
|
||||
bonuses: { magicVsHighDef: 0.15 },
|
||||
},
|
||||
ranger: {
|
||||
name: 'Ranger',
|
||||
fantasy: 'Fast skirmisher',
|
||||
base: { str: 5, agi: 9, int: 3, vit: 5 },
|
||||
primary: 'agi',
|
||||
bonuses: { critChance: 0.08 },
|
||||
},
|
||||
});
|
||||
|
||||
const CLASS_KEYS = Object.freeze(Object.keys(CLASSES));
|
||||
|
||||
function getClass(classKey) {
|
||||
const cls = CLASSES[classKey];
|
||||
if (!cls) {
|
||||
throw new Error(`Unknown class: ${classKey}. Valid: ${CLASS_KEYS.join(', ')}`);
|
||||
}
|
||||
return cls;
|
||||
}
|
||||
|
||||
function getBaseStats(classKey) {
|
||||
const cls = getClass(classKey);
|
||||
return { ...cls.base };
|
||||
}
|
||||
|
||||
module.exports = { CLASSES, CLASS_KEYS, getClass, getBaseStats };
|
||||
@@ -0,0 +1,47 @@
|
||||
// Energy regeneration: pure function based on BALANCE.md formulas.
|
||||
|
||||
const ENERGY_MAX = 100;
|
||||
const ENERGY_REGEN_SECONDS = 180; // +1 per 3 minutes
|
||||
|
||||
/**
|
||||
* Compute current energy based on time elapsed since last update.
|
||||
* Uses lazy evaluation: no timers, just math on timestamps.
|
||||
*
|
||||
* @param {number} stored - stored energy value (0-100)
|
||||
* @param {number} updatedAtMs - last energy update timestamp (ms epoch)
|
||||
* @param {number} nowMs - current time (ms epoch)
|
||||
* @returns {{ energy: number, updatedAtMs: number }}
|
||||
*/
|
||||
function computeEnergy(stored, updatedAtMs, nowMs) {
|
||||
// Clock went backwards or same time — return as-is
|
||||
if (nowMs <= updatedAtMs) {
|
||||
return { energy: stored, updatedAtMs };
|
||||
}
|
||||
|
||||
const elapsedMs = nowMs - updatedAtMs;
|
||||
const gained = Math.floor(elapsedMs / (ENERGY_REGEN_SECONDS * 1000));
|
||||
|
||||
// Less than one full regen tick — no change
|
||||
if (gained <= 0) {
|
||||
return { energy: stored, updatedAtMs };
|
||||
}
|
||||
|
||||
const newEnergy = stored + gained;
|
||||
|
||||
// Cap at max and reset timestamp
|
||||
if (newEnergy >= ENERGY_MAX) {
|
||||
return { energy: ENERGY_MAX, updatedAtMs: nowMs };
|
||||
}
|
||||
|
||||
// Advance timestamp by whole ticks only (keep partial)
|
||||
return {
|
||||
energy: newEnergy,
|
||||
updatedAtMs: updatedAtMs + gained * ENERGY_REGEN_SECONDS * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENERGY_MAX,
|
||||
ENERGY_REGEN_SECONDS,
|
||||
computeEnergy,
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
// Derived stats calculation based on BALANCE.md formulas.
|
||||
// All functions are pure — take data in, return data out.
|
||||
|
||||
const { getClass } = require('./classes');
|
||||
const { computeEnergy: _computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('./energy');
|
||||
|
||||
/**
|
||||
* Calculate derived stats from base stats, level, and equipment bonuses.
|
||||
* @param {object} stats - { str, agi, int, vit }
|
||||
* @param {number} level - hero level
|
||||
* @param {string} classKey - 'warrior' | 'mage' | 'ranger'
|
||||
* @param {object} equipBonuses - { bonus_str, bonus_agi, bonus_int, bonus_vit, bonus_attack, bonus_defense }
|
||||
* @returns {object} derived stats
|
||||
*/
|
||||
function calcDerived(stats, level, classKey, equipBonuses = {}) {
|
||||
const cls = getClass(classKey);
|
||||
|
||||
// Total stats = base + equipment bonuses
|
||||
const totalStr = stats.str + (equipBonuses.bonus_str || 0);
|
||||
const totalAgi = stats.agi + (equipBonuses.bonus_agi || 0);
|
||||
const totalInt = stats.int + (equipBonuses.bonus_int || 0);
|
||||
const totalVit = stats.vit + (equipBonuses.bonus_vit || 0);
|
||||
|
||||
// Primary stat for attack
|
||||
const primary = cls.primary === 'str' ? totalStr
|
||||
: cls.primary === 'agi' ? totalAgi
|
||||
: totalInt;
|
||||
|
||||
// maxHp = 50 + vit*10 + level*5 + itemBonusVit*10
|
||||
const maxHp = 50 + totalVit * 10 + level * 5 + (equipBonuses.bonus_vit || 0) * 10;
|
||||
|
||||
// attack = 5 + primary*2 + level + equipment.bonus_attack
|
||||
const attack = 5 + primary * 2 + level + (equipBonuses.bonus_attack || 0);
|
||||
|
||||
// defense = floor(vit*1.5) + equipment.bonus_defense (+10% if Warrior)
|
||||
let defense = Math.floor(totalVit * 1.5) + (equipBonuses.bonus_defense || 0);
|
||||
if (cls.bonuses.defensePct) {
|
||||
defense = Math.floor(defense * (1 + cls.bonuses.defensePct));
|
||||
}
|
||||
|
||||
// speed = 10 + agi
|
||||
const speed = 10 + totalAgi;
|
||||
|
||||
// critChance% = min(40, 5 + floor(agi*0.5)) (+8 if Ranger)
|
||||
let critChance = Math.min(40, 5 + Math.floor(totalAgi * 0.5));
|
||||
if (cls.bonuses.critChance) {
|
||||
critChance = Math.min(40, critChance + Math.floor(cls.bonuses.critChance * 100));
|
||||
}
|
||||
|
||||
return {
|
||||
maxHp,
|
||||
attack,
|
||||
defense,
|
||||
speed,
|
||||
critChance,
|
||||
critMult: 1.5,
|
||||
primary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Energy regeneration: re-exported from energy.js for backward compat.
|
||||
*/
|
||||
const computeEnergy = _computeEnergy;
|
||||
|
||||
/**
|
||||
* XP required to reach next level from given level.
|
||||
* @param {number} level - current level
|
||||
* @returns {number} XP needed
|
||||
*/
|
||||
function xpToNext(level) {
|
||||
return Math.floor(100 * Math.pow(level, 1.5));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hero leveled up and return new level + unspent points.
|
||||
* @param {number} currentLevel - current level
|
||||
* @param {number} currentXp - current XP
|
||||
* @param {number} xpGained - XP gained from this action
|
||||
* @returns {object} { newLevel, leveledUp, totalXp, unspentPoints }
|
||||
*/
|
||||
function checkLevelUp(currentLevel, currentXp, xpGained) {
|
||||
let totalXp = currentXp + xpGained;
|
||||
let level = currentLevel;
|
||||
let xpNeeded = xpToNext(level);
|
||||
|
||||
while (totalXp >= xpNeeded && level < 50) {
|
||||
totalXp -= xpNeeded;
|
||||
level += 1;
|
||||
xpNeeded = xpToNext(level);
|
||||
}
|
||||
|
||||
const leveledUp = level > currentLevel;
|
||||
const unspentPoints = (level - currentLevel) * 3;
|
||||
|
||||
return {
|
||||
newLevel: level,
|
||||
leveledUp,
|
||||
totalXp,
|
||||
unspentPoints,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENERGY_MAX,
|
||||
ENERGY_REGEN_SECONDS,
|
||||
calcDerived,
|
||||
computeEnergy,
|
||||
xpToNext,
|
||||
checkLevelUp,
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
const session = require('express-session');
|
||||
const SessionStore = require('better-sqlite3-session-store')(session);
|
||||
const config = require('../config');
|
||||
const { getDb } = require('../db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
function createSessionMiddleware() {
|
||||
const db = getDb();
|
||||
|
||||
return session({
|
||||
store: new SessionStore({
|
||||
client: db,
|
||||
table: 'sessions',
|
||||
expired: {
|
||||
clear: true,
|
||||
intervalMs: 900000, // 15 min cleanup
|
||||
},
|
||||
}),
|
||||
secret: config.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
secure: config.nodeEnv === 'production',
|
||||
},
|
||||
name: 'emberfall_session',
|
||||
rolling: true,
|
||||
});
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session || !req.session.userId) {
|
||||
// Return JSON error for API requests
|
||||
if (req.path.startsWith('/api/') || req.xhr) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
code: 'UNAUTHENTICATED',
|
||||
message: 'Authentication required.',
|
||||
},
|
||||
});
|
||||
}
|
||||
// Redirect to login for page requests
|
||||
return res.redirect('/login.html');
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { createSessionMiddleware, requireAuth };
|
||||
@@ -0,0 +1,90 @@
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const zod = require('zod');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { register, verifyLogin } = require('../services/authService');
|
||||
|
||||
// Validation schemas
|
||||
const registerSchema = zod.strictObject({
|
||||
username: zod.string().min(3).max(20).regex(/^[A-Za-z0-9_]+$/),
|
||||
password: zod.string().min(8).max(72),
|
||||
});
|
||||
|
||||
const loginSchema = zod.strictObject({
|
||||
username: zod.string().min(1).max(20),
|
||||
password: zod.string().min(1).max(72),
|
||||
});
|
||||
|
||||
// Rate limiters
|
||||
const registerLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: process.env.NODE_ENV === 'test' ? 1000 : 5,
|
||||
message: { error: { code: 'RATE_LIMITED', message: 'Too many registration attempts. Try again later.' } },
|
||||
});
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: process.env.NODE_ENV === 'test' ? 1000 : 10,
|
||||
message: { error: { code: 'RATE_LIMITED', message: 'Too many login attempts. Try again later.' } },
|
||||
});
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
async function handleRegister(req, res) {
|
||||
const parsed = registerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
|
||||
}
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
const user = await register(username, password);
|
||||
|
||||
// Regenerate session to prevent fixation
|
||||
req.session.regenerate((err) => {
|
||||
if (err) throw new AppError('INTERNAL_ERROR', 'Session regeneration failed.', 500);
|
||||
req.session.userId = user.id;
|
||||
res.status(201).json({ ok: true, data: { user: { id: user.id, username: user.username } } });
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLogin(req, res) {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
|
||||
}
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
const user = await verifyLogin(username, password);
|
||||
|
||||
// Regenerate session to prevent fixation
|
||||
req.session.regenerate((err) => {
|
||||
if (err) throw new AppError('INTERNAL_ERROR', 'Session regeneration failed.', 500);
|
||||
req.session.userId = user.id;
|
||||
res.json({ ok: true, data: { user: { id: user.id, username: user.username }, hasHero: false } });
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLogout(req, res) {
|
||||
req.session.destroy((err) => {
|
||||
if (err) throw new AppError('INTERNAL_ERROR', 'Logout failed.', 500);
|
||||
res.clearCookie('emberfall_session');
|
||||
res.json({ ok: true, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMe(req, res) {
|
||||
const userId = req.session.userId;
|
||||
res.json({ ok: true, data: { user: { id: userId, username: req.session.username || '' }, hasHero: false } });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerLimiter,
|
||||
loginLimiter,
|
||||
routes: {
|
||||
postRegister: wrap(handleRegister),
|
||||
postLogin: wrap(handleLogin),
|
||||
postLogout: wrap(handleLogout),
|
||||
getMe: wrap(handleMe),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
// Character routes: POST /api/character (create), GET /api/character (profile).
|
||||
const zod = require('zod');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { requireAuth } = require('../middleware/session');
|
||||
const { createCharacter, getCharacterProfile } = require('../services/characterService');
|
||||
const { CLASS_KEYS } = require('../game/classes');
|
||||
|
||||
// Validation schemas
|
||||
const createSchema = zod.strictObject({
|
||||
name: zod.string().min(3).max(16).regex(/^[A-Za-z0-9 ']+$/).trim(),
|
||||
class: zod.enum(CLASS_KEYS),
|
||||
});
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
async function handleCreate(req, res) {
|
||||
const parsed = createSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
|
||||
}
|
||||
|
||||
const { name, class: classKey } = parsed.data;
|
||||
const userId = req.session.userId;
|
||||
|
||||
const result = createCharacter(userId, name, classKey);
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
data: { character: { id: result.id, name, class: classKey } },
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGet(req, res) {
|
||||
const userId = req.session.userId;
|
||||
const profile = getCharacterProfile(userId);
|
||||
|
||||
if (!profile) {
|
||||
return res.json({ ok: true, data: null });
|
||||
}
|
||||
|
||||
res.json({ ok: true, data: profile });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
routes: {
|
||||
postCharacter: wrap(handleCreate),
|
||||
getCharacter: wrap(handleGet),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
const config = require('./config');
|
||||
const { createApp } = require('./app');
|
||||
|
||||
const app = createApp();
|
||||
|
||||
const server = app.listen(config.port, () => {
|
||||
console.log(`Emberfall server running on http://localhost:${config.port}`);
|
||||
});
|
||||
|
||||
module.exports = { app, server };
|
||||
@@ -0,0 +1,77 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { getDb } = require('../db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
const BCRYPT_COST = 12;
|
||||
|
||||
// Prepared statements — created once at module load
|
||||
const stmts = {
|
||||
findByUsername: (db) => db.prepare(
|
||||
'SELECT id, username, password_hash, is_banned FROM users WHERE username = ? COLLATE NOCASE'
|
||||
),
|
||||
insertUser: (db) => db.prepare(
|
||||
'INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)'
|
||||
),
|
||||
};
|
||||
|
||||
let cachedStmts;
|
||||
|
||||
function getStmts() {
|
||||
if (!cachedStmts) {
|
||||
const db = getDb();
|
||||
cachedStmts = {
|
||||
findByUsername: stmts.findByUsername(db),
|
||||
insertUser: stmts.insertUser(db),
|
||||
};
|
||||
}
|
||||
return cachedStmts;
|
||||
}
|
||||
|
||||
async function register(username, password) {
|
||||
const db = getDb();
|
||||
const s = getStmts();
|
||||
|
||||
// Check if username already exists (case-insensitive)
|
||||
const existing = s.findByUsername.get(username);
|
||||
if (existing) {
|
||||
throw new AppError('USERNAME_TAKEN', 'Username is already taken.', 409);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_COST);
|
||||
const createdAt = Date.now();
|
||||
|
||||
const result = s.insertUser.run(username, passwordHash, createdAt);
|
||||
const user = { id: result.lastInsertRowid, username };
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function verifyLogin(username, password) {
|
||||
const db = getDb();
|
||||
const s = getStmts();
|
||||
|
||||
// Look up user (case-insensitive)
|
||||
const user = s.findByUsername.get(username);
|
||||
if (!user) {
|
||||
// Dummy hash to prevent timing attacks
|
||||
await bcrypt.hash(password, BCRYPT_COST);
|
||||
throw new AppError('INVALID_CREDENTIALS', 'Invalid username or password.', 401);
|
||||
}
|
||||
|
||||
if (user.is_banned) {
|
||||
throw new AppError('BANNED', 'Your account has been banned.', 403);
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
throw new AppError('INVALID_CREDENTIALS', 'Invalid username or password.', 401);
|
||||
}
|
||||
|
||||
// Update last_login_at
|
||||
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(Date.now(), user.id);
|
||||
|
||||
const { password_hash: _, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
module.exports = { register, verifyLogin };
|
||||
@@ -0,0 +1,180 @@
|
||||
// Character service: create and retrieve characters with derived stats and energy.
|
||||
const { getDb } = require('../db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { getBaseStats } = require('../game/classes');
|
||||
const { calcDerived, computeEnergy, ENERGY_MAX } = require('../game/stats');
|
||||
const { computeEnergy: computeEnergyPure } = require('../game/energy');
|
||||
|
||||
const ENERGY_UPDATED_AT_DEFAULT = Date.now();
|
||||
|
||||
function wrapStmts(db) {
|
||||
return {
|
||||
findByUserId: db.prepare(
|
||||
'SELECT * FROM characters WHERE user_id = ?'
|
||||
),
|
||||
findByName: db.prepare(
|
||||
'SELECT id FROM characters WHERE name = ? COLLATE NOCASE'
|
||||
),
|
||||
insertCharacter: db.prepare(
|
||||
`INSERT INTO characters
|
||||
(user_id, name, class, level, xp, gold, str, agi, int, vit,
|
||||
unspent_points, hp_current, energy, energy_updated_at, kills, deaths, created_at)
|
||||
VALUES (?, ?, ?, 1, 0, 50, ?, ?, ?, ?, 0, ?, ?, ?, 0, 0, ?)`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let cachedStmts;
|
||||
|
||||
function getStmts() {
|
||||
if (!cachedStmts) {
|
||||
const db = getDb();
|
||||
cachedStmts = wrapStmts(db);
|
||||
}
|
||||
return cachedStmts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new character for the current user.
|
||||
*/
|
||||
function createCharacter(userId, name, classKey) {
|
||||
const db = getDb();
|
||||
const stmts = getStmts();
|
||||
const baseStats = getBaseStats(classKey);
|
||||
const now = Date.now();
|
||||
|
||||
// Check if user already has a character
|
||||
const existing = stmts.findByUserId.get(userId);
|
||||
if (existing) {
|
||||
throw new AppError('HERO_EXISTS', 'You already have a character.', 409);
|
||||
}
|
||||
|
||||
// Check if name is taken
|
||||
const nameCheck = stmts.findByName.get(name);
|
||||
if (nameCheck) {
|
||||
throw new AppError('NAME_TAKEN', 'Hero name is already taken.', 409);
|
||||
}
|
||||
|
||||
const hpCurrent = 50 + baseStats.vit * 10 + 5; // level 1: 50 + vit*10 + level*5
|
||||
|
||||
const result = stmts.insertCharacter.run(
|
||||
userId, name, classKey,
|
||||
baseStats.str, baseStats.agi, baseStats.int, baseStats.vit,
|
||||
hpCurrent, ENERGY_MAX, ENERGY_UPDATED_AT_DEFAULT, now
|
||||
);
|
||||
|
||||
return { id: result.lastInsertRowid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get character profile with derived stats and current energy.
|
||||
*/
|
||||
function getCharacterProfile(userId) {
|
||||
const stmts = getStmts();
|
||||
const character = stmts.findByUserId.get(userId);
|
||||
|
||||
if (!character) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute derived stats (with no equipment bonuses for now)
|
||||
const derived = calcDerived(
|
||||
{ str: character.str, agi: character.agi, int: character.int, vit: character.vit },
|
||||
character.level,
|
||||
character.class
|
||||
);
|
||||
|
||||
// Compute current energy with lazy regen
|
||||
const energy = computeEnergy(character.energy, character.energy_updated_at, Date.now());
|
||||
|
||||
// Update energy in DB if it changed
|
||||
if (energy.energy !== character.energy || energy.updatedAtMs !== character.energy_updated_at) {
|
||||
const updateEnergy = getDb().prepare(
|
||||
'UPDATE characters SET energy = ?, energy_updated_at = ? WHERE id = ?'
|
||||
);
|
||||
updateEnergy.run(energy.energy, energy.updatedAtMs, character.id);
|
||||
|
||||
// Refresh character data
|
||||
Object.assign(character, { energy: energy.energy, energy_updated_at: energy.updatedAtMs });
|
||||
}
|
||||
|
||||
// Calculate next energy tick
|
||||
const now = Date.now();
|
||||
const nextTickMs = character.energy_updated_at + (ENERGY_MAX - character.energy) * 180 * 1000;
|
||||
const nextTickSec = Math.max(0, Math.ceil((nextTickMs - now) / 1000));
|
||||
|
||||
return {
|
||||
character: {
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
class: character.class,
|
||||
level: character.level,
|
||||
xp: character.xp,
|
||||
gold: character.gold,
|
||||
str: character.str,
|
||||
agi: character.agi,
|
||||
int: character.int,
|
||||
vit: character.vit,
|
||||
unspent_points: character.unspent_points,
|
||||
hp_current: character.hp_current,
|
||||
maxHp: derived.maxHp,
|
||||
kills: character.kills,
|
||||
deaths: character.deaths,
|
||||
},
|
||||
derived: {
|
||||
maxHp: derived.maxHp,
|
||||
attack: derived.attack,
|
||||
defense: derived.defense,
|
||||
speed: derived.speed,
|
||||
critChance: derived.critChance,
|
||||
critMult: derived.critMult,
|
||||
},
|
||||
energy: {
|
||||
current: character.energy,
|
||||
max: ENERGY_MAX,
|
||||
nextTickInSec: nextTickSec,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend energy from a character. Runs in a transaction.
|
||||
* First applies lazy energy regen, then deducts the cost.
|
||||
* @param {number} characterId - the character's ID
|
||||
* @param {number} cost - energy to spend
|
||||
* @returns {object} { energy, updatedAtMs } new energy state
|
||||
*/
|
||||
function spendEnergy(characterId, cost) {
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
|
||||
// Validate cost
|
||||
if (typeof cost !== 'number' || cost <= 0 || !Number.isInteger(cost)) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Cost must be a positive integer.', 400);
|
||||
}
|
||||
|
||||
// Get character
|
||||
const character = db.prepare('SELECT * FROM characters WHERE id = ?').get(characterId);
|
||||
if (!character) {
|
||||
throw new AppError('NOT_FOUND', 'Character not found.', 404);
|
||||
}
|
||||
|
||||
// Compute current energy with lazy regen
|
||||
const energy = computeEnergyPure(character.energy, character.energy_updated_at, now);
|
||||
|
||||
// Check if enough energy
|
||||
if (energy.energy < cost) {
|
||||
throw new AppError('INSUFFICIENT_ENERGY', `Not enough energy. Have ${energy.energy}, need ${cost}.`, 422);
|
||||
}
|
||||
|
||||
// Deduct energy in a transaction
|
||||
const updateEnergy = db.transaction((newEnergy, newUpdatedAt, cid) => {
|
||||
db.prepare('UPDATE characters SET energy = ?, energy_updated_at = ? WHERE id = ?').run(newEnergy, newUpdatedAt, cid);
|
||||
});
|
||||
|
||||
updateEnergy(energy.energy - cost, energy.updatedAtMs, characterId);
|
||||
|
||||
return { energy: energy.energy - cost, updatedAtMs: energy.updatedAtMs };
|
||||
}
|
||||
|
||||
module.exports = { createCharacter, getCharacterProfile, spendEnergy };
|
||||
@@ -0,0 +1,9 @@
|
||||
class AppError extends Error {
|
||||
constructor(code, message, status = 400) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AppError };
|
||||
Reference in New Issue
Block a user