First commit, phased 1 and 2 created but untested
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user