step 10.3: done
This commit is contained in:
+19
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user