step 10.11: done
This commit is contained in:
@@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
## Current Focus
|
## Current Focus
|
||||||
- **Phase:** 10
|
- **Phase:** 10
|
||||||
- **Step:** 10.11 — Natural HP regeneration: +1% max HP/min, lazy computation, new migration adds hp_updated_at
|
- **Step:** 10.12 — Equipment must matter in combat: ONE getEquipmentBonuses function, integrate into huntService, getCharacterProfile, potion capping, shop/inventory previews
|
||||||
- **Blocked?** No
|
- **Blocked?** No
|
||||||
- **Last verified working command:** `npm test` — 227/227 passing (85 unit + 142 integration incl. 5 rest endpoint tests), 2026-10-23 step 10.10
|
- **Last verified working command:** `npm test` — 245/245 passing (97 unit + 148 integration incl. 12 hp unit + 6 hp regen integration), 2026-10-23 step 10.11
|
||||||
|
|
||||||
## Project Health
|
## Project Health
|
||||||
| Item | State |
|
| Item | State |
|
||||||
@@ -171,8 +171,9 @@
|
|||||||
- [x] **10.10** Add `POST /api/character/rest` (API.md, BALANCE.md §Rest): costs `level * 5` gold, heals to full max HP. Errors: `NOTHING_TO_HEAL` (422) if HP is already full, `INSUFFICIENT_GOLD` (422). One transaction. Add a "Rest (N gold)" button to the dashboard.
|
- [x] **10.10** Add `POST /api/character/rest` (API.md, BALANCE.md §Rest): costs `level * 5` gold, heals to full max HP. Errors: `NOTHING_TO_HEAL` (422) if HP is already full, `INSUFFICIENT_GOLD` (422). One transaction. Add a "Rest (N gold)" button to the dashboard.
|
||||||
*Acceptance:* tests: HP 10/135 level 3 with gold 100 → HP 135, gold 85; full HP → 422 and gold unchanged; gold 5 → 422; HP unchanged on failure.
|
*Acceptance:* tests: HP 10/135 level 3 with gold 100 → HP 135, gold 85; full HP → 422 and gold unchanged; gold 5 → 422; HP unchanged on failure.
|
||||||
*Verified:* 5 integration tests (happy heal, full HP rejection, insufficient gold, cross-user isolation, level scaling); `rest()` in characterService.js with one transaction; `handleRest` in character routes; mounted in app.js; dashboard button in game.html/game.js with CSS styles; hidden when HP at max. 227/227 tests pass.
|
*Verified:* 5 integration tests (happy heal, full HP rejection, insufficient gold, cross-user isolation, level scaling); `rest()` in characterService.js with one transaction; `handleRest` in character routes; mounted in app.js; dashboard button in game.html/game.js with CSS styles; hidden when HP at max. 227/227 tests pass.
|
||||||
- [ ] **10.11** Natural HP regeneration (GAME_DESIGN §Time): +1% of max HP per minute, computed lazily like energy. New migration (next unused number) adds `hp_updated_at INTEGER` (backfill existing rows with `energy_updated_at`). Add a pure function `computeHp(current, maxHp, updatedAtMs, nowMs)` in `src/game/hp.js` with unit tests (partial minute, cap at max, clock backwards, large gap). Apply it before the HP check in hunting, in `getCharacterProfile`, in potion use, and in rest. Heals/damage must set `hp_updated_at = now`.
|
- [x] **10.11** Natural HP regeneration (GAME_DESIGN §Time): +1% of max HP per minute, computed lazily like energy. New migration (next unused number) adds `hp_updated_at INTEGER` (backfill existing rows with `energy_updated_at`). Add a pure function `computeHp(current, maxHp, updatedAtMs, nowMs)` in `src/game/hp.js` with unit tests (partial minute, cap at max, clock backwards, large gap). Apply it before the HP check in hunting, in `getCharacterProfile`, in potion use, and in rest. Heals/damage must set `hp_updated_at = now`.
|
||||||
*Acceptance:* unit tests for `computeHp`; integration test: hero at 1 HP, 30 minutes later hunting is allowed again (≥ 20% of max HP restored by lazy regen, using `time.setNow`). A defeated hero can no longer soft-lock.
|
*Acceptance:* unit tests for `computeHp`; integration test: hero at 1 HP, 30 minutes later hunting is allowed again (≥ 20% of max HP restored by lazy regen, using `time.setNow`). A defeated hero can no longer soft-lock.
|
||||||
|
*Verified:* 12 unit tests for `computeHp`; 6 integration tests (30-min regen ≥20%, fresh char hp_updated_at set, rest sets hp_updated_at, <1min no change, cap at maxHp, potion applies regen before heal); migration 007 adds column; computeHp integrated in huntService, characterService (getCharacterProfile + rest), inventoryService (potion use), expeditionService (claim). 245/245 tests pass.
|
||||||
- [ ] **10.12** Equipment must matter in combat. Create ONE `getEquipmentBonuses(characterId)` function (sum of `bonus_str/agi/int/vit/attack/defense` over equipped items) and use it in `huntService` (pass the totals to `resolveCombat` as `bonus_*`), in `getCharacterProfile` (derived stats), in potion max-HP capping, and in the shop/inventory stat previews. Delete the copy-pasted aggregation code in `shopService.js` and `inventoryService.js`.
|
- [ ] **10.12** Equipment must matter in combat. Create ONE `getEquipmentBonuses(characterId)` function (sum of `bonus_str/agi/int/vit/attack/defense` over equipped items) and use it in `huntService` (pass the totals to `resolveCombat` as `bonus_*`), in `getCharacterProfile` (derived stats), in potion max-HP capping, and in the shop/inventory stat previews. Delete the copy-pasted aggregation code in `shopService.js` and `inventoryService.js`.
|
||||||
*Acceptance:* deterministic tests with a constant rng: the first hero hit is exactly higher with an equipped weapon than without; `GET /api/character` `derived.attack` includes the weapon bonus; equipment `bonus_vit` raises max HP exactly once; grep shows only one aggregation implementation.
|
*Acceptance:* deterministic tests with a constant rng: the first hero hit is exactly higher with an equipped weapon than without; `GET /api/character` `derived.attack` includes the weapon bonus; equipment `bonus_vit` raises max HP exactly once; grep shows only one aggregation implementation.
|
||||||
- [ ] **10.13** Starter kit (GAME_DESIGN §Items): `createCharacter` grants, in the same transaction, one class-appropriate common weapon (warrior Rusty Sword, mage Apprentice Staff, ranger Hunter's Shortbow) already equipped in the weapon slot, plus 2 Minor Healing Potions.
|
- [ ] **10.13** Starter kit (GAME_DESIGN §Items): `createCharacter` grants, in the same transaction, one class-appropriate common weapon (warrior Rusty Sword, mage Apprentice Staff, ranger Hunter's Shortbow) already equipped in the weapon slot, plus 2 Minor Healing Potions.
|
||||||
|
|||||||
@@ -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 { getBaseStats } = require('../game/classes');
|
||||||
const { calcDerived, computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('../game/stats');
|
const { calcDerived, computeEnergy, ENERGY_MAX, ENERGY_REGEN_SECONDS } = require('../game/stats');
|
||||||
const { computeEnergy: computeEnergyPure } = require('../game/energy');
|
const { computeEnergy: computeEnergyPure } = require('../game/energy');
|
||||||
|
const { computeHp } = require('../game/hp');
|
||||||
|
|
||||||
|
|
||||||
function wrapStmts(db) {
|
function wrapStmts(db) {
|
||||||
@@ -18,8 +19,8 @@ function wrapStmts(db) {
|
|||||||
insertCharacter: db.prepare(
|
insertCharacter: db.prepare(
|
||||||
`INSERT INTO characters
|
`INSERT INTO characters
|
||||||
(user_id, name, class, level, xp, gold, str, agi, int, vit,
|
(user_id, name, class, level, xp, gold, str, agi, int, vit,
|
||||||
unspent_points, hp_current, energy, energy_updated_at, kills, deaths, created_at)
|
unspent_points, hp_current, energy, energy_updated_at, hp_updated_at, kills, deaths, created_at)
|
||||||
VALUES (?, ?, ?, 1, 0, 50, ?, ?, ?, ?, 0, ?, ?, ?, 0, 0, ?)`
|
VALUES (?, ?, ?, 1, 0, 50, ?, ?, ?, ?, 0, ?, ?, ?, ?, 0, 0, ?)`
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ function createCharacter(userId, name, classKey) {
|
|||||||
const result = stmts.insertCharacter.run(
|
const result = stmts.insertCharacter.run(
|
||||||
userId, name, classKey,
|
userId, name, classKey,
|
||||||
baseStats.str, baseStats.agi, baseStats.int, baseStats.vit,
|
baseStats.str, baseStats.agi, baseStats.int, baseStats.vit,
|
||||||
hpCurrent, ENERGY_MAX, now, now
|
hpCurrent, ENERGY_MAX, now, now, now
|
||||||
);
|
);
|
||||||
|
|
||||||
return { id: result.lastInsertRowid };
|
return { id: result.lastInsertRowid };
|
||||||
@@ -98,8 +99,29 @@ function getCharacterProfile(userId) {
|
|||||||
Object.assign(character, { energy: energy.energy, energy_updated_at: energy.updatedAtMs });
|
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 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.
|
// 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 nextTickMs = character.energy_updated_at + ENERGY_REGEN_SECONDS * 1000;
|
||||||
const nextTickSec = character.energy >= ENERGY_MAX ? 0 : Math.max(0, Math.ceil((nextTickMs - now) / 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
|
// 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(
|
db.prepare(
|
||||||
'UPDATE characters SET hp_current = ?, gold = ? WHERE id = ?'
|
'UPDATE characters SET hp_current = ?, gold = ?, hp_updated_at = ? WHERE id = ?'
|
||||||
).run(hp, gold, cid);
|
).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
|
// Refresh character data
|
||||||
const updated = db.prepare('SELECT * FROM characters WHERE id = ?').get(character.id);
|
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 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.
|
// List all expedition definitions.
|
||||||
listDefs: db.prepare(
|
listDefs: db.prepare(
|
||||||
'SELECT * FROM expedition_defs ORDER BY min_level, id'
|
'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).
|
// Apply XP gain (adds to current, handles level-up, accumulates stat points).
|
||||||
const xpResult = applyXpGain(ch.level, ch.xp, ch.unspent_points, xpGained);
|
const xpResult = applyXpGain(ch.level, ch.xp, ch.unspent_points, xpGained);
|
||||||
|
|
||||||
// Execute inside a transaction: claim + grant rewards.
|
// Execute inside a transaction: claim + grant rewards + update HP timestamp.
|
||||||
const doClaim = db.transaction((eat, eid, ng, cid, xp, lvl, pts) => {
|
const doClaim = db.transaction((eat, eid, ng, cid, xp, lvl, pts, hat) => {
|
||||||
s.claimExpedition.run(eat, eid);
|
s.claimExpedition.run(eat, eid);
|
||||||
s.updateGold.run(ng, cid);
|
s.updateGold.run(ng, cid);
|
||||||
s.updateCharacterXp.run(xp, lvl, pts, 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 {
|
return {
|
||||||
xpGained,
|
xpGained,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const { calcDerived } = require('../game/stats');
|
|||||||
const { resolveCombat } = require('../game/combat');
|
const { resolveCombat } = require('../game/combat');
|
||||||
const { calculateRewards, rollLoot } = require('../game/rewards');
|
const { calculateRewards, rollLoot } = require('../game/rewards');
|
||||||
const { checkLevelUp, applyXpGain } = require('../game/leveling');
|
const { checkLevelUp, applyXpGain } = require('../game/leveling');
|
||||||
|
const { computeHp } = require('../game/hp');
|
||||||
|
|
||||||
const MIN_HP_PCT_TO_HUNT = 0.2;
|
const MIN_HP_PCT_TO_HUNT = 0.2;
|
||||||
const DEFEAT_GOLD_LOSS_PCT = 0.10;
|
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 (?, ?, ?, ?)'
|
'INSERT INTO inventory (character_id, item_id, quantity, acquired_at) VALUES (?, ?, ?, ?)'
|
||||||
),
|
),
|
||||||
updateChar: db.prepare(
|
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 = ?`
|
level = ?, unspent_points = ?, kills = ?, deaths = ? WHERE id = ?`
|
||||||
),
|
),
|
||||||
insertLog: db.prepare(
|
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 baseStats = { str: ch.str, agi: ch.agi, int: ch.int, vit: ch.vit };
|
||||||
const derived = calcDerived(baseStats, ch.level, ch.class);
|
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);
|
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;
|
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);
|
s.insertLog.run(characterId, monster.id, fight.winner === 'hero' ? 1 : 0, fight.rounds.length, xpGained, goldGained, now);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
const { getDb } = require('../db');
|
const { getDb } = require('../db');
|
||||||
const { AppError } = require('../utils/errors');
|
const { AppError } = require('../utils/errors');
|
||||||
const { calcDerived, ENERGY_MAX } = require('../game/stats');
|
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']);
|
const VALID_SLOTS = new Set(['weapon', 'armor', 'trinket']);
|
||||||
|
|
||||||
@@ -272,15 +274,20 @@ function useConsumable(characterId, itemId) {
|
|||||||
const invRow = s.inventoryRowForUse.get(characterId, item.id);
|
const invRow = s.inventoryRowForUse.get(characterId, item.id);
|
||||||
if (!invRow) throw new AppError('NOT_FOUND', 'Item not in inventory or depleted.', 404);
|
if (!invRow) throw new AppError('NOT_FOUND', 'Item not in inventory or depleted.', 404);
|
||||||
|
|
||||||
|
const now = time.now();
|
||||||
|
|
||||||
// Apply effect inside a transaction.
|
// 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.
|
// Apply the effect.
|
||||||
if (etype === 'heal') {
|
if (etype === 'heal') {
|
||||||
const maxHp = calcDerived(
|
const maxHp = calcDerived(
|
||||||
{ str: c.str, agi: c.agi, int: c.int, vit: c.vit },
|
{ str: c.str, agi: c.agi, int: c.int, vit: c.vit },
|
||||||
c.level, c.class
|
c.level, c.class
|
||||||
).maxHp;
|
).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);
|
s.updateHp.run(newHp, charId);
|
||||||
} else if (etype === 'energy') {
|
} else if (etype === 'energy') {
|
||||||
const { computeEnergy } = require('../game/stats');
|
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 updated listing.
|
||||||
return listInventory(characterId);
|
return listInventory(characterId);
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Integration tests for natural HP regeneration (step 10.11).
|
||||||
|
// IMPORTANT: time.setNow() must be called on the time module imported AFTER setup()
|
||||||
|
// because setup() clears the require cache and recreates the time module.
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { setup, teardown, cleanup } = require('../helpers');
|
||||||
|
|
||||||
|
describe('Step 10.11 — Natural HP regeneration', () => {
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
async function registerAndLogin(username) {
|
||||||
|
await fetch(`${baseUrl}/api/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password: 'password123' }),
|
||||||
|
});
|
||||||
|
const res = await fetch(`${baseUrl}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password: 'password123' }),
|
||||||
|
});
|
||||||
|
return res.headers.get('set-cookie');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createHero(cookie, name) {
|
||||||
|
await fetch(`${baseUrl}/api/character`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||||
|
body: JSON.stringify({ name, class: 'warrior' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupHero(username, name, hp, gold) {
|
||||||
|
const cookie = await registerAndLogin(username);
|
||||||
|
await createHero(cookie, name);
|
||||||
|
const db = require('../../src/db').getDb();
|
||||||
|
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get(username).id);
|
||||||
|
const char = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId);
|
||||||
|
db.prepare('UPDATE characters SET hp_current = ?, gold = ? WHERE id = ?').run(hp, gold, char.id);
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
baseUrl = await setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
// Reset time on the fresh module
|
||||||
|
const time = require('../../src/utils/time');
|
||||||
|
time.resetNow();
|
||||||
|
await teardown();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hero at 1 HP recovers after 30 minutes (lazy regen restores ≥20%)', async () => {
|
||||||
|
const cookie = await setupHero('hpregen1', 'HpRegen1', 1, 100);
|
||||||
|
|
||||||
|
// Import time AFTER setup() cleared the cache
|
||||||
|
const time = require('../../src/utils/time');
|
||||||
|
|
||||||
|
const db = require('../../src/db').getDb();
|
||||||
|
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen1').id);
|
||||||
|
const char = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userId);
|
||||||
|
const maxHp = 50 + char.vit * 10 + char.level * 5;
|
||||||
|
|
||||||
|
// Set HP to 1 and hp_updated_at to a fixed time
|
||||||
|
const startTime = 1000;
|
||||||
|
db.prepare('UPDATE characters SET hp_current = 1, hp_updated_at = ? WHERE id = ?').run(startTime, char.id);
|
||||||
|
|
||||||
|
// Set time AFTER setup() cleared the cache
|
||||||
|
const thirtyMinMs = 30 * 60 * 1000;
|
||||||
|
const later = startTime + thirtyMinMs;
|
||||||
|
time.setNow(() => later);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// HP should have regenerated: 30 ticks * 1% per tick = 30% of maxHp
|
||||||
|
const expectedHp = Math.min(1 + Math.floor(30 * maxHp / 100), maxHp);
|
||||||
|
assert.ok(expectedHp >= maxHp * 0.2, `Expected HP ${expectedHp} >= 20% of ${maxHp}`);
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/character`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.ok, true);
|
||||||
|
assert.equal(body.data.character.hp_current, expectedHp);
|
||||||
|
assert.equal(body.data.derived.maxHp, maxHp);
|
||||||
|
} finally {
|
||||||
|
time.resetNow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fresh character starts with hp_updated_at set', async () => {
|
||||||
|
const cookie = await registerAndLogin('hpregen2');
|
||||||
|
await createHero(cookie, 'HpFresh2');
|
||||||
|
|
||||||
|
const db = require('../../src/db').getDb();
|
||||||
|
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen2').id);
|
||||||
|
const ch = db.prepare('SELECT hp_updated_at FROM characters WHERE user_id = ?').get(userId);
|
||||||
|
assert.ok(ch, 'character row should exist');
|
||||||
|
assert.ok(ch.hp_updated_at > 0, 'hp_updated_at should be set for new character');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rest sets hp_updated_at to now', async () => {
|
||||||
|
const cookie = await setupHero('hpregen3', 'HpRest3', 10, 100);
|
||||||
|
|
||||||
|
const db = require('../../src/db').getDb();
|
||||||
|
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen3').id);
|
||||||
|
const char = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userId);
|
||||||
|
const maxHp = 50 + char.vit * 10 + char.level * 5;
|
||||||
|
|
||||||
|
// Set hp_updated_at to a fixed past time
|
||||||
|
const pastTime = 1000;
|
||||||
|
db.prepare('UPDATE characters SET hp_current = 10, hp_updated_at = ? WHERE id = ?').run(pastTime, char.id);
|
||||||
|
|
||||||
|
// Call rest directly without getCharacter in between
|
||||||
|
const restRes = await fetch(`${baseUrl}/api/character/rest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
});
|
||||||
|
assert.equal(restRes.status, 200);
|
||||||
|
const restData = await restRes.json();
|
||||||
|
assert.equal(restData.ok, true);
|
||||||
|
assert.equal(restData.data.character.hp_current, maxHp);
|
||||||
|
|
||||||
|
// DB should have hp_updated_at updated
|
||||||
|
const updated = db.prepare('SELECT hp_updated_at FROM characters WHERE id = ?').get(char.id);
|
||||||
|
assert.ok(updated.hp_updated_at > pastTime, 'hp_updated_at should be updated by rest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no HP change when less than 1 minute elapsed', async () => {
|
||||||
|
const cookie = await setupHero('hpregen4', 'HpNoRegen4', 10, 100);
|
||||||
|
|
||||||
|
// Import time AFTER setup() cleared the cache
|
||||||
|
const time = require('../../src/utils/time');
|
||||||
|
|
||||||
|
const db = require('../../src/db').getDb();
|
||||||
|
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen4').id);
|
||||||
|
const char = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userId);
|
||||||
|
|
||||||
|
// Set hp_updated_at to now
|
||||||
|
const now = Date.now();
|
||||||
|
db.prepare('UPDATE characters SET hp_current = 10, hp_updated_at = ? WHERE id = ?').run(now, char.id);
|
||||||
|
|
||||||
|
// Simulate 30 seconds passing (less than 1 minute)
|
||||||
|
time.setNow(() => now + 30000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/character`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.ok, true);
|
||||||
|
// HP should not have changed (less than 1 minute)
|
||||||
|
assert.equal(body.data.character.hp_current, 10);
|
||||||
|
} finally {
|
||||||
|
time.resetNow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HP regen caps at maxHp and resets timestamp', async () => {
|
||||||
|
const cookie = await setupHero('hpregen5', 'HpCap5', 5, 100);
|
||||||
|
|
||||||
|
// Import time AFTER setup() cleared the cache
|
||||||
|
const time = require('../../src/utils/time');
|
||||||
|
|
||||||
|
const db = require('../../src/db').getDb();
|
||||||
|
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen5').id);
|
||||||
|
const char = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userId);
|
||||||
|
const maxHp = 50 + char.vit * 10 + char.level * 5;
|
||||||
|
|
||||||
|
// Set HP low and hp_updated_at to a fixed time
|
||||||
|
const startTime = 1000;
|
||||||
|
db.prepare('UPDATE characters SET hp_current = 5, hp_updated_at = ? WHERE id = ?').run(startTime, char.id);
|
||||||
|
|
||||||
|
// Simulate 2 hours passing (should cap at maxHp)
|
||||||
|
const twoHoursMs = 2 * 60 * 60 * 1000;
|
||||||
|
const later = startTime + twoHoursMs;
|
||||||
|
time.setNow(() => later);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/character`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.ok, true);
|
||||||
|
assert.equal(body.data.character.hp_current, maxHp);
|
||||||
|
} finally {
|
||||||
|
time.resetNow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('potion heal applies lazy regen before healing', async () => {
|
||||||
|
const cookie = await setupHero('hpregen6', 'HpPotion6', 5, 200);
|
||||||
|
|
||||||
|
// Import time AFTER setup() cleared the cache
|
||||||
|
const time = require('../../src/utils/time');
|
||||||
|
|
||||||
|
// Buy a Minor Healing Potion
|
||||||
|
const shopRes = await fetch(`${baseUrl}/api/shop`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
});
|
||||||
|
assert.equal(shopRes.status, 200);
|
||||||
|
const shopBody = await shopRes.json();
|
||||||
|
const potion = shopBody.data.stock.find(i => i.slug === 'minor-healing-potion');
|
||||||
|
assert.ok(potion, `Minor Healing Potion should be in shop stock. Available: ${shopBody.data.stock.map(i => i.slug).join(', ')}`);
|
||||||
|
|
||||||
|
const buyRes = await fetch(`${baseUrl}/api/shop/buy`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||||
|
body: JSON.stringify({ itemId: potion.itemId, quantity: 1 }),
|
||||||
|
});
|
||||||
|
assert.equal(buyRes.status, 200);
|
||||||
|
|
||||||
|
const db = require('../../src/db').getDb();
|
||||||
|
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('hpregen6').id);
|
||||||
|
const char = db.prepare('SELECT * FROM characters WHERE user_id = ?').get(userId);
|
||||||
|
const maxHp = 50 + char.vit * 10 + char.level * 5;
|
||||||
|
|
||||||
|
// Set HP low and hp_updated_at far in the past
|
||||||
|
const pastTime = 1000;
|
||||||
|
db.prepare('UPDATE characters SET hp_current = 5, hp_updated_at = ? WHERE id = ?').run(pastTime, char.id);
|
||||||
|
|
||||||
|
// Simulate 10 minutes passing (10% regen)
|
||||||
|
const tenMinMs = 10 * 60 * 1000;
|
||||||
|
const later = pastTime + tenMinMs;
|
||||||
|
time.setNow(() => later);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expectedRegenHp = Math.min(5 + Math.floor(10 * maxHp / 100), maxHp);
|
||||||
|
|
||||||
|
// Use potion directly without getCharacter in between
|
||||||
|
const useRes = await fetch(`${baseUrl}/api/inventory/use`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||||
|
body: JSON.stringify({ itemId: potion.itemId }),
|
||||||
|
});
|
||||||
|
assert.equal(useRes.status, 200);
|
||||||
|
const useBody = await useRes.json();
|
||||||
|
assert.equal(useBody.ok, true);
|
||||||
|
|
||||||
|
// Check HP from DB (listInventory response doesn't include hp_current)
|
||||||
|
const updated = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(char.id);
|
||||||
|
// Minor Healing Potion heals 50 HP
|
||||||
|
const expectedFinalHp = Math.min(expectedRegenHp + 50, maxHp);
|
||||||
|
assert.equal(updated.hp_current, expectedFinalHp);
|
||||||
|
} finally {
|
||||||
|
time.resetNow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Unit tests for computeHp — natural HP regeneration (+1% max HP/min, lazy computation).
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { computeHp } = require('../../src/game/hp');
|
||||||
|
|
||||||
|
test('computeHp: no change when clock is same', () => {
|
||||||
|
const result = computeHp(50, 100, 1000, 1000);
|
||||||
|
assert.strictEqual(result.hp, 50);
|
||||||
|
assert.strictEqual(result.updatedAtMs, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: no change when clock went backwards', () => {
|
||||||
|
const result = computeHp(50, 100, 1000, 500);
|
||||||
|
assert.strictEqual(result.hp, 50);
|
||||||
|
assert.strictEqual(result.updatedAtMs, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: no change when less than 1 minute elapsed', () => {
|
||||||
|
const result = computeHp(50, 100, 1000, 60000); // 59s elapsed
|
||||||
|
assert.strictEqual(result.hp, 50);
|
||||||
|
assert.strictEqual(result.updatedAtMs, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: +1% max HP after 1 minute', () => {
|
||||||
|
const result = computeHp(50, 100, 1000, 61000); // 60s later
|
||||||
|
assert.strictEqual(result.hp, 51); // 1% of 100 = 1
|
||||||
|
assert.strictEqual(result.updatedAtMs, 1000 + 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: +5% max HP after 5 minutes', () => {
|
||||||
|
const result = computeHp(50, 100, 1000, 310000); // 300s later
|
||||||
|
assert.strictEqual(result.hp, 55); // 5% of 100 = 5
|
||||||
|
assert.strictEqual(result.updatedAtMs, 1000 + 5 * 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: caps at maxHp and resets timestamp', () => {
|
||||||
|
const result = computeHp(99, 100, 1000, 121000); // 120s later → +2% → 101 → cap at 100
|
||||||
|
assert.strictEqual(result.hp, 100);
|
||||||
|
assert.strictEqual(result.updatedAtMs, 121000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: already at max HP stays at max', () => {
|
||||||
|
const result = computeHp(100, 100, 1000, 3600000); // 1 hour later
|
||||||
|
assert.strictEqual(result.hp, 100);
|
||||||
|
assert.strictEqual(result.updatedAtMs, 3600000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: large gap — multiple minutes', () => {
|
||||||
|
// 10 minutes later, maxHp=135 → +10% = 13 HP
|
||||||
|
const result = computeHp(20, 135, 1000, 601000); // 600s later
|
||||||
|
assert.strictEqual(result.hp, 33); // 20 + floor(10 * 135 / 100) = 20 + 13 = 33
|
||||||
|
assert.strictEqual(result.updatedAtMs, 1000 + 10 * 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: large gap caps at max', () => {
|
||||||
|
// 2 hours later, maxHp=135, started at 5 → +120% → cap at 135
|
||||||
|
const result = computeHp(5, 135, 1000, 7201000); // 7200s later
|
||||||
|
assert.strictEqual(result.hp, 135);
|
||||||
|
assert.strictEqual(result.updatedAtMs, 7201000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: fractional percent floors correctly', () => {
|
||||||
|
// maxHp=100, 1 minute → +1% = +1
|
||||||
|
const result = computeHp(0, 100, 1000, 61000);
|
||||||
|
assert.strictEqual(result.hp, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: partial tick preserved in timestamp', () => {
|
||||||
|
// 90 seconds = 1 full minute + 30s partial
|
||||||
|
const result = computeHp(50, 100, 1000, 91000);
|
||||||
|
assert.strictEqual(result.hp, 51); // 1 tick
|
||||||
|
assert.strictEqual(result.updatedAtMs, 1000 + 60000); // partial 30s preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeHp: zero max HP edge case', () => {
|
||||||
|
const result = computeHp(0, 0, 1000, 1000000);
|
||||||
|
assert.strictEqual(result.hp, 0);
|
||||||
|
assert.strictEqual(result.updatedAtMs, 1000000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user