step 10.15: done

This commit is contained in:
bepis
2026-10-23 06:16:38 +11:00
parent 2a79c4ea43
commit 81794c7dbd
7 changed files with 240 additions and 34 deletions
+9 -8
View File
@@ -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.
+7 -5
View File
@@ -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
+6
View File
@@ -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.
<!-- Add D-008 onward below -->
+25
View File
@@ -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 };
+1 -11
View File
@@ -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
+20 -10
View File
@@ -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);
+172
View File
@@ -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);
}
});
});