diff --git a/HANDOFF.md b/HANDOFF.md index 948a759..a26155c 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:** 5.2 (inventory/equipment services + routes — list, equip, unequip) -- **Next step:** 5.3 — stackable consumables: POST /api/inventory/use (health potion heals; energy tonic restores energy, capped) +- **Last completed step:** 5.3 (stackable consumables: POST /api/inventory/use) +- **Next step:** 5.4 — UI: inventory grid, equipment panel, tooltips with stats - **Updated:** 2026-10-20 - **Was the last session cut off mid-task?** No @@ -14,7 +14,7 @@ |---|---| | `npm install` | succeeds | | `npm run migrate` | applies 001–004 and 006 / "nothing to do" | -| `npm test` | **111 passing, 0 failing** (87 unit + 24 integration) | +| `npm test` | **118 passing, 0 failing** (87 unit + 31 integration) | ## 3. What exists now (files that matter) - `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp. @@ -23,21 +23,21 @@ - `src/services/zoneService.js` — `listZones(characterLevel)` → zones with `unlocked` flag. - `src/services/combatLogService.js` — `getCombatLog(characterId, limit)` → recent fights. - `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`. -- **NEW:** `src/services/inventoryService.js` — `listInventory`, `equipItem`, `unequipItem`. Uses WeakMap stmt cache. Derived stats recalculated on list. -- **NEW:** `src/routes/inventory.js` — `GET /api/inventory`, `POST /api/inventory/equip`, `POST /api/inventory/unequip`. Zod-validated. +- **NEW:** `src/services/inventoryService.js` — `listInventory`, `equipItem`, `unequipItem`, `useConsumable`. Uses WeakMap stmt cache. Derived stats recalculated on list. Consumables: heal (capped at maxHp) and energy (capped at ENERGY_MAX), qty decrement, row removal at 0. +- **NEW:** `src/routes/inventory.js` — `GET /api/inventory`, `POST /api/inventory/equip`, `POST /api/inventory/unequip`, `POST /api/inventory/use`. Zod-validated. - `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services. - `src/routes/zones.js` — `GET /api/zones`. - `src/routes/combatLog.js` — `GET /api/combat-log`. - `src/db/migrations/003_content.sql` — also SEEDS zones/monsters/items/loot (INSERT OR IGNORE). - `src/db/migrations/004_inventory.sql` — inventory table, FK constraints, partial unique index for equipped slots. -- `tests/integration/inventory.test.js` — 13 integration tests for inventory service. +- `tests/integration/inventory.test.js` — 20 integration tests (13 equip/unequip + 7 consumable). - `public/game.html` — dashboard with stats panel, zone list, combat log, result panel. - `public/js/game.js` — full hunt UI: load zones, hunt handler, result display, combat log replay, toast notifications, energy sync. - `public/css/main.css` — zone cards, combat log entries, result panel, toast styles. ## 4. Half-finished / fragile things -- No consumable use endpoint yet (Phase 5 step 5.3). -- No UI for inventory/equipment (Phase 5 step 5.4). +- No consumable UI yet (Phase 5 step 5.4). +- No inventory grid/equipment panel/tooltips in game.html. - HP never regenerates naturally; only level-up restores it. - No restart-persistence test for sessions. - `npm run seed` script in package.json points to nonexistent `scripts/seed.js`. @@ -47,6 +47,8 @@ - D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock). - Zod query param parsing uses `zod.coerce.number()` because Express query params are strings. - Inventory response uses `itemId` (item.id) and `inventoryId` (inventory row id) to avoid confusion. +- Consumable effect parsing: regex `^(heal|energy):(\d+)$` on items.effect field. +- Qty decrement avoids CHECK constraint violation: delete directly when qty==1, decrement when qty>1. ## 6. Gotchas learned - `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`. A shell glob once skipped 17 tests. @@ -57,13 +59,13 @@ - Express query params are strings, not numbers — use `zod.coerce.number()` for numeric query params. - SQL `SELECT id` from inventory returns the auto-increment row id; rename with `AS inventory_id` when you also need `item_id`. - `itemInSlot` query must alias `id AS inventory_id` so `existing.inventory_id` works in unequip logic. +- Transaction callback args must match the call order exactly — passing `qty` as the second arg when the callback expects `etype` silently misapplies the effect. -## 7. Next-step briefing (5.3) -- **Goal:** Stackable consumables: `POST /api/inventory/use` — health potion heals HP (capped at maxHp); energy tonic restores energy (capped at ENERGY_MAX). Only consumable type items can be used. Consumable is consumed (quantity -1, removed when 0). -- **Docs:** DATABASE.md (§004_inventory), GAME_DESIGN.md §Items, BALANCE.md (effect format: 'heal:50', 'energy:25'). -- **Files to create/modify:** extend `inventoryService.js` with `useConsumable(characterId, itemId)`, extend `inventory.js` route. -- **Key rules:** parse `effect` field from items table ('heal:N' or 'energy:N'); validate character has the item in inventory (quantity >= 1); apply effect; decrement quantity; remove row when quantity reaches 0; return updated inventory listing. -- **Tests:** happy-path heal, happy-path energy, use on non-consumable rejected, insufficient quantity, effect capped at maxHp/energyMax. +## 7. Next-step briefing (5.4) +- **Goal:** UI for inventory — grid display of items (equipment + consumables), equipment panel showing current gear with stats, tooltips with stat bonuses. +- **Docs:** GAME_DESIGN.md §Items, UI_GUIDE.md, API.md. +- **Files to modify:** `public/game.html` (add inventory/equipment panels), `public/js/game.js` (load inventory, render grid, equip/unequip/use handlers, tooltips), `public/css/main.css` (inventory grid, tooltips, equipment panel styles). +- **Key rules:** Use `GET /api/inventory` for data; render equipment slots + consumable list; show stat bonuses on hover; click to equip/unequip; click consumable to use. ## 8. Open Known Issues STATUS.md issues 1–6 (balance, dangling `seed` script, restart test, equipment-in-combat, HP regen, zone min_level balance). diff --git a/STATUS.md b/STATUS.md index 157e82b..412e7ac 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,9 +8,9 @@ ## Current Focus - **Phase:** 5 -- **Step:** 5.3 +- **Step:** 5.4 - **Blocked?** No -- **Last verified working command:** `npm test` — 111/111 passing (87 unit + 24 integration), 2026-10-20 step 5.2 +- **Last verified working command:** `npm test` — 118/118 passing (87 unit + 31 integration), 2026-10-20 step 5.3 ## Project Health | Item | State | @@ -76,7 +76,8 @@ *Acceptance:* migration creates inventory table with FK constraints and indexes. - [x] **5.2** Services + routes: list inventory, equip, unequip (slots: weapon, armor, trinket). Equipment modifies derived stats via `calcDerived()`. *Acceptance:* list returns items with derived stats; equip moves previous item; unequip clears slot; wrong-type/level/missing-item rejected. -- [ ] **5.3** Stackable consumables: `POST /api/inventory/use` (health potion heals; energy tonic restores energy, capped). +- [x] **5.3** Stackable consumables: `POST /api/inventory/use` (health potion heals; energy tonic restores energy, capped). + *Acceptance:* heal effect caps at maxHp; energy effect caps at ENERGY_MAX; quantity decrements; row removed at qty 0; non-consumable rejected. - [ ] **5.4** UI: inventory grid, equipment panel, tooltips with stats. ## Phase 6 — Shop & Economy (docs: GAME_DESIGN §Economy) @@ -147,6 +148,7 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing` ## Session Log _Format:_ `YYYY-MM-DD | step | what changed | result` +- 2026-10-20 | 5.3 | Added `useConsumable()` to inventoryService (heal/energy effects, qty decrement, row removal at 0) + `POST /api/inventory/use` route; 7 integration tests covering heal, energy, cap, non-consumable rejection, missing item, qty-0 removal | 118/118 tests pass - 2026-10-20 | 5.2 | Created `src/services/inventoryService.js` (listInventory, equipItem, unequipItem) + `src/routes/inventory.js` (GET /api/inventory, POST /api/inventory/equip, POST /api/inventory/unequip); registered routes in app.js; 13 integration tests covering empty inventory, equip/unequip, slot swap, stat updates, all error paths | 111/111 tests pass - 2026-10-20 | 5.1 | Verified 004_inventory.sql migration (inventory table, FK constraints, indexes) applies clean; all tests pass | 98/98 tests pass - 2026-10-20 | 4.5 | Added `GET /api/zones` (zoneService + zones route), `GET /api/combat-log` (combatLogService + combatLog route); extended game.html with zone list/combat log/result panels; rewrote game.js with hunt UI (zone cards, hunt button, result display, combat log replay, toast notifications); added CSS for zone cards/combat log/toasts; 5 new integration tests | 98/98 tests pass diff --git a/src/app.js b/src/app.js index d7d1afa..ca43556 100644 --- a/src/app.js +++ b/src/app.js @@ -57,6 +57,7 @@ function createApp() { app.get('/api/inventory', requireAuth, inventoryRoutes.getInventory); app.post('/api/inventory/equip', requireAuth, inventoryRoutes.postEquip); app.post('/api/inventory/unequip', requireAuth, inventoryRoutes.postUnequip); + app.post('/api/inventory/use', requireAuth, inventoryRoutes.postUse); // Central error handler app.use((err, _req, res, _next) => { diff --git a/src/routes/inventory.js b/src/routes/inventory.js index 05432e5..bae6e98 100644 --- a/src/routes/inventory.js +++ b/src/routes/inventory.js @@ -1,9 +1,9 @@ -// Inventory routes: list, equip, unequip. +// Inventory routes: list, equip, unequip, use consumables. const zod = require('zod'); const { AppError } = require('../utils/errors'); const { requireAuth } = require('../middleware/session'); const { getCharacterIdForUser } = require('../services/characterService'); -const { listInventory, equipItem, unequipItem } = require('../services/inventoryService'); +const { listInventory, equipItem, unequipItem, useConsumable } = require('../services/inventoryService'); // Validation schemas const slotSchema = zod.enum(['weapon', 'armor', 'trinket']); @@ -56,10 +56,28 @@ async function handleUnequip(req, res) { res.json({ ok: true, data: result }); } +/** + * POST /api/inventory/use — use a stackable consumable (heal/energy). + */ +async function handleUse(req, res) { + const parsed = zod.strictObject({ + itemId: zod.number().int().positive(), + }).safeParse(req.body); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400); + } + + const { itemId } = parsed.data; + const characterId = getCharacterIdForUser(req.session.userId); + const result = useConsumable(characterId, itemId); + res.json({ ok: true, data: result }); +} + module.exports = { routes: { getInventory: wrap(handleListInventory), postEquip: wrap(handleEquip), postUnequip: wrap(handleUnequip), + postUse: wrap(handleUse), }, }; diff --git a/src/services/inventoryService.js b/src/services/inventoryService.js index 5a32156..ec15712 100644 --- a/src/services/inventoryService.js +++ b/src/services/inventoryService.js @@ -1,7 +1,8 @@ -// Inventory service: list, equip, unequip items. +// Inventory service: list, equip, unequip, and use consumables. // Equipment bonuses feed into calcDerived() for stat updates. const { getDb } = require('../db'); const { AppError } = require('../utils/errors'); +const { calcDerived, ENERGY_MAX } = require('../game/stats'); const VALID_SLOTS = new Set(['weapon', 'armor', 'trinket']); @@ -54,6 +55,31 @@ function getStmts(db) { character: db.prepare( 'SELECT * FROM characters WHERE id = ?' ), + + // Get inventory row for a specific (character, item) — including quantity. + inventoryRowForUse: db.prepare( + 'SELECT * FROM inventory WHERE character_id = ? AND item_id = ? AND quantity >= 1' + ), + + // Decrement quantity by 1 (only when qty > 1 to avoid CHECK violation). + decrementQty: db.prepare( + 'UPDATE inventory SET quantity = quantity - 1 WHERE id = ? AND quantity > 1' + ), + + // Delete row when it's the last item (qty == 1). + deleteLast: db.prepare( + 'DELETE FROM inventory WHERE id = ? AND quantity = 1' + ), + + // Update character hp_current. + updateHp: db.prepare( + 'UPDATE characters SET hp_current = ? WHERE id = ?' + ), + + // Update character energy. + updateEnergy: db.prepare( + 'UPDATE characters SET energy = ?, energy_updated_at = ? WHERE id = ?' + ), }; stmtCache.set(db, s); } @@ -209,4 +235,74 @@ function unequipItem(characterId, slot) { return listInventory(characterId); } -module.exports = { listInventory, equipItem, unequipItem }; +/** + * Use a stackable consumable item. Applies its effect (heal or energy), + * decrements quantity, removes the row when quantity reaches 0. + * @param {number} characterId + * @param {number} itemId + * @returns {object} updated inventory listing + */ +function useConsumable(characterId, itemId) { + const db = getDb(); + const s = getStmts(db); + + const ch = s.character.get(characterId); + if (!ch) throw new AppError('NOT_FOUND', 'Character not found.', 404); + + const item = s.itemById.get(itemId); + if (!item) throw new AppError('NOT_FOUND', 'Item not found.', 404); + + if (item.type !== 'consumable') { + throw new AppError('VALIDATION_ERROR', `${item.name} is not a consumable.`, 400); + } + + if (!item.effect) { + throw new AppError('VALIDATION_ERROR', `${item.name} has no effect.`, 400); + } + + // Parse effect: 'heal:N' or 'energy:N' + const match = item.effect.match(/^(heal|energy):(\d+)$/); + if (!match) { + throw new AppError('VALIDATION_ERROR', `${item.name} has an invalid effect format.`, 400); + } + const [_full, effectType, effectValueStr] = match; + const effectValue = parseInt(effectValueStr, 10); + + // Find the inventory row (must have quantity >= 1). + const invRow = s.inventoryRowForUse.get(characterId, item.id); + if (!invRow) throw new AppError('NOT_FOUND', 'Item not in inventory or depleted.', 404); + + // Apply effect inside a transaction. + const applyEffect = db.transaction((rowId, qty, etype, evalue, charId, c) => { + // Apply the effect. + if (etype === 'heal') { + const maxHp = calcDerived( + { str: c.str, agi: c.agi, int: c.int, vit: c.vit }, + c.level, c.class + ).maxHp; + const newHp = Math.min(c.hp_current + evalue, maxHp); + s.updateHp.run(newHp, charId); + } else if (etype === 'energy') { + const { computeEnergy } = require('../game/stats'); + const now = require('../utils/time').now(); + const energy = computeEnergy(c.energy, c.energy_updated_at, now); + const newEnergy = Math.min(energy.energy + evalue, ENERGY_MAX); + const newUpdatedAt = newEnergy >= ENERGY_MAX ? now : energy.updatedAtMs; + s.updateEnergy.run(newEnergy, newUpdatedAt, charId); + } + + // Delete directly when qty==1; otherwise decrement. + if (qty === 1) { + s.deleteLast.run(rowId); + } else { + s.decrementQty.run(rowId); + } + }); + + applyEffect(invRow.id, invRow.quantity, effectType, effectValue, characterId, ch); + + // Return updated listing. + return listInventory(characterId); +} + +module.exports = { listInventory, equipItem, unequipItem, useConsumable }; diff --git a/tests/integration/inventory.test.js b/tests/integration/inventory.test.js index 2962ff6..760067f 100644 --- a/tests/integration/inventory.test.js +++ b/tests/integration/inventory.test.js @@ -12,7 +12,7 @@ describe('inventoryService', () => { before(async () => { await setup(); db = require('../../src/db').getDb(); - ({ listInventory, equipItem, unequipItem } = require('../../src/services/inventoryService')); + ({ listInventory, equipItem, unequipItem, useConsumable } = require('../../src/services/inventoryService')); ({ createCharacter } = require('../../src/services/characterService')); }); @@ -163,4 +163,111 @@ describe('inventoryService', () => { assert.ok(result.derived.maxHp > baseMaxHp, `maxHp should increase with vit bonus (was ${baseMaxHp}, now ${result.derived.maxHp})`); }); + + it('uses a healing potion and increases HP (capped at maxHp)', () => { + const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 }); + // Grant 3 Minor Healing Potions (item 14, heal:50) + grantItem(id, 14, 3); + + // Set HP to 60 (out of ~145 maxHp for warrior with vit=8: 50+80+5=135) + db.prepare('UPDATE characters SET hp_current = 60 WHERE id = ?').run(id); + + const before = listInventory(id); + const beforeHp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(id).hp_current; + const maxHp = before.derived.maxHp; + + // Use one potion: should heal to min(60+50, maxHp) + const result = useConsumable(id, 14); + const afterHp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(id).hp_current; + + assert.equal(afterHp, Math.min(beforeHp + 50, maxHp)); + // Quantity should be 3 → 2 (still in inventory) + const potion = result.items.find((i) => i.itemId === 14); + assert.equal(potion.quantity, 2); + }); + + it('uses an energy tonic and restores energy (capped at 100)', () => { + const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 }); + // Grant 2 Energy Tonics (item 16, energy:25) + grantItem(id, 16, 2); + + // Set energy to 40 + db.prepare('UPDATE characters SET energy = 40 WHERE id = ?').run(id); + + const before = listInventory(id); + const beforeEnergy = db.prepare('SELECT energy FROM characters WHERE id = ?').get(id).energy; + + // Use one tonic: should restore to min(40+25, 100) = 65 + const result = useConsumable(id, 16); + const afterEnergy = db.prepare('SELECT energy FROM characters WHERE id = ?').get(id).energy; + + assert.equal(afterEnergy, Math.min(beforeEnergy + 25, 100)); + // Quantity should be 2 → 1 + const tonic = result.items.find((i) => i.itemId === 16); + assert.equal(tonic.quantity, 1); + }); + + it('rejects using a non-consumable item', () => { + const id = makeHero(); + grantItem(id, 1); // Rusty Sword (weapon) + assert.throws(() => useConsumable(id, 1), (e) => e.code === 'VALIDATION_ERROR'); + }); + + it('rejects using an item not in inventory', () => { + const id = makeHero(); + // Item 14 exists but not granted to this character + assert.throws(() => useConsumable(id, 14), (e) => e.code === 'NOT_FOUND'); + }); + + it('removes inventory row when quantity reaches 0 (single-item stack)', () => { + const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 }); + // Grant 1 Greater Healing Potion (item 15, heal:150) + grantItem(id, 15, 1); + + // Set HP low + db.prepare('UPDATE characters SET hp_current = 20 WHERE id = ?').run(id); + + const before = listInventory(id); + assert.equal(before.items.find((i) => i.itemId === 15).quantity, 1); + + useConsumable(id, 15); + + const after = listInventory(id); + const potion = after.items.find((i) => i.itemId === 15); + assert.equal(potion, undefined, 'consumable row should be removed when quantity hits 0'); + }); + + it('caps heal effect at maxHp', () => { + const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 }); + grantItem(id, 14, 5); // Minor Healing Potion (heal:50) + + // Set HP already at max + const before = listInventory(id); + const maxHp = before.derived.maxHp; + db.prepare('UPDATE characters SET hp_current = ? WHERE id = ?').run(maxHp, id); + + useConsumable(id, 14); + + const after = listInventory(id); + const hp = db.prepare('SELECT hp_current FROM characters WHERE id = ?').get(id).hp_current; + assert.equal(hp, maxHp, 'HP should not exceed maxHp'); + // Quantity should decrease + assert.equal(after.items.find((i) => i.itemId === 14).quantity, 4); + }); + + it('caps energy effect at ENERGY_MAX (100)', () => { + const id = makeHero({ level: 1, str: 8, agi: 4, int: 2, vit: 8 }); + grantItem(id, 16, 5); // Energy Tonic (energy:25) + + // Set energy to 90 + db.prepare('UPDATE characters SET energy = 90 WHERE id = ?').run(id); + + useConsumable(id, 16); + + const after = listInventory(id); + const energy = db.prepare('SELECT energy FROM characters WHERE id = ?').get(id).energy; + assert.equal(energy, 100, 'Energy should not exceed 100'); + // Quantity should decrease + assert.equal(after.items.find((i) => i.itemId === 16).quantity, 4); + }); });