// 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 };