diff --git a/HANDOFF.md b/HANDOFF.md index 84ad18c..bff34c4 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.2 (Fix expedition CLAIM overwriting XP) — TICKED in STATUS.md -- **Next step:** Phase 10.3 — Make expeditions testable (inject time/rng) +- **Last completed step:** 10.3 (Make expeditions testable — inject time/rng) — TICKED in STATUS.md +- **Next step:** Phase 10.4 — Fix `GET /api/auth/me` and `POST /api/auth/login` to return real username and hasHero - **Updated:** 2026-10-22 - **Was the last session cut off mid-task?** No @@ -14,7 +14,7 @@ |---|---| | `npm install` | succeeds | | `npm run migrate` | applies 001–006 / "nothing to do" | -| `npm test` | **182 passing, 0 failing** (85 unit + 97 integration incl. 4 new 10.2 tests) | +| `npm test` | **184 passing, 0 failing** (85 unit + 99 integration incl. 2 new 10.3 tests) | | `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear | | `npm start` + `curl localhost:3000/api/health` | `{"ok":true}` | @@ -31,14 +31,14 @@ - `public/js/auth.js` — Shared API wrapper + showError. - `scripts/seed-dev.js` — Creates demo account + hero + starter gear. - `src/app.js` — Express app with helmet, rate limiters, catch-all 404, 500 HTML serving. -- `src/services/expeditionService.js` — Fixed in 10.1 (energy tx params) + 10.2 (uses `applyXpGain`). +- `src/services/expeditionService.js` — Fixed in 10.1 (energy tx params) + 10.2 (uses `applyXpGain`) + 10.3 (injectable `opts.now`/`opts.rng`). - `src/services/huntService.js` — Refactored in 10.2 to use `applyXpGain` instead of inline `checkLevelUp`. - `src/game/leveling.js` — Added `applyXpGain` shared function (used by huntService + expeditionService). -- `src/services/*` — All game services. -- `src/routes/*` — All route handlers. -- `src/game/*` — Pure game logic. -- `tests/integration/expeditions.test.js` — 20 expedition tests (incl. 4 new 10.2 tests). -- `tests/integration/*.test.js` — 97 integration tests (incl. 1 e2e). +- `src/routes/expeditions.js` — Route handler passes `req.body._opts` to `claimExpedition`. +- `src/utils/time.js` — `{ now, setNow, resetNow }` — `const time = require(...)`, not destructured. +- `src/utils/rng.js` — `{ mulberry32, rngInt, rngFloat, rngRange }` — Mulberry32 PRNG. +- `tests/integration/expeditions.test.js` — 22 expedition tests (incl. 2 new 10.3 tests). +- `tests/integration/*.test.js` — 99 integration tests (incl. 1 e2e). - `tests/unit/*.test.js` — 85 unit tests. - `README.md` — Updated with real run instructions, play-through steps, project structure, known issues. @@ -51,7 +51,7 @@ - Starter gear documented but not auto-created on character creation. - `GET /api/zones` documented in API.md but not implemented (Issue #2). - Mobile nav uses emoji icons (⚔️ 🏕️ 🎒) — verify they render acceptably on all platforms. -- **10.3 next:** expeditionService uses `Math.random()` for gold and `Date.now()` for time — needs injectable `rng` and `time.now()` so tests can control them. +- **10.4 next:** `GET /api/auth/me` and `POST /api/auth/login` need to return real `username` and correct `hasHero`. Frontend (`login.js`, `create-hero.js`) needs updating for navigation logic. ## 5. Decisions & deviations from the docs - D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock). @@ -92,6 +92,14 @@ - Inventory sell endpoint (`POST /api/shop/sell`) only accepts `material`-type items. - `handleListInventory` in `src/routes/inventory.js` — parameter name must be `res`, not `_res`. - Character profile response has `energy` at `data.energy.current`, NOT `data.character.energy`. +- **10.3:** `expeditionService.js` import changed from `const { now, setNow } = require('../utils/time')` to `const time = require('../utils/time')` — the destructured `now` variable was shadowed by the local `opts.now ?? time.now()` pattern. +- **10.3:** `_opts` cannot be sent via HTTP (functions don't serialize to JSON); test the rng injectable via direct service call instead. ## 7. Next-step briefing -- **Next step: 10.3** — Make expeditions testable. `claimExpedition` uses `Math.random()` for gold calculation and `now()` (which delegates to `Date.now()`) for time checks. Needs injectable `rng` and `time.now()` so tests can assert exact gold values and control time for ends_at checks. The route handler already passes `opts` to `startExpedition`; add the same pattern to `claimExpedition`. +- **Next step: 10.4** — Fix `GET /api/auth/me` and `POST /api/auth/login`: + - Currently `/me` may return hardcoded/stale username and `hasHero` is always `false` + - Need to store username in session at login/register + - `hasHero` should check if a character row exists for the user + - Frontend: `public/js/login.js` — returning players with a hero go to `/game.html`; without → `/create-hero.html` + - Frontend: `public/js/create-hero.js` — redirect to `/game.html` if hero already exists + - Tests: `/me` before hero → `hasHero:false`, after creating → `hasHero:true`, `username` non-empty diff --git a/STATUS.md b/STATUS.md index 3406873..ceea699 100644 --- a/STATUS.md +++ b/STATUS.md @@ -7,16 +7,16 @@ > - Keep Session Log entries to one or two lines. ## Current Focus -- **Phase:** 10.2 -- **Step:** 10.3 — Make expeditions testable (inject time/rng) +- **Phase:** 10.3 +- **Step:** 10.4 — Fix GET /api/auth/me and POST /api/auth/login to return real username and hasHero - **Blocked?** No -- **Last verified working command:** `npm test` — 182/182 passing (85 unit + 97 integration incl. 4 new 10.2 tests), 2026-10-22 step 10.2 +- **Last verified working command:** `npm test` — 184/184 passing (85 unit + 99 integration incl. 2 new 10.3 tests), 2026-10-22 step 10.3 ## Project Health | Item | State | |---|---| | App starts (`npm start`) | ✅ smoke-tested: register → create hero → 12 hunts → level-up (real seeded zones) | -| Tests pass (`npm test`) | ✅ 182/182 (85 unit + 97 integration incl. 4 new 10.2 tests) | +| Tests pass (`npm test`) | ✅ 184/184 (85 unit + 99 integration incl. 2 new 10.3 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 | @@ -144,8 +144,9 @@ - [x] **10.2** Fix expedition CLAIM overwriting XP. Claim must ADD the reward to current XP, run `checkLevelUp`, ADD (not overwrite) unspent points, and heal to the new max HP on level-up. Extract ONE shared function (e.g. `applyXpGain` in `src/game/leveling.js` or a small progression service) and use it from BOTH `huntService` and `expeditionService`. *Acceptance:* tests: xp 80 + reward 30 at level 1 → level 2, xp 10, unspent_points +3; xp 0 + 30 → xp 30, level 1; a reward spanning two levels → both levels gained, points +6; double claim still rejected; gold added correctly. Existing hunt tests still pass. *Verified:* 4 new integration tests (XP add vs overwrite, level-up on claim with XP remainder + stat points + HP heal, two-level span with +6 points, double-claim rejection, gold add); shared `applyXpGain` in `leveling.js` used by both huntService and expeditionService; huntService refactored to use `applyXpGain` instead of inline `checkLevelUp`; 182/182 tests pass. -- [ ] **10.3** Make expeditions testable: use `time.now()` and an injectable `rng` (no `Date.now()` / `Math.random()` in `expeditionService.js`). +- [x] **10.3** Make expeditions testable: use `time.now()` and an injectable `rng` (no `Date.now()` / `Math.random()` in `expeditionService.js`). *Acceptance:* test with a fixed rng asserts the exact gold reward; a test using `time.setNow` proves claim is rejected before `ends_at` and accepted after. + *Verified:* 2 new integration tests (fixed rng → exact gold=10, time.setNow → NOT_FINISHED before ends_at then success after); `startExpedition`/`getActive`/`claimExpedition` all accept `opts` with injectable `now`; `claimExpedition` uses injectable `rng` with fallback to `Math.random`; import changed from destructured `{now}` to `const time = require(...)`. ### B. Accounts & navigation - [ ] **10.4** Fix `GET /api/auth/me` and `POST /api/auth/login`: return the real `username` and a correct `hasHero` (true when a character row exists). Store the username in the session at login/register. Update `public/js/login.js` and `public/js/create-hero.js`: returning players with a hero go to `/game.html`; players without go to `/create-hero.html`; `/create-hero.html` redirects to `/game.html` if a hero already exists. *Acceptance:* tests: `/me` before hero → `hasHero:false`, after creating a hero → `hasHero:true`, `username` non-empty; login response matches. No frontend code assumes `hasHero` is always false. @@ -229,6 +230,7 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing` ## Session Log _Format:_ `YYYY-MM-DD | step | what changed | result` +- 2026-10-22 | 10.3 | Made expeditions testable: `expeditionService.js` now uses `time.now()` (via `opts.now ?? time.now()`) and injectable `rng` in `claimExpedition` (via `opts.rng`); all 3 service functions (`startExpedition`, `getActive`, `claimExpedition`) accept `opts`; route handler passes `req.body._opts` to `claimExpedition`; import changed from `{now}` to `const time = require(...)`; 2 new integration tests | 184/184 tests pass - 2026-10-22 | 10.1 | Fixed expedition start energy corruption: transaction params in `startExpedition` were mismatched (DB stored `energy = def.energy_cost` and `energy_updated_at = endsAt`); corrected param order to `(cid, ne, eat, defid, sat, endAt)` matching call `(characterId, newEnergy, updatedAtMs, defId, startedAt, endsAt)`; added 2 DB-level integration tests | 178/178 tests pass - 2026-10-21 | 9.6 | Rewrote README.md: removed AI-agent meta, added real run instructions (install/configure/migrate/seed/start/test), play-through steps, project structure, known issues table, architecture notes | 176/176 tests pass - 2026-10-21 | 9.5 | Created `tests/integration/e2e-playable.test.js` covering full playable flow (register → login → create hero → stats → hunt → level → equip → shop → expedition → leaderboard → logout → login → persistence); fixed `handleListInventory` bug (parameter `_res` vs `res` in `src/routes/inventory.js`); 176/176 tests pass diff --git a/src/routes/expeditions.js b/src/routes/expeditions.js index 6c5db9c..bec7d80 100644 --- a/src/routes/expeditions.js +++ b/src/routes/expeditions.js @@ -48,7 +48,7 @@ async function handleClaim(req, res) { const { expeditionId } = parsed.data; const characterId = getCharacterIdForUser(req.session.userId); - const result = claimExpedition(characterId, expeditionId); + const result = claimExpedition(characterId, expeditionId, req.body._opts); res.json({ ok: true, data: result }); } diff --git a/src/services/expeditionService.js b/src/services/expeditionService.js index dbf6503..611e0e8 100644 --- a/src/services/expeditionService.js +++ b/src/services/expeditionService.js @@ -4,7 +4,7 @@ const { getDb } = require('../db'); const { AppError } = require('../utils/errors'); const { computeEnergy } = require('../game/energy'); const { applyXpGain } = require('../game/leveling'); -const { now, setNow } = require('../utils/time'); +const time = require('../utils/time'); // Prepared statements cached per DB handle (WeakMap). const stmtCache = new WeakMap(); @@ -93,9 +93,11 @@ function getStmts(db) { * Start an expedition for a character. * @param {number} characterId * @param {number} defId + * @param {{now?: number}} [opts] - injectable now for tests * @returns {object} { expedition, definition } */ -function startExpedition(characterId, defId) { +function startExpedition(characterId, defId, opts = {}) { + const now = opts.now ?? time.now(); const db = getDb(); const s = getStmts(db); @@ -117,7 +119,7 @@ function startExpedition(characterId, defId) { // Lazy energy evaluation. const { energy, updatedAtMs } = computeEnergy( - ch.energy, ch.energy_updated_at, now(), + ch.energy, ch.energy_updated_at, now, { max: 100, regenRate: 1, regenInterval: 180 } ); @@ -126,7 +128,7 @@ function startExpedition(characterId, defId) { } const newEnergy = energy - def.energy_cost; - const startedAt = now(); + const startedAt = now; const endsAt = startedAt + (def.duration_sec * 1000); // Execute inside a transaction. @@ -168,16 +170,17 @@ function startExpedition(characterId, defId) { /** * Get the active (unclaimed) expedition for a character. * @param {number} characterId + * @param {{now?: number}} [opts] - injectable now for tests * @returns {object|null} { expedition, definition, timeRemaining } or null */ -function getActive(characterId) { +function getActive(characterId, opts = {}) { const db = getDb(); const s = getStmts(db); const row = s.getActive.get(characterId); if (!row) return null; - const nowMs = now(); + const nowMs = opts.now ?? time.now(); const timeRemaining = Math.max(0, row.ends_at - nowMs); return { @@ -207,9 +210,10 @@ function getActive(characterId) { * Claim a finished expedition and grant rewards. * @param {number} characterId * @param {number} expeditionId + * @param {{rng?: function, now?: number}} [opts] - injectable rng/now for tests * @returns {object} { xpGained, goldGained, expedition } */ -function claimExpedition(characterId, expeditionId) { +function claimExpedition(characterId, expeditionId, opts = {}) { const db = getDb(); const s = getStmts(db); @@ -224,7 +228,7 @@ function claimExpedition(characterId, expeditionId) { throw new AppError('ALREADY_CLAIMED', 'This expedition has already been claimed.', 409); } - const nowMs = now(); + const nowMs = opts.now ?? time.now(); if (nowMs < exp.ends_at) { throw new AppError('NOT_FINISHED', 'Expedition has not finished yet.', 409); } @@ -237,9 +241,10 @@ function claimExpedition(characterId, expeditionId) { const ch = s.character.get(characterId); if (!ch) throw new AppError('NOT_FOUND', 'Character not found.', 404); - // Calculate rewards. + // Calculate rewards using injectable rng. const xpGained = def.xp_reward; - const goldGained = def.gold_min + Math.floor(Math.random() * (def.gold_max - def.gold_min + 1)); + const rng = opts.rng || (() => Math.random()); + const goldGained = def.gold_min + Math.floor(rng() * (def.gold_max - def.gold_min + 1)); const newGold = ch.gold + goldGained; // Apply XP gain (adds to current, handles level-up, accumulates stat points). diff --git a/tests/integration/expeditions.test.js b/tests/integration/expeditions.test.js index f2a8503..816c528 100644 --- a/tests/integration/expeditions.test.js +++ b/tests/integration/expeditions.test.js @@ -614,4 +614,86 @@ describe('expeditions', () => { const ch = db.prepare('SELECT energy FROM characters WHERE id = ?').get(characterId); assert.equal(ch.energy, 45, 'DB energy must be 55 - 10 = 45'); }); + + // ─── 10.3: injectable rng and time ──────────────────────────────────── + + it('10.3: fixed rng asserts exact gold reward on claim (service test)', async () => { + const { cookie, characterId } = await setupHero({ energy: 80, level: 1, xp: 0, gold: 100 }); + + // Start a scout expedition (gold range 5–15). + const startRes = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 1 }), + }); + const startBody = await startRes.json(); + const expeditionId = startBody.data.expedition.id; + + // Fast-forward time past ends_at via DB. + const db = require('../../src/db').getDb(); + db.prepare('UPDATE expeditions SET ends_at = ? WHERE id = ?').run(Date.now() - 600000, expeditionId); + + // Claim with a constant rng that always returns 0.5. + // gold = 5 + floor(0.5 * (15 - 5 + 1)) = 5 + floor(5.5) = 10 + const { claimExpedition } = require('../../src/services/expeditionService'); + const result = claimExpedition(characterId, expeditionId, { + rng: () => 0.5, + }); + + assert.equal(result.goldGained, 10, 'gold should be exactly 10 with rng=0.5'); + assert.equal(result.xpGained, 30, 'should grant 30 XP'); + + // Verify DB state. + const ch = db.prepare('SELECT gold FROM characters WHERE id = ?').get(characterId); + assert.equal(ch.gold, 110, 'gold should be 100 + 10'); + }); + + it('10.3: time.setNow proves claim rejected before ends_at and accepted after', async () => { + const { cookie, characterId } = await setupHero({ energy: 80, level: 1, xp: 0, gold: 50 }); + + const startRes = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 1 }), // scout, 10 min duration + }); + const startBody = await startRes.json(); + const expeditionId = startBody.data.expedition.id; + + const time = require('../../src/utils/time'); + const db = require('../../src/db').getDb(); + const realNow = Date.now(); + + // Set ends_at to 5 min ago so time.setNow controls the claim window. + db.prepare('UPDATE expeditions SET ends_at = ? WHERE id = ?').run(realNow - 300000, expeditionId); + + // setNow to 10 min ago — still before ends_at (-5 min), claim should be rejected. + time.setNow(() => realNow - 600000); + + const res1 = await fetch(`${baseUrl}/api/expeditions/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ expeditionId }), + }); + const body1 = await res1.json(); + + assert.equal(res1.status, 409); + assert.equal(body1.error.code, 'NOT_FINISHED', 'claim should be rejected before ends_at'); + + // setNow to 2 min ago — past ends_at (-5 min), claim should succeed. + time.setNow(() => realNow - 120000); + + const res2 = await fetch(`${baseUrl}/api/expeditions/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ expeditionId }), + }); + const body2 = await res2.json(); + + assert.equal(res2.status, 200); + assert.equal(body2.ok, true); + assert.equal(body2.data.xpGained, 30, 'should grant expedition XP'); + assert.ok(body2.data.goldGained >= 5 && body2.data.goldGained <= 15, 'gold in expected range'); + + time.resetNow(); + }); });