step 6.3: done

This commit is contained in:
bepis
2026-10-21 01:10:23 +11:00
parent 2930c70010
commit cf5c4616d8
4 changed files with 91 additions and 22 deletions
+13 -11
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:** 6.2 (Shop UI: `shop.html` + `shop.js` with buy/sell tabs, level-gated stock display, material sell list) — TICKED in STATUS.md - **Last completed step:** 6.3 (Shop tests: sell equipped item rejection + concurrent double-buy safety) — TICKED in STATUS.md
- **Next step:** 6.3 — Additional tests: buy with insufficient gold (already covered in shop.test.js), sell equipped item (must be rejected), concurrent double-buy safety (transaction) - **Next step:** 7.1 — Migration `005_expeditions.sql`. Expedition definitions in seed data.
- **Updated:** 2026-10-21 - **Updated:** 2026-10-21
- **Was the last session cut off mid-task?** No - **Was the last session cut off mid-task?** No
@@ -14,7 +14,7 @@
|---|---| |---|---|
| `npm install` | succeeds | | `npm install` | succeeds |
| `npm run migrate` | applies 001–004 and 006 / "nothing to do" | | `npm run migrate` | applies 001–004 and 006 / "nothing to do" |
| `npm test` | **137 passing, 0 failing** (85 unit + 52 integration) | | `npm test` | **139 passing, 0 failing** (85 unit + 54 integration) |
## 3. What exists now (files that matter) ## 3. What exists now (files that matter)
- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp. - `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp.
@@ -24,7 +24,7 @@
- `src/services/combatLogService.js` — `getCombatLog(characterId, limit)` → recent fights. - `src/services/combatLogService.js` — `getCombatLog(characterId, limit)` → recent fights.
- `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`. - `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`.
- `src/services/inventoryService.js` — `listInventory`, `equipItem`, `unequipItem`, `useConsumable`. Uses WeakMap stmt cache. - `src/services/inventoryService.js` — `listInventory`, `equipItem`, `unequipItem`, `useConsumable`. Uses WeakMap stmt cache.
- `src/services/shopService.js` — `getStock`, `buyItem`, `sellItem`. Uses WeakMap stmt cache. Sell value = 40% of buy_price (materials use rarity default). - `src/services/shopService.js` — `getStock`, `buyItem`, `sellItem`. Uses WeakMap stmt cache. Sell value = 40% of buy_price (materials use rarity default). **Equipped items rejected in sellItem (check moved before material type check).**
- `src/routes/shop.js` — `GET /api/shop`, `POST /api/shop/buy`, `POST /api/shop/sell`. - `src/routes/shop.js` — `GET /api/shop`, `POST /api/shop/buy`, `POST /api/shop/sell`.
- `src/routes/inventory.js` — `GET /api/inventory`, `POST /api/inventory/equip`, `POST /api/inventory/unequip`, `POST /api/inventory/use`. - `src/routes/inventory.js` — `GET /api/inventory`, `POST /api/inventory/equip`, `POST /api/inventory/unequip`, `POST /api/inventory/use`.
- `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services. - `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services.
@@ -32,7 +32,7 @@
- `src/routes/combatLog.js` — `GET /api/combat-log`. - `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/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. - `src/db/migrations/004_inventory.sql` — inventory table, FK constraints, partial unique index for equipped slots.
- `tests/integration/shop.test.js` — 14 integration tests (stock listing, level gating, buy, sell, error paths, round-trip spread). - `tests/integration/shop.test.js` — 16 integration tests (stock, level gating, buy, sell, equipped rejection, concurrent double-buy, round-trip).
- `tests/integration/shop-page.test.js` — 5 integration tests (stock listing, levelOk, sell pricing, buy/sell round-trip). - `tests/integration/shop-page.test.js` — 5 integration tests (stock listing, levelOk, sell pricing, buy/sell round-trip).
- `tests/integration/inventory.test.js` — 20 integration tests (13 equip/unequip + 7 consumable). - `tests/integration/inventory.test.js` — 20 integration tests (13 equip/unequip + 7 consumable).
- `public/shop.html` — dedicated shop page with buy/sell tabs. - `public/shop.html` — dedicated shop page with buy/sell tabs.
@@ -49,7 +49,7 @@
- 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). - Equipment bonuses not yet passed into combat (Issue #5 in STATUS.md).
- Step 6.3 needs: sell equipped item rejection test, concurrent double-buy safety test. - Step 7.1: need migration `005_expeditions.sql` with expedition table + seed data.
## 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).
@@ -60,6 +60,7 @@
- UI uses emoji icons (⚔️🛡️💍🧪📦) for item types per UI_GUIDE recommendation. - UI uses emoji icons (⚔️🛡️💍🧪📦) for item types per UI_GUIDE recommendation.
- Rarity colors match UI_GUIDE spec (common #b8b0a3, uncommon #5fb36b, rare #4a8fe0, epic #a45ee0, legendary #f2a23a). - Rarity colors match UI_GUIDE spec (common #b8b0a3, uncommon #5fb36b, rare #4a8fe0, epic #a45ee0, legendary #f2a23a).
- **D-008:** Shop materials with buy_price=0 sell at `floor(default_rate * character_level / item_min_level)` where default_rate is common=2, uncommon=4, rare=8, epic=16, legendary=32 (min 1). - **D-008:** Shop materials with buy_price=0 sell at `floor(default_rate * character_level / item_min_level)` where default_rate is common=2, uncommon=4, rare=8, epic=16, legendary=32 (min 1).
- **D-009:** `sellItem` checks equipped_slot before material type, so equipped items are always rejected regardless of item type.
## 6. Gotchas learned ## 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. - `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`. A shell glob once skipped 17 tests.
@@ -73,11 +74,12 @@
- Transaction callback args must match the call order exactly — passing `qty` as the second arg when the callback expects `etype` silently misapplies the effect. - Transaction callback args must match the call order exactly — passing `qty` as the second arg when the callback expects `etype` silently misapplies the effect.
- Error responses from route-level `AppError`s do NOT include `data` field — only `{ ok: false, error: { code, message } }`. - Error responses from route-level `AppError`s do NOT include `data` field — only `{ ok: false, error: { code, message } }`.
## 7. Next-step briefing (6.3) ## 7. Next-step briefing (7.1)
- **Goal:** Additional shop tests — buy with insufficient gold (already covered in shop.test.js), sell equipped item (must be rejected), concurrent double-buy safety (transaction). - **Goal:** Migration `005_expeditions.sql` — create expeditions table and seed expedition definitions.
- **Docs:** GAME_DESIGN.md §Economy, API.md. - **Docs:** GAME_DESIGN.md §Expeditions, DATABASE.md.
- **Files to modify:** `tests/integration/shop.test.js` or `tests/integration/shop-page.test.js`. - **Files to create:** `src/db/migrations/005_expeditions.sql`.
- **Key rules:** Sell equipped item → should reject (item is in slot, still in inventory but equipped). Concurrent double-buy → two requests for same item simultaneously, only one should succeed if gold is insufficient for both. - **Key schema:** expeditions table (id, character_id, expedition_id, started_at, ends_at, claimed boolean FK constraints). Only one active expedition per hero at a time.
- **Seed data:** expedition definitions in the same migration (INSERT OR IGNORE).
## 8. Open Known Issues ## 8. Open Known Issues
STATUS.md issues 1–6 (balance, dangling `seed` script, restart test, equipment-in-combat, HP regen, zone min_level balance). STATUS.md issues 1–6 (balance, dangling `seed` script, restart test, equipment-in-combat, HP regen, zone min_level balance).
+7 -5
View File
@@ -7,10 +7,10 @@
> - Keep Session Log entries to one or two lines. > - Keep Session Log entries to one or two lines.
## Current Focus ## Current Focus
- **Phase:** 6 - **Phase:** 7
- **Step:** 6.3 - **Step:** 7.1
- **Blocked?** No - **Blocked?** No
- **Last verified working command:** `npm test` — 137/137 passing (85 unit + 52 integration), 2026-10-21 step 6.2 - **Last verified working command:** `npm test` — 139/139 passing (85 unit + 54 integration), 2026-10-21 step 6.3
## Project Health ## Project Health
| Item | State | | Item | State |
@@ -87,7 +87,8 @@
*Acceptance:* shop endpoints return stock, buy deducts gold, sell adds gold at 40%, gold never goes negative. *Verified:* 14 integration tests covering stock listing, level gating, buy/sell, gold math, error paths, round-trip spread. *Acceptance:* shop endpoints return stock, buy deducts gold, sell adds gold at 40%, gold never goes negative. *Verified:* 14 integration tests covering stock listing, level gating, buy/sell, gold math, error paths, round-trip spread.
- [x] **6.2** Level-gated shop stock. UI page. - [x] **6.2** Level-gated shop stock. UI page.
*Acceptance:* shop page loads with buy/sell tabs; stock filtered by character level; materials always visible for selling. *Verified:* shop.html + shop.js created; 5 integration tests covering stock listing, level gating, sell pricing, buy/sell round-trip. *Acceptance:* shop page loads with buy/sell tabs; stock filtered by character level; materials always visible for selling. *Verified:* shop.html + shop.js created; 5 integration tests covering stock listing, level gating, sell pricing, buy/sell round-trip.
- [ ] **6.3** Tests: buy with insufficient gold, sell equipped item (must be rejected), concurrent double-buy safety (transaction). - [x] **6.3** Tests: buy with insufficient gold (already covered), sell equipped item (must be rejected), concurrent double-buy safety (transaction).
*Acceptance:* sell equipped item → 400 VALIDATION_ERROR; concurrent double-buy → exactly one succeeds with INSUFFICIENT_GOLD for the other; insufficient gold already covered. *Verified:* 2 new integration tests added to shop.test.js; `sellItem` check moved before material type check so equipped items are rejected regardless of type.
## Phase 7 — Expeditions (Offline Progress) (docs: GAME_DESIGN §Time, §Expeditions) ## Phase 7 — Expeditions (Offline Progress) (docs: GAME_DESIGN §Time, §Expeditions)
- [ ] **7.1** Migration `005_expeditions.sql`. Expedition definitions in seed data. - [ ] **7.1** Migration `005_expeditions.sql`. Expedition definitions in seed data.
@@ -148,10 +149,11 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing`
| Suite | Passing | Failing | Last run | | Suite | Passing | Failing | Last run |
|---|---|---|---| |---|---|---|---|
| unit | 85 | 0 | 2026-10-21 step 6.1 | | unit | 85 | 0 | 2026-10-21 step 6.1 |
| integration | 47 | 0 | 2026-10-21 step 6.1 | | integration | 54 | 0 | 2026-10-21 step 6.3 |
## Session Log ## Session Log
_Format:_ `YYYY-MM-DD | step | what changed | result` _Format:_ `YYYY-MM-DD | step | what changed | result`
- 2026-10-21 | 6.3 | Added 2 integration tests to shop.test.js (sell equipped item rejection, concurrent double-buy safety); moved equipped check before material type check in sellItem so equipped items are rejected regardless of type | 139/139 tests pass
- 2026-10-21 | 6.2 | Created shop UI: `public/shop.html` + `public/js/shop.js` (buy/sell tabs, level-gated stock display, material sell list, toast notifications); 5 integration tests for shop page API coverage | 137/137 tests pass - 2026-10-21 | 6.2 | Created shop UI: `public/shop.html` + `public/js/shop.js` (buy/sell tabs, level-gated stock display, material sell list, toast notifications); 5 integration tests for shop page API coverage | 137/137 tests pass
- 2026-10-21 | 6.1 | Ticked step 6.1 — acceptance criteria verified: GET /api/shop, POST /api/shop/buy, POST /api/shop/sell all implemented with 40% sell value and gold-negative protection; 14 integration tests all passing | 132/132 tests pass - 2026-10-21 | 6.1 | Ticked step 6.1 — acceptance criteria verified: GET /api/shop, POST /api/shop/buy, POST /api/shop/sell all implemented with 40% sell value and gold-negative protection; 14 integration tests all passing | 132/132 tests pass
- 2026-10-21 | 6.1 | Created `src/services/shopService.js` (getStock, buyItem, sellItem) + `src/routes/shop.js` (GET /api/shop, POST /api/shop/buy, POST /api/shop/sell); registered routes in app.js; 14 integration tests covering stock listing, level gating, buy/sell, gold math, error paths, round-trip spread | 132/132 tests pass - 2026-10-21 | 6.1 | Created `src/services/shopService.js` (getStock, buyItem, sellItem) + `src/routes/shop.js` (GET /api/shop, POST /api/shop/buy, POST /api/shop/sell); registered routes in app.js; 14 integration tests covering stock listing, level gating, buy/sell, gold math, error paths, round-trip spread | 132/132 tests pass
+13 -6
View File
@@ -148,6 +148,19 @@ function sellItem(characterId, itemId, quantity) {
const item = s.itemById.get(itemId); const item = s.itemById.get(itemId);
if (!item) throw new AppError('NOT_FOUND', 'Item not found.', 404); if (!item) throw new AppError('NOT_FOUND', 'Item not found.', 404);
// Find inventory rows for this (character, item).
const rows = s.inventoryRows.all(characterId, itemId, 1);
if (rows.length === 0) {
throw new AppError('NOT_FOUND', 'Item not in inventory.', 404);
}
// Reject if any row is equipped.
for (const row of rows) {
if (row.equipped_slot !== null) {
throw new AppError('VALIDATION_ERROR', 'Unequip the item before selling.', 400);
}
}
if (item.type !== 'material') { if (item.type !== 'material') {
throw new AppError('VALIDATION_ERROR', 'Only materials can be sold.', 400); throw new AppError('VALIDATION_ERROR', 'Only materials can be sold.', 400);
} }
@@ -161,12 +174,6 @@ function sellItem(characterId, itemId, quantity) {
sellPrice = Math.max(1, Math.floor(defaultRate * ch.level / item.min_level)); sellPrice = Math.max(1, Math.floor(defaultRate * ch.level / item.min_level));
} }
// Find inventory rows for this (character, item).
const rows = s.inventoryRows.all(characterId, itemId, 1);
if (rows.length === 0) {
throw new AppError('NOT_FOUND', 'Item not in inventory.', 404);
}
// Calculate how many we can actually sell from available rows. // Calculate how many we can actually sell from available rows.
let remaining = quantity; let remaining = quantity;
const sales = []; // { rowId, qty, sellPrice } const sales = []; // { rowId, qty, sellPrice }
+58
View File
@@ -236,6 +236,39 @@ describe('shop', () => {
assert.equal(body.error.code, 'VALIDATION_ERROR'); assert.equal(body.error.code, 'VALIDATION_ERROR');
}); });
it('concurrent double-buy: only one succeeds when gold covers exactly one', async () => {
const { cookie, characterId } = await setupHero({ gold: 20 });
// Rusty Sword costs 20 gold — exactly enough for one.
// Send two buy requests concurrently.
const [resA, resB] = await Promise.all([
fetch(`${baseUrl}/api/shop/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 1, quantity: 1 }),
}),
fetch(`${baseUrl}/api/shop/buy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 1, quantity: 1 }),
}),
]);
const bodyA = await resA.json();
const bodyB = await resB.json();
// Exactly one should succeed, the other should fail with INSUFFICIENT_GOLD.
const success = [bodyA, bodyB].filter((b) => b.ok === true);
const failures = [bodyA, bodyB].filter((b) => b.ok === false);
assert.equal(success.length, 1, 'exactly one buy should succeed');
assert.equal(failures.length, 1, 'exactly one buy should fail');
assert.equal(failures[0].error.code, 'INSUFFICIENT_GOLD');
// Gold should be 0 after one successful buy.
const winner = success[0];
assert.equal(winner.data.character.gold, 0);
});
// ─── POST /api/shop/sell ────────────────────────────────────────────── // ─── POST /api/shop/sell ──────────────────────────────────────────────
it('sells a material and increases gold', async () => { it('sells a material and increases gold', async () => {
@@ -336,6 +369,31 @@ describe('shop', () => {
assert.equal(body.error.code, 'NOT_FOUND'); assert.equal(body.error.code, 'NOT_FOUND');
}); });
// ─── Sell equipped item rejection ─────────────────────────────────────
it('rejects selling an equipped item', async () => {
const { cookie, characterId } = await setupHero({ gold: 100 });
// Grant and equip Rusty Sword (item 1, weapon)
grantItem(characterId, 1, 1);
const db = require('../../src/db').getDb();
db.prepare('UPDATE inventory SET equipped_slot = ? WHERE character_id = ? AND item_id = ?').run('weapon', characterId, 1);
// Try to sell the equipped Rusty Sword
const res = await fetch(`${baseUrl}/api/shop/sell`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) },
body: JSON.stringify({ itemId: 1, quantity: 1 }),
});
const body = await res.json();
assert.equal(res.status, 400);
assert.equal(body.ok, false);
assert.equal(body.error.code, 'VALIDATION_ERROR');
assert.ok(body.error.message.includes('Unequip'), 'should mention unequipping');
});
// ─── Round-trip ─────────────────────────────────────────────────────── // ─── Round-trip ───────────────────────────────────────────────────────
it('buy and sell round-trip: buy then sell loses gold (spread)', async () => { it('buy and sell round-trip: buy then sell loses gold (spread)', async () => {