181 lines
5.5 KiB
JavaScript
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 };
|