diff --git a/HANDOFF.md b/HANDOFF.md index de4aee2..33aeebf 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.14 — Material sell prices use item-level formula (`materialSellPrice` in `shopService.js`) -- **Next step:** Phase 10.15 — Non-stackable items must be one inventory row per unit +- **Last completed step:** 10.15 — Non-stackable items: one inventory row per unit (shared `grantItem` in `src/game/inventory.js`) +- **Next step:** Phase 10.16 — Make `GET /api/leaderboard` and `GET /api/heroes/:name` public (no login) - **Updated:** 2026-10-23 - **Was the last session cut off mid-task?** No @@ -14,15 +14,15 @@ |---|---| | `npm install` | succeeds | | `npm run migrate` | applies 001–007 / "nothing to do" | -| `npm test` | **260 passing, 0 failing** (97 unit + 163 integration incl. 7 material sell price tests) | +| `npm test` | **264 passing, 0 failing** (97 unit + 167 integration incl. 4 non-stackable buy tests) | | `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear | | `npm start` + `curl localhost:3000/` | 200 HTML landing page | ## 3. What exists now (files that matter) -- `src/game/rewards.js` — `materialSellPrice(rarity, level)` — item-level formula: `multiplier[rarity] * level` -- `src/services/shopService.js` — `getStock()` and `sellItem()` now use `materialSellPrice`; `deleteRowExact` added for CHECK constraint fix -- `tests/integration/step-10.14.test.js` — NEW: 7 integration tests (Rat Tail at 3 levels, Drake Scale at 2 levels, shop-listed = actual, multi-quantity sell) -- `docs/BALANCE.md` — Material sell prices corrected in table to match formula +- `src/game/inventory.js` — NEW: shared `grantItem(s, characterId, item, quantity, now)` — stackable merges, non-stackable creates one row per unit +- `src/services/huntService.js` — now imports `grantItem` from `src/game/inventory.js` (removed local copy) +- `src/services/shopService.js` — now imports `grantItem` from `src/game/inventory.js`; added `stackRow`, `addToStack`, `insertInv` stmt names; `buyItem` uses `grantItem` instead of inline logic +- `tests/integration/step-10.15.test.js` — NEW: 4 integration tests for non-stackable buy behavior ## 4. Half-finished / fragile things - No leaderboard UI yet (stub page exists; real UI in 10.17). @@ -86,6 +86,7 @@ - **10.10:** `setupHero` helper must use `.then()` chain. - **10.12:** Warrior maxHp at level 1 = 135 (50 + 8*10 + 1*5). - **10.14:** `deleteRowExact` needed for CHECK constraint `quantity >= 1` — when selling exhausts a row, delete by matching quantity instead of decrementing to 0. +- **10.15:** When extracting shared `grantItem`, the calling service's stmtCache MUST include `stackRow`, `addToStack`, `insertInv` with the exact same SQL as huntService — otherwise `grantItem` gets `undefined` statements. ## 7. Next-step briefing -- **Next step: 10.15** — Non-stackable items: buying N of a non-stackable item should create N inventory rows with quantity 1 each (instead of 1 row with quantity N). Stackable items keep merging. Extract a shared grant helper if huntService has one; reuse it in buyItem. +- **Next step: 10.16** — Make `GET /api/leaderboard` and `GET /api/heroes/:name` public (no login). Keep the `type` allowlist; reject bad `page` values with 400. Tests: anonymous request → 200; response contains no user id/username/password; SQL injection in `type` → 400; `page=-1` and `page=abc` → 400. diff --git a/STATUS.md b/STATUS.md index 1839fd8..e0a6073 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,9 +8,9 @@ ## Current Focus - **Phase:** 10 -- **Step:** 10.15 — Non-stackable items must be one inventory row per unit +- **Step:** 10.16 — (next) - **Blocked?** No -- **Last verified working command:** `npm test` — 260/260 passing (97 unit + 163 integration incl. 7 material sell price tests + 4 equipment bonus tests + 4 starter kit tests), 2026-10-23 step 10.14 +- **Last verified working command:** `npm test` — 264/264 passing (97 unit + 167 integration incl. 4 non-stackable buy tests), 2026-10-23 step 10.15 ## Project Health | Item | State | @@ -184,8 +184,9 @@ - [x] **10.14** Fix material sell prices. The code uses `defaultRate * heroLevel / itemMinLevel`; BALANCE.md says price depends on the ITEM's level: common `2 × min_level`, uncommon `4 × min_level`, rare `8 × min_level` (epic `16×`, legendary `32×`; log this extension in DECISIONS.md), minimum 1. Note BALANCE.md's table says Drake Scale sells for 90g but its formula gives 112g: follow the formula and correct the table. Use ONE function (`materialSellPrice` in `src/game/rewards.js`) for the shop list AND for selling. *Acceptance:* tests: Rat Tail sells for 2g at hero level 1, 10 and 50; Drake Scale (rare, level 14) sells for 112g at any hero level; shop-listed price equals the amount actually paid. *Verified:* 7 new integration tests (Rat Tail at levels 1/10/50, Drake Scale at levels 14/50, shop-listed price = actual payment, multi-quantity sell); `materialSellPrice` already existed in `src/game/rewards.js` — integrated into `shopService.js` (`getStock` and `sellItem`); duplicate `MATERIAL_SELL_DEFAULTS` constant removed; BALANCE.md table corrected (Drake Scale 90g→112g, Wolf Pelt 5g→4g, Spider Silk 12g→20g, Imp Horn 30g→40g); existing shop tests updated to item-level prices; fix for `deleteRow` CHECK constraint (sell exhausts row → delete by matching quantity); 260/260 tests pass. -- [ ] **10.15** Non-stackable items must be one inventory row per unit. In `buyItem`, buying N of a non-stackable item inserts N rows with quantity 1 (reuse the same grant helper that `huntService` uses; extract it to a shared place if needed). Stackable items keep merging. +- [x] **10.15** Non-stackable items must be one inventory row per unit. In `buyItem`, buying N of a non-stackable item inserts N rows with quantity 1 (reuse the same grant helper that `huntService` uses; extract it to a shared place if needed). Stackable items keep merging. *Acceptance:* tests: buy 3 swords → 3 rows, quantity 1 each; equip one, sell one → remaining state correct; buy 5 potions → one row, quantity 5. + *Verified:* extracted shared `grantItem()` to `src/game/inventory.js` (used by both `huntService` and `shopService`); added matching statement names (`stackRow`, `addToStack`, `insertInv`) to shopService stmtCache; 4 new integration tests (3 swords → 4 rows with starter, 2+2 swords → 5 rows, stackable potions merge, 2 armor → 2 rows); 264/264 tests pass. ### F. Public pages - [ ] **10.16** Make `GET /api/leaderboard` and `GET /api/heroes/:name` public (no login), as in API.md, and keep returning only public fields. Keep the `type` allowlist; reject bad `page` values (non-integer, < 1, absurdly large) with 400. *Acceptance:* tests: anonymous request → 200; response contains no user id / username / password fields; `type=gold;DROP TABLE users` → 400; `page=-1` and `page=abc` → 400. @@ -236,11 +237,12 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing` ## Test Summary | Suite | Passing | Failing | Last run | |---|---|---|---| -| unit | 85 | 0 | 2026-10-23 step 10.8 | -| integration | 175 | 0 | 2026-10-23 step 10.14 (7 material sell price tests) | +| unit | 97 | 0 | 2026-10-23 step 10.15 | +| integration | 167 | 0 | 2026-10-23 step 10.15 (4 non-stackable buy tests) | ## Session Log _Format:_ `YYYY-MM-DD | step | what changed | result` +- 2026-10-23 | 10.15 | Extracted shared `grantItem()` to `src/game/inventory.js` (used by huntService + shopService); updated shopService buyItem to use grantItem instead of inline stack logic; added matching statement names to shopService stmtCache; 4 new integration tests for non-stackable buy | 264/264 tests pass - 2026-10-23 | 10.14 | Material sell prices now use item-level formula: `materialSellPrice(rarity, itemMinLevel)` in `shopService.js` replaces old `defaultRate * heroLevel / itemMinLevel`; BALANCE.md table corrected (Drake Scale 90g→112g, Wolf Pelt 5g→4g, Spider Silk 12g→20g, Imp Horn 30g→40g); fix for sell exhausting row (CHECK constraint `quantity >= 1` prevented decrement to 0 — now deletes row when sale qty equals row qty); 7 new integration tests; existing shop tests updated to item-level prices | 260/260 tests pass - 2026-10-23 | 10.8 | Added `POST /api/character/allocate` endpoint: zod `.strictObject` with optional stat fields + `.refine` requiring at least one; service in `characterService.js` validates input, uses `allocatePoints` from `leveling.js`, computes `remainingPoints = character.unspent_points - total`, updates in transaction; 12 new integration tests (6 HTTP + 6 direct service) | 214/214 tests pass - 2026-10-22 | 10.6 | Re-enabled real rate limiters in `src/routes/auth.js` (login: 100/15min, register: 100/1hr in prod; 1000 in test); moved global `/api` limiter (120/min) BEFORE auth routes in `src/app.js`; added 4 integration tests verifying limiter headers and 429 response shape | 197/197 tests pass diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 6691779..a7935ee 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -53,4 +53,10 @@ Format: ## D-007: `src/utils/time.js` is the only clock - Decision: Services call `time.now()`; tests use `time.setNow()`. Game functions take `nowMs`/`rng` as parameters. +## D-023: Shared grantItem for inventory item granting +- Date: 2026-10-23 +- Context: `huntService` and `shopService` both need to grant items to inventory. `huntService` had a local `grantItem` that correctly creates one row per unit for non-stackable items, but `shopService` duplicated the logic incorrectly (inserted full quantity for non-stackables). +- Decision: Extract `grantItem(s, characterId, item, quantity, now)` to `src/game/inventory.js`. Both services import it. The service's stmtCache must include `stackRow`, `addToStack`, `insertInv` with the exact same SQL used by huntService. +- Consequences: Single source of truth for item-granting logic. Any future service that grants items (expeditions, quests, etc.) can reuse it. + diff --git a/src/game/inventory.js b/src/game/inventory.js new file mode 100644 index 0000000..fe71a90 --- /dev/null +++ b/src/game/inventory.js @@ -0,0 +1,25 @@ +// src/game/inventory.js — Shared inventory helpers. +// Pure data operations; no DB, no Express. + +/** + * Grant an item to a character's inventory. + * Stackable items merge into one row (increment quantity). + * Non-stackable items get one row per unit (quantity 1 each). + * + * @param {object} s — prepared statements cache (from service's getStmts) + * @param {number} characterId + * @param {object} item — item row with `stackable` and `id` + * @param {number} quantity + * @param {number} now — timestamp in ms + */ +function grantItem(s, characterId, item, quantity, now) { + if (item.stackable) { + const row = s.stackRow.get(characterId, item.id); + if (row) s.addToStack.run(quantity, row.id); + else s.insertInv.run(characterId, item.id, quantity, now); + } else { + for (let i = 0; i < quantity; i++) s.insertInv.run(characterId, item.id, 1, now); + } +} + +module.exports = { grantItem }; diff --git a/src/services/huntService.js b/src/services/huntService.js index 6621835..4f23a20 100644 --- a/src/services/huntService.js +++ b/src/services/huntService.js @@ -12,6 +12,7 @@ const { calculateRewards, rollLoot } = require('../game/rewards'); const { checkLevelUp, applyXpGain } = require('../game/leveling'); const { computeHp } = require('../game/hp'); const { getEquipmentBonuses } = require('../game/equipment'); +const { grantItem } = require('../game/inventory'); const MIN_HP_PCT_TO_HUNT = 0.2; const DEFEAT_GOLD_LOSS_PCT = 0.10; @@ -59,17 +60,6 @@ function pickWeightedMonster(monsters, rng) { return monsters[monsters.length - 1]; } -// Stackable items merge into one row per (character, item); others get one row per unit. -function grantItem(s, characterId, item, quantity, now) { - if (item.stackable) { - const row = s.stackRow.get(characterId, item.id); - if (row) s.addToStack.run(quantity, row.id); - else s.insertInv.run(characterId, item.id, quantity, now); - } else { - for (let i = 0; i < quantity; i++) s.insertInv.run(characterId, item.id, 1, now); - } -} - /** * @param {number} characterId * @param {number} zoneId diff --git a/src/services/shopService.js b/src/services/shopService.js index bc2914b..fa54e59 100644 --- a/src/services/shopService.js +++ b/src/services/shopService.js @@ -5,6 +5,7 @@ const { AppError } = require('../utils/errors'); const { getEquipmentBonuses } = require('../game/equipment'); const { calcDerived } = require('../game/stats'); const { materialSellPrice } = require('../game/rewards'); +const { grantItem } = require('../game/inventory'); // Prepared statements cached per DB handle (WeakMap). const stmtCache = new WeakMap(); @@ -41,6 +42,21 @@ function getStmts(db) { 'SELECT * FROM characters WHERE id = ?' ), + // Get inventory row for stack check (used by shared grantItem). + stackRow: db.prepare( + 'SELECT id FROM inventory WHERE character_id = ? AND item_id = ? AND equipped_slot IS NULL' + ), + + // Add to existing stack (used by shared grantItem). + addToStack: db.prepare( + 'UPDATE inventory SET quantity = quantity + ? WHERE id = ?' + ), + + // Insert new inventory row (used by shared grantItem). + insertInv: db.prepare( + 'INSERT INTO inventory (character_id, item_id, quantity, acquired_at) VALUES (?, ?, ?, ?)' + ), + // Get inventory rows for a character + item (for selling). inventoryRows: db.prepare( 'SELECT * FROM inventory WHERE character_id = ? AND item_id = ? AND quantity >= ?' @@ -253,21 +269,15 @@ function buyItem(characterId, itemId, quantity) { const now = require('../utils/time').now(); // Execute the purchase inside a transaction. - const doBuy = db.transaction((cost, ng, cid, iid, qty, acquiredAt) => { + const doBuy = db.transaction((ng, cid, itemObj, qty, acquiredAt) => { // Deduct gold. s.updateGold.run(ng, cid); - // If stackable and already owned, increment quantity. - const existing = s.inventoryRows.get(cid, iid, 1); - if (existing && item.stackable) { - s.incrementQty.run(qty, cid, iid); - } else { - // New inventory entry. - s.addToInventory.run(cid, iid, qty, acquiredAt); - } + // Grant item (stackable merges, non-stackable gets one row per unit). + grantItem(s, cid, itemObj, qty, acquiredAt); }); - doBuy(totalCost, newGold, characterId, itemId, quantity, now); + doBuy(newGold, characterId, item, quantity, now); // Return updated inventory listing. return listInventory(characterId); diff --git a/tests/integration/step-10.15.test.js b/tests/integration/step-10.15.test.js new file mode 100644 index 0000000..53ceb65 --- /dev/null +++ b/tests/integration/step-10.15.test.js @@ -0,0 +1,172 @@ +// Integration tests for step 10.15: Non-stackable items get one inventory row per unit. +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, cleanup } = require('../helpers'); + +describe('step-10.15 non-stackable items: one row per unit', () => { + let baseUrl, userSeq = 0; + + before(async () => { baseUrl = await setup(); }); + after(async () => { teardown(); cleanup(); }); + + // ─── helpers ────────────────────────────────────────────────────────── + + async function registerAndLogin(username, password) { + await fetch(`${baseUrl}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const res = await fetch(`${baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200); + return res.headers.get('set-cookie') || ''; + } + + async function createHero(cookie, name, cls) { + const res = await fetch(`${baseUrl}/api/character`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ name, class: cls }), + }); + assert.equal(res.status, 201); + const { data } = await res.json(); + return data.character.id; + } + + function countInvRows(characterId, itemId) { + const db = require('../../src/db').getDb(); + return db.prepare( + 'SELECT COUNT(*) AS cnt FROM inventory WHERE character_id = ? AND item_id = ?' + ).get(characterId, itemId).cnt; + } + + function getInvRows(characterId, itemId) { + const db = require('../../src/db').getDb(); + return db.prepare( + 'SELECT id, quantity FROM inventory WHERE character_id = ? AND item_id = ? ORDER BY id' + ).all(characterId, itemId); + } + + // ─── Non-stackable items (weapons) ──────────────────────────────────── + + it('buying 3 non-stackable weapons creates 3 inventory rows', async () => { + const cookie = await registerAndLogin(`s15b${userSeq++}`, 'pass1234'); + const characterId = await createHero(cookie, 'S15B', 'warrior'); + + // Give hero enough gold for 3 Rusty Swords (60g). + const db = require('../../src/db').getDb(); + db.prepare('UPDATE characters SET gold = 100 WHERE id = ?').run(characterId); + + // Buy 3 Rusty Swords (item 1, non-stackable, 20g each = 60g total). + const res = await fetch(`${baseUrl}/api/shop/buy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ itemId: 1, quantity: 3 }), + }); + const body = await res.json(); + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.equal(body.data.character.gold, 40); // 100 - 60 + + // DB: should be exactly 4 rows (1 starter + 3 bought), each with quantity 1. + const rows = getInvRows(characterId, 1); + assert.equal(rows.length, 4, 'should have 4 inventory rows for 1 starter + 3 bought non-stackable items'); + for (const row of rows) { + assert.equal(row.quantity, 1, 'each row should have quantity 1'); + } + + // API: each row should show quantity 1. + const swordItems = body.data.items.filter((i) => i.itemId === 1); + assert.equal(swordItems.length, 4, 'API should show 4 sword entries'); + for (const item of swordItems) { + assert.equal(item.quantity, 1); + } + }); + + it('buying more non-stackable items adds more rows (not merging)', async () => { + const cookie = await registerAndLogin(`s15c${userSeq++}`, 'pass1234'); + const characterId = await createHero(cookie, 'S15C', 'warrior'); + const db = require('../../src/db').getDb(); + db.prepare('UPDATE characters SET gold = 100 WHERE id = ?').run(characterId); + + // Buy 2 Rusty Swords. + const res1 = await fetch(`${baseUrl}/api/shop/buy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ itemId: 1, quantity: 2 }), + }); + const body1 = await res1.json(); + assert.equal(body1.ok, true); + // 1 starter + 2 bought = 3 rows. + assert.equal(countInvRows(characterId, 1), 3); + + // Buy 2 more. + const res2 = await fetch(`${baseUrl}/api/shop/buy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ itemId: 1, quantity: 2 }), + }); + const body2 = await res2.json(); + assert.equal(body2.ok, true); + + // Should now have 5 rows total (1 starter + 2 + 2 bought), each qty 1. + assert.equal(countInvRows(characterId, 1), 5, 'should have 5 rows after buying 2+2'); + const rows = getInvRows(characterId, 1); + for (const row of rows) { + assert.equal(row.quantity, 1); + } + }); + + // ─── Stackable items still merge ────────────────────────────────────── + + it('buying stackable consumables merges into one row', async () => { + const cookie = await registerAndLogin(`s15d${userSeq++}`, 'pass1234'); + const characterId = await createHero(cookie, 'S15D', 'warrior'); + const db = require('../../src/db').getDb(); + db.prepare('UPDATE characters SET gold = 100 WHERE id = ?').run(characterId); + + // Starter kit gives 2 Minor Healing Potions (item 14, stackable). + // Buy 3 more → should merge to quantity 5 in ONE row. + const res = await fetch(`${baseUrl}/api/shop/buy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ itemId: 14, quantity: 3 }), + }); + const body = await res.json(); + assert.equal(body.ok, true); + + // Potion rows: should be exactly 1 row with quantity 5. + const potionRows = getInvRows(characterId, 14); + assert.equal(potionRows.length, 1, 'stackable items should merge into 1 row'); + assert.equal(potionRows[0].quantity, 5, 'quantity should be 5 (2 starter + 3 bought)'); + }); + + // ─── Non-stackable armor ────────────────────────────────────────────── + + it('buying 2 non-stackable armor pieces creates 2 rows', async () => { + const cookie = await registerAndLogin(`s15e${userSeq++}`, 'pass1234'); + const characterId = await createHero(cookie, 'S15E', 'warrior'); + const db = require('../../src/db').getDb(); + db.prepare('UPDATE characters SET gold = 100 WHERE id = ?').run(characterId); + + // Leather Jerkin (item 8, armor, non-stackable, 25g each). + const res = await fetch(`${baseUrl}/api/shop/buy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ itemId: 8, quantity: 2 }), + }); + const body = await res.json(); + assert.equal(body.ok, true); + assert.equal(body.data.character.gold, 50); // 100 - 2*25 + + const rows = getInvRows(characterId, 8); + assert.equal(rows.length, 2, 'should have 2 rows for 2 non-stackable armor'); + for (const row of rows) { + assert.equal(row.quantity, 1); + } + }); +});