First commit, phased 1 and 2 created but untested

This commit is contained in:
bepis
2026-10-20 07:40:35 +11:00
commit 7a3e408ffc
66 changed files with 5639 additions and 0 deletions
+81
View File
@@ -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 };
View File
+27
View File
@@ -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;
+28
View File
@@ -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 };
View File
+10
View File
@@ -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);
+25
View File
@@ -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);
View File
View File
+44
View File
@@ -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 };
+47
View File
@@ -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,
};
+111
View File
@@ -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,
};
View File
+50
View File
@@ -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 };
View File
+90
View File
@@ -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),
},
};
+51
View File
@@ -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),
},
};
+10
View File
@@ -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 };
View File
+77
View File
@@ -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 };
+180
View File
@@ -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 };
View File
+9
View File
@@ -0,0 +1,9 @@
class AppError extends Error {
constructor(code, message, status = 400) {
super(message);
this.code = code;
this.status = status;
}
}
module.exports = { AppError };