step 10.11: done

This commit is contained in:
bepis
2026-10-23 02:33:16 +11:00
parent e124266c76
commit 08d880ab73
9 changed files with 451 additions and 20 deletions
+7
View File
@@ -0,0 +1,7 @@
-- Migration 007: Natural HP regeneration
-- Adds hp_updated_at column for lazy HP regen computation.
ALTER TABLE characters ADD COLUMN hp_updated_at INTEGER NOT NULL DEFAULT 0;
-- Backfill existing rows: use energy_updated_at as the initial HP timestamp
UPDATE characters SET hp_updated_at = energy_updated_at WHERE hp_updated_at = 0;
+45
View File
@@ -0,0 +1,45 @@
// HP regeneration: pure function based on GAME_DESIGN.md (§Time).
// +1% of max HP per minute, computed lazily like energy.
const HP_REGEN_SECONDS = 60; // +1% max HP per 60 seconds
/**
* Compute current HP based on time elapsed since last update.
* Uses lazy evaluation: no timers, just math on timestamps.
*
* @param {number} stored - stored HP value
* @param {number} maxHp - maximum HP (for cap calculation)
* @param {number} updatedAtMs - last HP update timestamp (ms epoch)
* @param {number} nowMs - current time (ms epoch)
* @returns {{ hp: number, updatedAtMs: number }}
*/
function computeHp(stored, maxHp, updatedAtMs, nowMs) {
// Clock went backwards or same time — return as-is
if (nowMs <= updatedAtMs) {
return { hp: stored, updatedAtMs };
}
const elapsedMs = nowMs - updatedAtMs;
// Each full minute adds 1% of max HP (floored to integer)
const ticks = Math.floor(elapsedMs / (HP_REGEN_SECONDS * 1000));
if (ticks <= 0) {
return { hp: stored, updatedAtMs };
}
const gained = Math.floor(ticks * maxHp / 100);
const newHp = stored + gained;
// Cap at max and reset timestamp
if (newHp >= maxHp) {
return { hp: maxHp, updatedAtMs: nowMs };
}
// Advance timestamp by whole ticks only
return {
hp: newHp,
updatedAtMs: updatedAtMs + ticks * HP_REGEN_SECONDS * 1000,
};
}
module.exports = { computeHp, HP_REGEN_SECONDS };
+31 -8
View File
@@ -5,6 +5,7 @@ const { AppError } = require('../utils/errors');
const { getBaseStats } = require('../game/classes');
const { calcDerived, computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('../game/stats');
const { computeEnergy: computeEnergyPure } = require('../game/energy');
const { computeHp } = require('../game/hp');
function wrapStmts(db) {
@@ -18,8 +19,8 @@ function wrapStmts(db) {
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, ?)`
unspent_points, hp_current, energy, energy_updated_at, hp_updated_at, kills, deaths, created_at)
VALUES (?, ?, ?, 1, 0, 50, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, 0, ?)`
),
};
}
@@ -60,7 +61,7 @@ function createCharacter(userId, name, classKey) {
const result = stmts.insertCharacter.run(
userId, name, classKey,
baseStats.str, baseStats.agi, baseStats.int, baseStats.vit,
hpCurrent, ENERGY_MAX, now, now
hpCurrent, ENERGY_MAX, now, now, now
);
return { id: result.lastInsertRowid };
@@ -98,8 +99,29 @@ function getCharacterProfile(userId) {
Object.assign(character, { energy: energy.energy, energy_updated_at: energy.updatedAtMs });
}
// Calculate next energy tick
// Compute current HP with lazy regen
const now = time.now();
const hpUpdatedAt = character.hp_updated_at ?? character.energy_updated_at;
const hp = computeHp(character.hp_current, derived.maxHp, hpUpdatedAt, now);
// Update HP in DB if it changed
if (hp.hp !== character.hp_current || hp.updatedAtMs !== character.hp_updated_at) {
const updateHp = getDb().prepare(
'UPDATE characters SET hp_current = ?, hp_updated_at = ? WHERE id = ?'
);
updateHp.run(hp.hp, hp.updatedAtMs, character.id);
// Refresh character data
Object.assign(character, { hp_current: hp.hp, hp_updated_at: hp.updatedAtMs });
// Update derived maxHp since HP may have changed
derived.maxHp = calcDerived(
{ str: character.str, agi: character.agi, int: character.int, vit: character.vit },
character.level,
character.class
).maxHp;
}
// Calculate next energy tick
// Seconds until the NEXT +1 energy (0 when full). Not the time until full.
const nextTickMs = character.energy_updated_at + ENERGY_REGEN_SECONDS * 1000;
const nextTickSec = character.energy >= ENERGY_MAX ? 0 : Math.max(0, Math.ceil((nextTickMs - now) / 1000));
@@ -320,13 +342,14 @@ function rest(userId) {
}
// Heal to full and deduct gold in a transaction
const update = db.transaction((hp, gold, cid) => {
const now = time.now();
const update = db.transaction((hp, gold, hat, cid) => {
db.prepare(
'UPDATE characters SET hp_current = ?, gold = ? WHERE id = ?'
).run(hp, gold, cid);
'UPDATE characters SET hp_current = ?, gold = ?, hp_updated_at = ? WHERE id = ?'
).run(hp, gold, hat, cid);
});
update(derived.maxHp, character.gold - cost, character.id);
update(derived.maxHp, character.gold - cost, now, character.id);
// Refresh character data
const updated = db.prepare('SELECT * FROM characters WHERE id = ?').get(character.id);
+9 -3
View File
@@ -79,6 +79,11 @@ function getStmts(db) {
'UPDATE characters SET xp = ?, level = ?, unspent_points = ? WHERE id = ?'
),
// Update character HP timestamp.
updateHpTimestamp: db.prepare(
'UPDATE characters SET hp_updated_at = ? WHERE id = ?'
),
// List all expedition definitions.
listDefs: db.prepare(
'SELECT * FROM expedition_defs ORDER BY min_level, id'
@@ -250,14 +255,15 @@ function claimExpedition(characterId, expeditionId, opts = {}) {
// Apply XP gain (adds to current, handles level-up, accumulates stat points).
const xpResult = applyXpGain(ch.level, ch.xp, ch.unspent_points, xpGained);
// Execute inside a transaction: claim + grant rewards.
const doClaim = db.transaction((eat, eid, ng, cid, xp, lvl, pts) => {
// Execute inside a transaction: claim + grant rewards + update HP timestamp.
const doClaim = db.transaction((eat, eid, ng, cid, xp, lvl, pts, hat) => {
s.claimExpedition.run(eat, eid);
s.updateGold.run(ng, cid);
s.updateCharacterXp.run(xp, lvl, pts, cid);
s.updateHpTimestamp.run(hat, cid);
});
doClaim(nowMs, expeditionId, newGold, characterId, xpResult.xp, xpResult.level, xpResult.unspentPoints);
doClaim(nowMs, expeditionId, newGold, characterId, xpResult.xp, xpResult.level, xpResult.unspentPoints, nowMs);
return {
xpGained,
+9 -3
View File
@@ -10,6 +10,7 @@ const { calcDerived } = require('../game/stats');
const { resolveCombat } = require('../game/combat');
const { calculateRewards, rollLoot } = require('../game/rewards');
const { checkLevelUp, applyXpGain } = require('../game/leveling');
const { computeHp } = require('../game/hp');
const MIN_HP_PCT_TO_HUNT = 0.2;
const DEFEAT_GOLD_LOSS_PCT = 0.10;
@@ -34,7 +35,7 @@ function getStmts(db) {
'INSERT INTO inventory (character_id, item_id, quantity, acquired_at) VALUES (?, ?, ?, ?)'
),
updateChar: db.prepare(
`UPDATE characters SET hp_current = ?, energy = ?, energy_updated_at = ?, xp = ?, gold = ?,
`UPDATE characters SET hp_current = ?, hp_updated_at = ?, energy = ?, energy_updated_at = ?, xp = ?, gold = ?,
level = ?, unspent_points = ?, kills = ?, deaths = ? WHERE id = ?`
),
insertLog: db.prepare(
@@ -94,7 +95,12 @@ function hunt(characterId, zoneId, opts = {}) {
const baseStats = { str: ch.str, agi: ch.agi, int: ch.int, vit: ch.vit };
const derived = calcDerived(baseStats, ch.level, ch.class);
if (ch.hp_current < derived.maxHp * MIN_HP_PCT_TO_HUNT) {
// Apply lazy HP regen before checking HP threshold
const hpUpdatedAt = ch.hp_updated_at ?? ch.energy_updated_at;
const hpRegen = computeHp(ch.hp_current, derived.maxHp, hpUpdatedAt, now);
if (hpRegen.hp < derived.maxHp * MIN_HP_PCT_TO_HUNT) {
throw new AppError('HP_TOO_LOW', 'Your HP is too low. Rest or use a potion.', 422);
}
@@ -149,7 +155,7 @@ function hunt(characterId, zoneId, opts = {}) {
deaths += 1;
}
s.updateChar.run(hp, energy.energy - zone.energy_cost, energy.updatedAtMs, xp, gold, level, unspent, kills, deaths, characterId);
s.updateChar.run(hp, now, energy.energy - zone.energy_cost, energy.updatedAtMs, xp, gold, level, unspent, kills, deaths, characterId);
s.insertLog.run(characterId, monster.id, fight.winner === 'hero' ? 1 : 0, fight.rounds.length, xpGained, goldGained, now);
return {
+10 -3
View File
@@ -3,6 +3,8 @@
const { getDb } = require('../db');
const { AppError } = require('../utils/errors');
const { calcDerived, ENERGY_MAX } = require('../game/stats');
const { computeHp } = require('../game/hp');
const time = require('../utils/time');
const VALID_SLOTS = new Set(['weapon', 'armor', 'trinket']);
@@ -272,15 +274,20 @@ function useConsumable(characterId, itemId) {
const invRow = s.inventoryRowForUse.get(characterId, item.id);
if (!invRow) throw new AppError('NOT_FOUND', 'Item not in inventory or depleted.', 404);
const now = time.now();
// Apply effect inside a transaction.
const applyEffect = db.transaction((rowId, qty, etype, evalue, charId, c) => {
const applyEffect = db.transaction((rowId, qty, etype, evalue, charId, c, hat) => {
// Apply the effect.
if (etype === 'heal') {
const maxHp = calcDerived(
{ str: c.str, agi: c.agi, int: c.int, vit: c.vit },
c.level, c.class
).maxHp;
const newHp = Math.min(c.hp_current + evalue, maxHp);
// Apply lazy HP regen before healing
const hpUpdatedAt = c.hp_updated_at ?? c.energy_updated_at;
const regenerated = computeHp(c.hp_current, maxHp, hpUpdatedAt, hat);
const newHp = Math.min(regenerated.hp + evalue, maxHp);
s.updateHp.run(newHp, charId);
} else if (etype === 'energy') {
const { computeEnergy } = require('../game/stats');
@@ -299,7 +306,7 @@ function useConsumable(characterId, itemId) {
}
});
applyEffect(invRow.id, invRow.quantity, effectType, effectValue, characterId, ch);
applyEffect(invRow.id, invRow.quantity, effectType, effectValue, characterId, ch, now);
// Return updated listing.
return listInventory(characterId);