Files
Emberfall/src/services/characterService.js
T

181 lines
5.5 KiB
JavaScript

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