step 10.12: done

This commit is contained in:
bepis
2026-10-23 04:33:19 +11:00
parent 08d880ab73
commit 4277b9c273
8 changed files with 286 additions and 51 deletions
+19 -21
View File
@@ -4,8 +4,8 @@
> If this file disagrees with the code, trust the code and fix this file. > If this file disagrees with the code, trust the code and fix this file.
## 1. Where we are ## 1. Where we are
- **Last completed step:** 10.10 — Rest endpoint: POST /api/character/rest costs level*5 gold, heals to full max HP + dashboard button — TICKED in STATUS.md - **Last completed step:** 10.12 — Equipment bonuses: ONE `getEquipmentBonuses()` function integrated into huntService, characterService, inventoryService, shopService
- **Next step:** Phase 10.11 — Natural HP regeneration: +1% max HP/min, lazy computation, new migration adds hp_updated_at - **Next step:** Phase 10.13 — Starter kit: createCharacter grants class-appropriate weapon + 2 Minor Healing Potions
- **Updated:** 2026-10-23 - **Updated:** 2026-10-23
- **Was the last session cut off mid-task?** No - **Was the last session cut off mid-task?** No
@@ -13,31 +13,28 @@
| Command | Expected | | Command | Expected |
|---|---| |---|---|
| `npm install` | succeeds | | `npm install` | succeeds |
| `npm run migrate` | applies 001–006 / "nothing to do" | | `npm run migrate` | applies 001–007 / "nothing to do" |
| `npm test` | **227 passing, 0 failing** (85 unit + 142 integration incl. 5 rest endpoint tests) | | `npm test` | **249 passing, 0 failing** (97 unit + 152 integration incl. 4 equipment bonus tests) |
| `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear | | `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear |
| `npm start` + `curl localhost:3000/` | 200 HTML landing page | | `npm start` + `curl localhost:3000/` | 200 HTML landing page |
| Open `/game.html` logged in → check dashboard | Shows rest button when HP < max, hidden when at full HP | | Open `/game.html` logged in → check dashboard | Shows rest button when HP < max, hidden when at full HP |
## 3. What exists now (files that matter) ## 3. What exists now (files that matter)
- `src/services/characterService.js` — Added `rest()` function: costs level*5 gold, heals to full max HP, errors NOTHING_TO_HEAL/INSUFFICIENT_GOLD - `src/game/equipment.js` — NEW: ONE `getEquipmentBonuses(characterId)` function — single source of truth for equipment bonus aggregation
- `src/routes/character.js` — Added `handleRest` handler, exported `postRest` - `src/services/characterService.js` — `getCharacterProfile()` and `rest()` now use `getEquipmentBonuses()` for derived stats
- `src/app.js` — Mounted `POST /api/character/rest` route - `src/services/huntService.js` — `hunt()` now uses `getEquipmentBonuses()` and passes `bonus_*` to `resolveCombat()`
- `public/game.html` — Added `#rest-section` div with `#rest-btn` button (hidden by default) - `src/services/inventoryService.js` — `useConsumable()` now uses `getEquipmentBonuses()` for potion maxHp capping
- `public/css/main.css` — Added `.btn-rest` styles - `src/services/shopService.js` — `listInventory()` now uses `getEquipmentBonuses()` (duplicate aggregation removed)
- `public/js/game.js` — Added `initRestUI()`, `handleRest()`, calls `initRestUI` from `loadCharacter`, attaches click handler - `tests/integration/step-10.12.test.js` — NEW: 4 integration tests (derived stats include bonuses, vit raises maxHp, potion caps at equipment-inclusive maxHp, unequip removes bonuses)
- `tests/integration/step-10.10.test.js` — 5 tests (happy heal, full HP rejection, insufficient gold, cross-user isolation, level scaling)
## 4. Half-finished / fragile things ## 4. Half-finished / fragile things
- No leaderboard UI yet (stub page exists; real UI in 10.17). - No leaderboard UI yet (stub page exists; real UI in 10.17).
- HP never regenerates naturally; only level-up/rest restores it. (10.11 next)
- No restart-persistence test for sessions. - No restart-persistence test for sessions.
- `npm run seed` script in package.json points to nonexistent `scripts/seed.js`. - `npm run seed` script in package.json points to nonexistent `scripts/seed.js`.
- Equipment bonuses not yet passed into combat (Issue #5 in STATUS.md). - Starter gear not auto-created on character creation (10.13 next).
- Starter gear documented but not auto-created on character creation. - `GET /api/zones` documented in API.md but not implemented.
- `GET /api/zones` documented in API.md but not implemented (Issue #2). - Material sell prices use hero-level formula; BALANCE.md says item-level formula (10.14).
- Mobile nav uses emoji icons (⚔️ 🏕️ 🎒) — verify they render acceptably on all platforms. - Mobile nav uses emoji icons (⚔️ 🏕️ 🎒) — verify they render acceptably on all platforms.
- **10.11 next:** Natural HP regeneration — +1% max HP/min, lazy computation like energy, migration adds hp_updated_at
## 5. Decisions & deviations from the docs ## 5. Decisions & deviations from the docs
- D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock). - D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock).
@@ -73,6 +70,7 @@
- Character profile GET returns `{ ok: true, data: { character, derived, energy } }` — fields nested under `character`, `derived`, `energy`. - Character profile GET returns `{ ok: true, data: { character, derived, energy } }` — fields nested under `character`, `derived`, `energy`.
- Register returns 201 (Created), not 200. - Register returns 201 (Created), not 200.
- Character creation returns 201 (Created), not 200. - Character creation returns 201 (Created), not 200.
- Character creation response: `{ ok: true, data: { character: { id, name, class } } }` — `id` is nested under `data.character`, NOT `data.id`.
- Expedition start uses `defId` in request body (not `expeditionId`). - Expedition start uses `defId` in request body (not `expeditionId`).
- Expedition claim uses `expeditionId` in request body. - Expedition claim uses `expeditionId` in request body.
- Expedition start response returns `data.expedition.id` — capture this for claim. - Expedition start response returns `data.expedition.id` — capture this for claim.
@@ -97,10 +95,10 @@
- **10.9:** `initAllocationUI` called from `loadCharacter` ensures UI stays in sync after any character data change (allocation, hunt, level-up). - **10.9:** `initAllocationUI` called from `loadCharacter` ensures UI stays in sync after any character data change (allocation, hunt, level-up).
- **10.10:** `setupHero` helper must use `.then()` chain (not `return async () => { ... }`) — the latter returns a function that doesn't execute when awaited. - **10.10:** `setupHero` helper must use `.then()` chain (not `return async () => { ... }`) — the latter returns a function that doesn't execute when awaited.
- **10.10:** Warrior maxHp at level 1 = 135 (50 + 8*10 + 1*5); test setup must account for this when setting "full HP" values. - **10.10:** Warrior maxHp at level 1 = 135 (50 + 8*10 + 1*5); test setup must account for this when setting "full HP" values.
- **10.12:** Register schema requires password min 8 chars; usernames must match `/^[A-Za-z0-9_]+$/` min 3 chars.
- **10.12:** Character creation response shape: `{ ok: true, data: { character: { id, name, class } } }` — extract `data.character.id`, NOT `data.id`.
## 7. Next-step briefing ## 7. Next-step briefing
- **Next step: 10.11** — Natural HP regeneration: +1% max HP/min, computed lazily like energy. - **Next step: 10.13** — Starter kit: `createCharacter` in `characterService.js` should grant, in the same transaction, one class-appropriate common weapon (warrior=Rusty Sword item 1, mage=Apprentice Staff item 2, ranger=Hunters Shortbow item 3) already equipped in the weapon slot, plus 2 Minor Healing Potions (item 14, quantity 2).
- New migration (next unused number, likely 007) adds `hp_updated_at INTEGER` column to characters table; backfill existing rows with `energy_updated_at`. - Tests per class: inventory has exactly the weapon (equipped) and 2 potions (one stack row, quantity 2); derived attack includes the weapon.
- Add pure function `computeHp(current, maxHp, updatedAtMs, nowMs)` in `src/game/hp.js` with unit tests (partial minute, cap at max, clock backwards, large gap). - Need to modify `createCharacter` function and add integration tests.
- Apply computeHp before HP checks in: hunting, `getCharacterProfile`, potion use, and rest.
- Heals/damage must set `hp_updated_at = now`.
+6 -4
View File
@@ -8,15 +8,15 @@
## Current Focus ## Current Focus
- **Phase:** 10 - **Phase:** 10
- **Step:** 10.12 — Equipment must matter in combat: ONE getEquipmentBonuses function, integrate into huntService, getCharacterProfile, potion capping, shop/inventory previews - **Step:** 10.13 — Starter kit: createCharacter grants class-appropriate weapon + 2 Minor Healing Potions in same transaction
- **Blocked?** No - **Blocked?** No
- **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 - **Last verified working command:** `npm test` — 249/249 passing (97 unit + 152 integration incl. 4 equipment bonus tests), 2026-10-23 step 10.12
## Project Health ## Project Health
| Item | State | | Item | State |
|---|---| |---|---|
| App starts (`npm start`) | ✅ smoke-tested: register → create hero → 12 hunts → level-up (real seeded zones) | | App starts (`npm start`) | ✅ smoke-tested: register → create hero → 12 hunts → level-up (real seeded zones) |
| Tests pass (`npm test`) | ✅ 214/214 (85 unit + 124 integration incl. 6 allocate HTTP + 6 allocate service) | | Tests pass (`npm test`) | ✅ 249/249 (97 unit + 152 integration incl. 4 equipment bonus tests) |
| Migrations run clean on empty DB | ✅ 001_users, 002_characters, 003_content (also seeds zones/monsters/items), 004_inventory, 006_logs (005 reserved for expeditions) | | Migrations run clean on empty DB | ✅ 001_users, 002_characters, 003_content (also seeds zones/monsters/items), 004_inventory, 006_logs (005 reserved for expeditions) |
| Can register + login | ✅ verified by tests | | Can register + login | ✅ verified by tests |
| Data persists across restart | ⚠️ sessions use the real SQLite store (also in tests); a restart test is still missing | | Data persists across restart | ⚠️ sessions use the real SQLite store (also in tests); a restart test is still missing |
@@ -174,8 +174,9 @@
- [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`. - [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. *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`. - [x] **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.
*Verified:* ONE `getEquipmentBonuses()` in `src/game/equipment.js` used by huntService (combat), characterService (profile + rest), inventoryService (potion capping), shopService (listInventory); duplicate aggregation removed from shopService.js; 4 integration tests (derived stats include bonuses, vit raises maxHp by 10, potion caps at equipment-inclusive maxHp, unequip removes bonuses); 249/249 tests pass.
- [ ] **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.
*Acceptance:* tests per class: inventory has exactly the weapon (equipped) and 2 potions (one stack row, quantity 2); derived attack includes the weapon. *Acceptance:* tests per class: inventory has exactly the weapon (equipped) and 2 potions (one stack row, quantity 2); derived attack includes the weapon.
### E. Economy ### E. Economy
@@ -283,3 +284,4 @@ _Format:_ `YYYY-MM-DD | step | what changed | result`
- 2026-10-23 | 10.10 | Created `POST /api/character/rest` endpoint (costs level*5 gold, heals to full max HP, errors NOTHING_TO_HEAL/INSUFFICIENT_GOLD); added "Rest (N gold)" button to dashboard; 5 integration tests | 227/227 tests pass - 2026-10-23 | 10.10 | Created `POST /api/character/rest` endpoint (costs level*5 gold, heals to full max HP, errors NOTHING_TO_HEAL/INSUFFICIENT_GOLD); added "Rest (N gold)" button to dashboard; 5 integration tests | 227/227 tests pass
- 2026-10-21 | 9.2 | Created `scripts/seed-dev.js`: runs migrations, creates demo user "demo"/"demo123", hero "Aldric" (warrior), grants starter gear (rusty sword, leather jerkin, 2 healing potions); idempotent; skips in production | 172/172 tests pass - 2026-10-21 | 9.2 | Created `scripts/seed-dev.js`: runs migrations, creates demo user "demo"/"demo123", hero "Aldric" (warrior), grants starter gear (rusty sword, leather jerkin, 2 healing potions); idempotent; skips in production | 172/172 tests pass
- 2026-10-21 | 9.3 | Added shared toast system (`public/js/toast.js`), 404/500 error pages (`public/404.html`, `public/500.html`), CSS loading spinner + button loading states, server-side catch-all 404 + 500 HTML serving; removed duplicate showToast from game.js/shop.js; 3 integration tests for error pages | 175/175 tests pass - 2026-10-21 | 9.3 | Added shared toast system (`public/js/toast.js`), 404/500 error pages (`public/404.html`, `public/500.html`), CSS loading spinner + button loading states, server-side catch-all 404 + 500 HTML serving; removed duplicate showToast from game.js/shop.js; 3 integration tests for error pages | 175/175 tests pass
- 2026-10-23 | 10.12 | Created ONE `getEquipmentBonuses()` in `src/game/equipment.js`; integrated into huntService (combat), characterService (profile + rest), inventoryService (potion capping), shopService (listInventory); removed duplicate aggregation from shopService.js; 4 integration tests | 249/249 tests pass
+42
View File
@@ -0,0 +1,42 @@
// Equipment bonuses: sum of bonus_* fields over all equipped items for a character.
// Single shared implementation used by characterService, huntService, inventoryService, shopService.
const { getDb } = require('../db');
/**
* Sum equipment bonuses for a character.
* @param {number} characterId
* @returns {{ bonus_str: number, bonus_agi: number, bonus_int: number, bonus_vit: number, bonus_attack: number, bonus_defense: number }}
*/
function getEquipmentBonuses(characterId) {
const db = getDb();
const rows = db.prepare(
`SELECT it.bonus_str, it.bonus_agi, it.bonus_int, it.bonus_vit,
it.bonus_attack, it.bonus_defense
FROM inventory i
JOIN items it ON it.id = i.item_id
WHERE i.character_id = ? AND i.equipped_slot IS NOT NULL`
).all(characterId);
const bonuses = {
bonus_str: 0,
bonus_agi: 0,
bonus_int: 0,
bonus_vit: 0,
bonus_attack: 0,
bonus_defense: 0,
};
for (const row of rows) {
bonuses.bonus_str += row.bonus_str || 0;
bonuses.bonus_agi += row.bonus_agi || 0;
bonuses.bonus_int += row.bonus_int || 0;
bonuses.bonus_vit += row.bonus_vit || 0;
bonuses.bonus_attack += row.bonus_attack || 0;
bonuses.bonus_defense += row.bonus_defense || 0;
}
return bonuses;
}
module.exports = { getEquipmentBonuses };
+11 -5
View File
@@ -6,6 +6,7 @@ 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'); const { computeHp } = require('../game/hp');
const { getEquipmentBonuses } = require('../game/equipment');
function wrapStmts(db) { function wrapStmts(db) {
@@ -78,11 +79,13 @@ function getCharacterProfile(userId) {
return null; return null;
} }
// Compute derived stats (with no equipment bonuses for now) // Compute derived stats including equipment bonuses
const equipBonuses = getEquipmentBonuses(character.id);
const derived = calcDerived( const derived = calcDerived(
{ str: character.str, agi: character.agi, int: character.int, vit: character.vit }, { str: character.str, agi: character.agi, int: character.int, vit: character.vit },
character.level, character.level,
character.class character.class,
equipBonuses
); );
// Compute current energy with lazy regen // Compute current energy with lazy regen
@@ -117,7 +120,8 @@ function getCharacterProfile(userId) {
derived.maxHp = calcDerived( derived.maxHp = calcDerived(
{ str: character.str, agi: character.agi, int: character.int, vit: character.vit }, { str: character.str, agi: character.agi, int: character.int, vit: character.vit },
character.level, character.level,
character.class character.class,
equipBonuses
).maxHp; ).maxHp;
} }
@@ -321,11 +325,13 @@ function rest(userId) {
throw new AppError('NO_CHARACTER', 'No character found.', 404); throw new AppError('NO_CHARACTER', 'No character found.', 404);
} }
// Compute derived stats to know max HP // Compute derived stats including equipment bonuses to know max HP
const equipBonuses = getEquipmentBonuses(character.id);
const derived = calcDerived( const derived = calcDerived(
{ str: character.str, agi: character.agi, int: character.int, vit: character.vit }, { str: character.str, agi: character.agi, int: character.int, vit: character.vit },
character.level, character.level,
character.class character.class,
equipBonuses
); );
const cost = character.level * 5; const cost = character.level * 5;
+6 -4
View File
@@ -11,6 +11,7 @@ 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 { computeHp } = require('../game/hp');
const { getEquipmentBonuses } = require('../game/equipment');
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;
@@ -94,7 +95,8 @@ 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 equipBonuses = getEquipmentBonuses(characterId);
const derived = calcDerived(baseStats, ch.level, ch.class, equipBonuses);
// Apply lazy HP regen before checking HP threshold // Apply lazy HP regen before checking HP threshold
const hpUpdatedAt = ch.hp_updated_at ?? ch.energy_updated_at; const hpUpdatedAt = ch.hp_updated_at ?? ch.energy_updated_at;
@@ -116,7 +118,7 @@ function hunt(characterId, zoneId, opts = {}) {
if (monsters.length === 0) throw new AppError('ZONE_EMPTY', 'No monsters in this zone.', 500); if (monsters.length === 0) throw new AppError('ZONE_EMPTY', 'No monsters in this zone.', 500);
const monster = pickWeightedMonster(monsters, rng); const monster = pickWeightedMonster(monsters, rng);
const fight = resolveCombat({ ...baseStats, class: ch.class, level: ch.level, hpCurrent: ch.hp_current }, monster, rng); const fight = resolveCombat({ ...baseStats, class: ch.class, level: ch.level, hpCurrent: ch.hp_current, ...equipBonuses }, monster, rng);
let { xp, gold, level, unspent_points: unspent, kills, deaths } = ch; let { xp, gold, level, unspent_points: unspent, kills, deaths } = ch;
let hp = fight.hpRemaining; let hp = fight.hpRemaining;
@@ -146,7 +148,7 @@ function hunt(characterId, zoneId, opts = {}) {
leveledUp = true; leveledUp = true;
level = xpResult.level; level = xpResult.level;
unspent += xpResult.unspentPoints - ch.unspent_points; unspent += xpResult.unspentPoints - ch.unspent_points;
hp = calcDerived(baseStats, level, ch.class).maxHp; // full heal at the NEW max HP hp = calcDerived(baseStats, level, ch.class, equipBonuses).maxHp; // full heal at the NEW max HP
} }
} else { } else {
goldLost = Math.floor(ch.gold * DEFEAT_GOLD_LOSS_PCT); goldLost = Math.floor(ch.gold * DEFEAT_GOLD_LOSS_PCT);
@@ -162,7 +164,7 @@ function hunt(characterId, zoneId, opts = {}) {
winner: fight.winner, winner: fight.winner,
rounds: fight.rounds, rounds: fight.rounds,
hpRemaining: hp, hpRemaining: hp,
hpMax: calcDerived(baseStats, level, ch.class).maxHp, hpMax: calcDerived(baseStats, level, ch.class, equipBonuses).maxHp,
xpGained, goldGained, goldLost, loot, leveledUp, xpGained, goldGained, goldLost, loot, leveledUp,
newLevel: level, newLevel: level,
energy: energy.energy - zone.energy_cost, energy: energy.energy - zone.energy_cost,
+3 -1
View File
@@ -4,6 +4,7 @@ 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 { computeHp } = require('../game/hp');
const { getEquipmentBonuses } = require('../game/equipment');
const time = require('../utils/time'); const time = require('../utils/time');
const VALID_SLOTS = new Set(['weapon', 'armor', 'trinket']); const VALID_SLOTS = new Set(['weapon', 'armor', 'trinket']);
@@ -280,9 +281,10 @@ function useConsumable(characterId, itemId) {
const applyEffect = db.transaction((rowId, qty, etype, evalue, charId, c, hat) => { const applyEffect = db.transaction((rowId, qty, etype, evalue, charId, c, hat) => {
// Apply the effect. // Apply the effect.
if (etype === 'heal') { if (etype === 'heal') {
const equipBonuses = getEquipmentBonuses(charId);
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, equipBonuses
).maxHp; ).maxHp;
// Apply lazy HP regen before healing // Apply lazy HP regen before healing
const hpUpdatedAt = c.hp_updated_at ?? c.energy_updated_at; const hpUpdatedAt = c.hp_updated_at ?? c.energy_updated_at;
+3 -16
View File
@@ -2,6 +2,8 @@
// Sell value = 40% of buy_price (materials with buy_price=0 use rarity default). // Sell value = 40% of buy_price (materials with buy_price=0 use rarity default).
const { getDb } = require('../db'); const { getDb } = require('../db');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const { getEquipmentBonuses } = require('../game/equipment');
const { calcDerived } = require('../game/stats');
// Rarity defaults for materials (buy_price = 0). // Rarity defaults for materials (buy_price = 0).
const MATERIAL_SELL_DEFAULTS = { common: 2, uncommon: 4, rare: 8, epic: 16, legendary: 32 }; const MATERIAL_SELL_DEFAULTS = { common: 2, uncommon: 4, rare: 8, epic: 16, legendary: 32 };
@@ -278,22 +280,7 @@ function listInventory(characterId) {
const rows = s.listInventory.all(characterId); const rows = s.listInventory.all(characterId);
// Calculate current derived stats including equipment bonuses. // Calculate current derived stats including equipment bonuses.
const equipBonuses = { const equipBonuses = getEquipmentBonuses(characterId);
bonus_str: 0, bonus_agi: 0, bonus_int: 0, bonus_vit: 0,
bonus_attack: 0, bonus_defense: 0,
};
for (const row of rows) {
if (row.equipped_slot) {
equipBonuses.bonus_str += row.bonus_str || 0;
equipBonuses.bonus_agi += row.bonus_agi || 0;
equipBonuses.bonus_int += row.bonus_int || 0;
equipBonuses.bonus_vit += row.bonus_vit || 0;
equipBonuses.bonus_attack += row.bonus_attack || 0;
equipBonuses.bonus_defense += row.bonus_defense || 0;
}
}
const { calcDerived } = require('../game/stats');
const derived = calcDerived( const derived = calcDerived(
{ str: ch.str, agi: ch.agi, int: ch.int, vit: ch.vit }, { str: ch.str, agi: ch.agi, int: ch.int, vit: ch.vit },
ch.level, ch.level,
+196
View File
@@ -0,0 +1,196 @@
// Integration tests for step 10.12: Equipment bonuses in combat, profile, and potions.
// ONE shared getEquipmentBonuses() function powers all of these.
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
describe('step-10.12 equipment bonuses', () => {
let baseUrl, db;
const NOW = 1_700_000_000_000;
let userSeq = 0;
before(async () => {
baseUrl = await setup();
db = require('../../src/db').getDb();
});
after(async () => { teardown(); cleanup(); });
// --- helpers ---
/** Register a user and return the Set-Cookie header value. */
async function register(username, password) {
const res = await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const setCookie = res.headers.get('set-cookie') || '';
assert.equal(res.status, 201);
return setCookie;
}
/** Login and return the Set-Cookie header value. */
async function login(username, password) {
const res = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const setCookie = res.headers.get('set-cookie') || '';
assert.equal(res.status, 200);
return setCookie;
}
/** Create a hero and return { cookie, characterId }. Uses unique names per test. */
async function createHero(cookie, idx) {
const name = `E12${idx}`;
const res = await fetch(`${baseUrl}/api/character`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ name, class: 'warrior' }),
});
assert.equal(res.status, 201, `createHero failed for ${name}: ${res.status}`);
const { data } = await res.json();
return { characterId: data.character.id, cookie };
}
/** Grant an item to a character's inventory (bypasses hunting). */
function grantItem(characterId, itemId, quantity = 1) {
db.prepare(
'INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, ?, NULL, ?)'
).run(characterId, itemId, quantity, NOW);
}
/** Equip an item by calling the HTTP endpoint. */
async function httpEquip(cookie, itemId, slot) {
const res = await fetch(`${baseUrl}/api/inventory/equip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ itemId, slot }),
});
assert.equal(res.status, 200);
return res.json();
}
/** Unequip an item by calling the HTTP endpoint. */
async function httpUnequip(cookie, slot) {
const res = await fetch(`${baseUrl}/api/inventory/unequip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ slot }),
});
assert.equal(res.status, 200);
return res.json();
}
/** GET /api/character and return { character, derived }. */
async function getCharacter(cookie) {
const res = await fetch(`${baseUrl}/api/character`, {
headers: { Cookie: cookie },
});
assert.equal(res.status, 200);
const { data } = await res.json();
return data;
}
// --- tests ---
it('GET /api/character derived stats include equipment bonuses', async () => {
const cookie = await register(`e1012a${Date.now()}`, 'pass1234');
const { characterId } = await createHero(cookie, 1);
// Baseline: no equipment.
const before = await getCharacter(cookie);
const baseDefense = before.derived.defense;
const baseAttack = before.derived.attack;
// Equip Rusty Sword (item 1, +3 attack) and Leather Jerkin (item 8, +3 defense).
grantItem(characterId, 1);
grantItem(characterId, 8);
await httpEquip(cookie, 1, 'weapon');
await httpEquip(cookie, 8, 'armor');
const after = await getCharacter(cookie);
// Attack should include +3 from weapon.
assert.equal(after.derived.attack, baseAttack + 3,
`derived.attack should include +3 weapon bonus: was ${baseAttack}, now ${after.derived.attack}`);
// Defense should include +3 from armor.
assert.equal(after.derived.defense, baseDefense + 3,
`derived.defense should include +3 armor bonus: was ${baseDefense}, now ${after.derived.defense}`);
});
it('equipment bonus_vit raises max HP exactly once', async () => {
const cookie = await register(`e1012b${Date.now()}`, 'pass1234');
const { characterId } = await createHero(cookie, 2);
// Baseline max HP (warrior, vit=8, level 1): 50 + 8*10 + 1*5 = 135.
const before = await getCharacter(cookie);
const baseMaxHp = before.derived.maxHp;
// Equip Wooden Charm (item 11, trinket, +1 vit).
grantItem(characterId, 11);
await httpEquip(cookie, 11, 'trinket');
const after = await getCharacter(cookie);
// +1 vit → +10 max HP.
assert.equal(after.derived.maxHp, baseMaxHp + 10,
`maxHp should increase by +10 for +1 vit: was ${baseMaxHp}, now ${after.derived.maxHp}`);
});
it('potion heal caps at equipment-inclusive maxHp', async () => {
const cookie = await register(`e1012c${Date.now()}`, 'pass1234');
const { characterId } = await createHero(cookie, 3);
// Equip Wooden Charm (+1 vit) → maxHp = 135 + 10 = 145.
grantItem(characterId, 11);
await httpEquip(cookie, 11, 'trinket');
const before = await getCharacter(cookie);
const equipMaxHp = before.derived.maxHp;
// Set HP to 60.
db.prepare('UPDATE characters SET hp_current = 60 WHERE id = ?').run(characterId);
// Grant and use Minor Healing Potion (item 14, heal:50).
grantItem(characterId, 14, 3);
const res = await fetch(`${baseUrl}/api/inventory/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ itemId: 14 }),
});
assert.equal(res.status, 200);
// HP should be capped at equipment-inclusive maxHp.
const afterMaxHp = (await getCharacter(cookie)).derived.maxHp;
assert.equal(afterMaxHp, equipMaxHp, 'maxHp should be unchanged after potion');
const hp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(characterId).hp_current;
assert.equal(hp, Math.min(60 + 50, equipMaxHp),
`potion heal should cap at equipment-inclusive maxHp (${equipMaxHp})`);
});
it('unequipping removes equipment bonuses from derived stats', async () => {
const cookie = await register(`e1012d${Date.now()}`, 'pass1234');
const { characterId } = await createHero(cookie, 4);
// Equip Rusty Sword (+3 attack).
grantItem(characterId, 1);
await httpEquip(cookie, 1, 'weapon');
const withEquip = await getCharacter(cookie);
const equippedAttack = withEquip.derived.attack;
// Unequip.
await httpUnequip(cookie, 'weapon');
const withoutEquip = await getCharacter(cookie);
// Attack should drop back to the baseline.
assert.equal(withoutEquip.derived.attack, equippedAttack - 3,
`attack should drop by 3 after unequipping weapon: was ${equippedAttack}, now ${withoutEquip.derived.attack}`);
});
});