step 10.11: done
This commit is contained in:
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user