From 4277b9c273e2e3bdf011cc5ea8f296dba3bef9db Mon Sep 17 00:00:00 2001 From: bepis Date: Fri, 23 Oct 2026 04:33:19 +1100 Subject: [PATCH] step 10.12: done --- HANDOFF.md | 40 +++--- STATUS.md | 10 +- src/game/equipment.js | 42 ++++++ src/services/characterService.js | 16 ++- src/services/huntService.js | 10 +- src/services/inventoryService.js | 4 +- src/services/shopService.js | 19 +-- tests/integration/step-10.12.test.js | 196 +++++++++++++++++++++++++++ 8 files changed, 286 insertions(+), 51 deletions(-) create mode 100644 src/game/equipment.js create mode 100644 tests/integration/step-10.12.test.js diff --git a/HANDOFF.md b/HANDOFF.md index ff097b2..b242f59 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,8 +4,8 @@ > If this file disagrees with the code, trust the code and fix this file. ## 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 -- **Next step:** Phase 10.11 — Natural HP regeneration: +1% max HP/min, lazy computation, new migration adds hp_updated_at +- **Last completed step:** 10.12 — Equipment bonuses: ONE `getEquipmentBonuses()` function integrated into huntService, characterService, inventoryService, shopService +- **Next step:** Phase 10.13 — Starter kit: createCharacter grants class-appropriate weapon + 2 Minor Healing Potions - **Updated:** 2026-10-23 - **Was the last session cut off mid-task?** No @@ -13,31 +13,28 @@ | Command | Expected | |---|---| | `npm install` | succeeds | -| `npm run migrate` | applies 001–006 / "nothing to do" | -| `npm test` | **227 passing, 0 failing** (85 unit + 142 integration incl. 5 rest endpoint tests) | +| `npm run migrate` | applies 001–007 / "nothing to do" | +| `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 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 | ## 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/routes/character.js` — Added `handleRest` handler, exported `postRest` -- `src/app.js` — Mounted `POST /api/character/rest` route -- `public/game.html` — Added `#rest-section` div with `#rest-btn` button (hidden by default) -- `public/css/main.css` — Added `.btn-rest` styles -- `public/js/game.js` — Added `initRestUI()`, `handleRest()`, calls `initRestUI` from `loadCharacter`, attaches click handler -- `tests/integration/step-10.10.test.js` — 5 tests (happy heal, full HP rejection, insufficient gold, cross-user isolation, level scaling) +- `src/game/equipment.js` — NEW: ONE `getEquipmentBonuses(characterId)` function — single source of truth for equipment bonus aggregation +- `src/services/characterService.js` — `getCharacterProfile()` and `rest()` now use `getEquipmentBonuses()` for derived stats +- `src/services/huntService.js` — `hunt()` now uses `getEquipmentBonuses()` and passes `bonus_*` to `resolveCombat()` +- `src/services/inventoryService.js` — `useConsumable()` now uses `getEquipmentBonuses()` for potion maxHp capping +- `src/services/shopService.js` — `listInventory()` now uses `getEquipmentBonuses()` (duplicate aggregation removed) +- `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) ## 4. Half-finished / fragile things - 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. - `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 documented but not auto-created on character creation. -- `GET /api/zones` documented in API.md but not implemented (Issue #2). +- Starter gear not auto-created on character creation (10.13 next). +- `GET /api/zones` documented in API.md but not implemented. +- 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. -- **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 - 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`. - Register 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 claim uses `expeditionId` in request body. - 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.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.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 -- **Next step: 10.11** — Natural HP regeneration: +1% max HP/min, computed lazily like energy. -- New migration (next unused number, likely 007) adds `hp_updated_at INTEGER` column to characters table; backfill existing rows with `energy_updated_at`. -- 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). -- Apply computeHp before HP checks in: hunting, `getCharacterProfile`, potion use, and rest. -- Heals/damage must set `hp_updated_at = now`. +- **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). +- Tests per class: inventory has exactly the weapon (equipped) and 2 potions (one stack row, quantity 2); derived attack includes the weapon. +- Need to modify `createCharacter` function and add integration tests. diff --git a/STATUS.md b/STATUS.md index 758d95d..2be77f3 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,15 +8,15 @@ ## Current Focus - **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 -- **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 | Item | State | |---|---| | 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) | | 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 | @@ -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`. *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`. +- [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. + *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. *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 @@ -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-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-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 diff --git a/src/game/equipment.js b/src/game/equipment.js new file mode 100644 index 0000000..61db215 --- /dev/null +++ b/src/game/equipment.js @@ -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 }; diff --git a/src/services/characterService.js b/src/services/characterService.js index 037654c..ae1ddef 100644 --- a/src/services/characterService.js +++ b/src/services/characterService.js @@ -6,6 +6,7 @@ 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'); +const { getEquipmentBonuses } = require('../game/equipment'); function wrapStmts(db) { @@ -78,11 +79,13 @@ function getCharacterProfile(userId) { 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( { str: character.str, agi: character.agi, int: character.int, vit: character.vit }, character.level, - character.class + character.class, + equipBonuses ); // Compute current energy with lazy regen @@ -117,7 +120,8 @@ function getCharacterProfile(userId) { derived.maxHp = calcDerived( { str: character.str, agi: character.agi, int: character.int, vit: character.vit }, character.level, - character.class + character.class, + equipBonuses ).maxHp; } @@ -321,11 +325,13 @@ function rest(userId) { 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( { str: character.str, agi: character.agi, int: character.int, vit: character.vit }, character.level, - character.class + character.class, + equipBonuses ); const cost = character.level * 5; diff --git a/src/services/huntService.js b/src/services/huntService.js index b1abac7..6621835 100644 --- a/src/services/huntService.js +++ b/src/services/huntService.js @@ -11,6 +11,7 @@ const { resolveCombat } = require('../game/combat'); const { calculateRewards, rollLoot } = require('../game/rewards'); const { checkLevelUp, applyXpGain } = require('../game/leveling'); const { computeHp } = require('../game/hp'); +const { getEquipmentBonuses } = require('../game/equipment'); const MIN_HP_PCT_TO_HUNT = 0.2; 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 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 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); 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 hp = fight.hpRemaining; @@ -146,7 +148,7 @@ function hunt(characterId, zoneId, opts = {}) { leveledUp = true; level = xpResult.level; 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 { goldLost = Math.floor(ch.gold * DEFEAT_GOLD_LOSS_PCT); @@ -162,7 +164,7 @@ function hunt(characterId, zoneId, opts = {}) { winner: fight.winner, rounds: fight.rounds, hpRemaining: hp, - hpMax: calcDerived(baseStats, level, ch.class).maxHp, + hpMax: calcDerived(baseStats, level, ch.class, equipBonuses).maxHp, xpGained, goldGained, goldLost, loot, leveledUp, newLevel: level, energy: energy.energy - zone.energy_cost, diff --git a/src/services/inventoryService.js b/src/services/inventoryService.js index ad4db90..e6a15e4 100644 --- a/src/services/inventoryService.js +++ b/src/services/inventoryService.js @@ -4,6 +4,7 @@ const { getDb } = require('../db'); const { AppError } = require('../utils/errors'); const { calcDerived, ENERGY_MAX } = require('../game/stats'); const { computeHp } = require('../game/hp'); +const { getEquipmentBonuses } = require('../game/equipment'); const time = require('../utils/time'); 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) => { // Apply the effect. if (etype === 'heal') { + const equipBonuses = getEquipmentBonuses(charId); const maxHp = calcDerived( { str: c.str, agi: c.agi, int: c.int, vit: c.vit }, - c.level, c.class + c.level, c.class, equipBonuses ).maxHp; // Apply lazy HP regen before healing const hpUpdatedAt = c.hp_updated_at ?? c.energy_updated_at; diff --git a/src/services/shopService.js b/src/services/shopService.js index 73b05dd..f6f7ba7 100644 --- a/src/services/shopService.js +++ b/src/services/shopService.js @@ -2,6 +2,8 @@ // Sell value = 40% of buy_price (materials with buy_price=0 use rarity default). const { getDb } = require('../db'); const { AppError } = require('../utils/errors'); +const { getEquipmentBonuses } = require('../game/equipment'); +const { calcDerived } = require('../game/stats'); // Rarity defaults for materials (buy_price = 0). 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); // Calculate current derived stats including equipment bonuses. - const equipBonuses = { - 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 equipBonuses = getEquipmentBonuses(characterId); const derived = calcDerived( { str: ch.str, agi: ch.agi, int: ch.int, vit: ch.vit }, ch.level, diff --git a/tests/integration/step-10.12.test.js b/tests/integration/step-10.12.test.js new file mode 100644 index 0000000..ef280bd --- /dev/null +++ b/tests/integration/step-10.12.test.js @@ -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}`); + }); +});