step 10.16: done

This commit is contained in:
bepis
2026-10-23 06:29:17 +11:00
parent 81794c7dbd
commit a475eaf8eb
5 changed files with 179 additions and 21 deletions
+12 -9
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:** 10.15 — Non-stackable items: one inventory row per unit (shared `grantItem` in `src/game/inventory.js`) - **Last completed step:** 10.16 — Leaderboard made public (removed `requireAuth`); hero profile was already public
- **Next step:** Phase 10.16 — Make `GET /api/leaderboard` and `GET /api/heroes/:name` public (no login) - **Next step:** Phase 10.17 — Build `public/leaderboard.html` + `public/js/leaderboard.js`: tabs Level/Gold/Kills, rank, hero name (link to profile.html?name=...), class, value; `textContent` only. Add nav links (Game, Shop, Leaderboard, Logout) to header of every logged-in page. Remove temporary link-checker exception from 10.5.
- **Updated:** 2026-10-23 - **Updated:** 2026-10-23
- **Was the last session cut off mid-task?** No - **Was the last session cut off mid-task?** No
@@ -14,15 +14,15 @@
|---|---| |---|---|
| `npm install` | succeeds | | `npm install` | succeeds |
| `npm run migrate` | applies 001–007 / "nothing to do" | | `npm run migrate` | applies 001–007 / "nothing to do" |
| `npm test` | **264 passing, 0 failing** (97 unit + 167 integration incl. 4 non-stackable buy tests) | | `npm test` | **273 passing, 0 failing** (97 unit + 176 integration incl. 9 public leaderboard/profile tests) |
| `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear | | `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear |
| `npm start` + `curl localhost:3000/` | 200 HTML landing page | | `npm start` + `curl localhost:3000/` | 200 HTML landing page |
| `curl localhost:3000/api/leaderboard?type=level` | 200 JSON (no auth required) |
## 3. What exists now (files that matter) ## 3. What exists now (files that matter)
- `src/game/inventory.js` — NEW: shared `grantItem(s, characterId, item, quantity, now)` — stackable merges, non-stackable creates one row per unit - `src/app.js` — leaderboard route no longer has `requireAuth` (public)
- `src/services/huntService.js` — now imports `grantItem` from `src/game/inventory.js` (removed local copy) - `tests/integration/step-10.16.test.js` — NEW: 9 tests for public leaderboard + hero profile
- `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/leaderboard.test.js` — updated "rejects request without authentication" → "is accessible without authentication"
- `tests/integration/step-10.15.test.js` — NEW: 4 integration tests for non-stackable buy behavior
## 4. Half-finished / fragile things ## 4. Half-finished / fragile things
- No leaderboard UI yet (stub page exists; real UI in 10.17). - No leaderboard UI yet (stub page exists; real UI in 10.17).
@@ -33,7 +33,7 @@
- Characters start with 50g default (DB constraint) — tests must account for this baseline. - Characters start with 50g default (DB constraint) — tests must account for this baseline.
## 5. Decisions & deviations from the docs ## 5. Decisions & deviations from the docs
- D-004..D-007, D-022 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock, material sell prices) - D-004..D-007, D-014..D-021 in `docs/DECISIONS.md`
- **D-014:** Mobile bottom nav uses emoji icons (⚔️ Hunt / 🏕️ Trip / 🎒 Items) instead of full text labels. - **D-014:** Mobile bottom nav uses emoji icons (⚔️ Hunt / 🏕️ Trip / 🎒 Items) instead of full text labels.
- **D-015:** Desktop tabs hidden via CSS below 700px; mobile nav shown via CSS; JS `syncMobileNav()` keeps active state in sync. - **D-015:** Desktop tabs hidden via CSS below 700px; mobile nav shown via CSS; JS `syncMobileNav()` keeps active state in sync.
- **D-016:** `applyXpGain` in `src/game/leveling.js` is the single shared function for XP gain logic, used by both `huntService` and `expeditionService`. - **D-016:** `applyXpGain` in `src/game/leveling.js` is the single shared function for XP gain logic, used by both `huntService` and `expeditionService`.
@@ -87,6 +87,9 @@
- **10.12:** Warrior maxHp at level 1 = 135 (50 + 8*10 + 1*5). - **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.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. - **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.
- **10.16:** Leaderboard returns `{ ok: true, data: { entries: [...], pagination: {...} } }`.
## 7. Next-step briefing ## 7. Next-step briefing
- **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. - **Next step: 10.17** — Build `public/leaderboard.html` + `public/js/leaderboard.js`: tabs Level/Gold/Kills, rank, hero name (linking to `profile.html?name=...`), class, value; `textContent` only. Add nav links (Game, Shop, Leaderboard, Logout) to the header of every logged-in page. Remove any temporary link-checker exception from 10.5.
- The leaderboard API (`GET /api/leaderboard?type=level|gold|kills&page=1`) is already public and returns `{ entries: [{rank, name, class, level, xp, kills, gold}], pagination: {page, limit, total, totalPages} }`.
- The link-checker test currently has no exceptions (10.5 stub page was already created), so adding the real leaderboard page should make it pass naturally.
+9 -7
View File
@@ -8,15 +8,15 @@
## Current Focus ## Current Focus
- **Phase:** 10 - **Phase:** 10
- **Step:** 10.16 — (next) - **Step:** 10.17 — (next)
- **Blocked?** No - **Blocked?** No
- **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 - **Last verified working command:** `npm test` — 273/273 passing (97 unit + 176 integration incl. 9 public leaderboard/profile tests), 2026-10-23 step 10.16
## Project Health ## Project Health
| Item | State | | Item | State |
|---|---| |---|---|
| App starts (`npm start`) | ✅ smoke-tested: register → create hero → 12 hunts → level-up (real seeded zones) | | App starts (`npm start`) | ✅ smoke-tested: register → create hero → 12 hunts → level-up (real seeded zones) |
| Tests pass (`npm test`) | ✅ 260/260 (97 unit + 163 integration incl. 7 material sell price tests) | | Tests pass (`npm test`) | ✅ 273/273 (97 unit + 176 integration incl. 9 public leaderboard/profile 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) | | 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 | | 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 | | Data persists across restart | ⚠️ sessions use the real SQLite store (also in tests); a restart test is still missing |
@@ -188,7 +188,9 @@
*Acceptance:* tests: buy 3 swords → 3 rows, quantity 1 each; equip one, sell one → remaining state correct; buy 5 potions → one row, quantity 5. *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. *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 ### 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. - [x] **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:* anonymous request → 200; response contains no user id/username/password; SQL injection in `type` → 400; `page=-1` and `page=abc` → 400.
*Verified:* `requireAuth` removed from leaderboard route in `src/app.js`; hero profile was already public; 9 new integration tests (3 leaderboard anonymous access, 1 no-private-fields, 3 validation: SQL injection/page=-1/page=abc, 2 hero profile anonymous access); updated existing leaderboard test "rejects request without authentication" → "is accessible without authentication" (expects 200); 273/273 tests pass.
*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. *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.
- [ ] **10.17** Build `public/leaderboard.html` + `public/js/leaderboard.js`: tabs Level / Gold / Kills, rank, hero name (linking to `profile.html?name=...`), class, value; `textContent` only. Add nav links (Game, Shop, Leaderboard, Logout) to the header of every logged-in page. Remove any temporary link-checker exception from 10.5. - [ ] **10.17** Build `public/leaderboard.html` + `public/js/leaderboard.js`: tabs Level / Gold / Kills, rank, hero name (linking to `profile.html?name=...`), class, value; `textContent` only. Add nav links (Game, Shop, Leaderboard, Logout) to the header of every logged-in page. Remove any temporary link-checker exception from 10.5.
*Acceptance:* link-checker test passes with no exceptions; the leaderboard page works logged-out and logged-in. *Acceptance:* link-checker test passes with no exceptions; the leaderboard page works logged-out and logged-in.
@@ -237,12 +239,12 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing`
## Test Summary ## Test Summary
| Suite | Passing | Failing | Last run | | Suite | Passing | Failing | Last run |
|---|---|---|---| |---|---|---|---|
| unit | 97 | 0 | 2026-10-23 step 10.15 | | unit | 97 | 0 | 2026-10-23 step 10.16 |
| integration | 167 | 0 | 2026-10-23 step 10.15 (4 non-stackable buy tests) | | integration | 176 | 0 | 2026-10-23 step 10.16 (9 public leaderboard/profile tests) |
## Session Log ## Session Log
_Format:_ `YYYY-MM-DD | step | what changed | result` _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.16 | Removed `requireAuth` from `GET /api/leaderboard` in `src/app.js` (now public); hero profile was already public; updated existing "rejects request without authentication" → "is accessible without authentication" in leaderboard.test.js; 9 new integration tests (anonymous access, no-private-fields, validation: SQL injection/page=-1/page=abc, hero profile anonymous) | 273/273 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.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-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 - 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
+2 -2
View File
@@ -94,8 +94,8 @@ function createApp() {
app.get('/api/expeditions/active', requireAuth, expeditionRoutes.getActive); app.get('/api/expeditions/active', requireAuth, expeditionRoutes.getActive);
app.post('/api/expeditions/claim', requireAuth, expeditionRoutes.postClaim); app.post('/api/expeditions/claim', requireAuth, expeditionRoutes.postClaim);
// Leaderboard route (require session) // Leaderboard route (public, no auth required)
app.get('/api/leaderboard', requireAuth, leaderboardRoutes.getLeaderboard); app.get('/api/leaderboard', leaderboardRoutes.getLeaderboard);
// Hero profile route (public, no session required) // Hero profile route (public, no session required)
app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile); app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile);
+4 -3
View File
@@ -210,12 +210,13 @@ describe('leaderboard', () => {
assert.equal(body.error.code, 'VALIDATION_ERROR'); assert.equal(body.error.code, 'VALIDATION_ERROR');
}); });
it('rejects request without authentication', async () => { it('is accessible without authentication', async () => {
const res = await fetch(`${baseUrl}/api/leaderboard?type=level`); const res = await fetch(`${baseUrl}/api/leaderboard?type=level`);
const body = await res.json(); const body = await res.json();
assert.equal(res.status, 401); assert.equal(res.status, 200);
assert.equal(body.ok, false); assert.equal(body.ok, true);
assert.ok(Array.isArray(body.data.entries));
}); });
// ─── Entry shape ────────────────────────────────────────────────────── // ─── Entry shape ──────────────────────────────────────────────────────
+152
View File
@@ -0,0 +1,152 @@
// Step 10.16: Leaderboard public + hero profile public — no login required.
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
describe('step 10.16 — leaderboard & hero profile public', () => {
let baseUrl;
before(async () => {
baseUrl = await setup();
});
after(async () => {
await teardown();
cleanup();
});
// ─── Leaderboard: anonymous access ────────────────────────────────────
it('anonymous GET /api/leaderboard?type=level returns 200', async () => {
const res = await fetch(`${baseUrl}/api/leaderboard?type=level`);
assert.equal(res.status, 200);
const json = await res.json();
assert.equal(json.ok, true);
assert.ok(Array.isArray(json.data.entries));
assert.ok(json.data.pagination);
});
it('anonymous GET /api/leaderboard?type=gold returns 200', async () => {
const res = await fetch(`${baseUrl}/api/leaderboard?type=gold`);
assert.equal(res.status, 200);
const json = await res.json();
assert.equal(json.ok, true);
});
it('anonymous GET /api/leaderboard?type=kills returns 200', async () => {
const res = await fetch(`${baseUrl}/api/leaderboard?type=kills`);
assert.equal(res.status, 200);
const json = await res.json();
assert.equal(json.ok, true);
});
it('leaderboard response contains no user id / username / password', async () => {
const res = await fetch(`${baseUrl}/api/leaderboard?type=level`);
assert.equal(res.status, 200);
const json = await res.json();
for (const entry of json.data.entries) {
assert.ok(!('id' in entry), `entry should not have 'id': ${entry.name}`);
assert.ok(!('username' in entry), `entry should not have 'username': ${entry.name}`);
assert.ok(!('password' in entry), `entry should not have 'password': ${entry.name}`);
assert.ok(!('password_hash' in entry), `entry should not have 'password_hash': ${entry.name}`);
assert.ok(!('user_id' in entry), `entry should not have 'user_id': ${entry.name}`);
}
});
// ─── Leaderboard: validation ──────────────────────────────────────────
it('rejects SQL injection in type with 400', async () => {
const res = await fetch(
`${baseUrl}/api/leaderboard?type=level%20OR%201=1`,
);
const body = await res.json();
assert.equal(res.status, 400);
assert.equal(body.ok, false);
assert.equal(body.error.code, 'VALIDATION_ERROR');
});
it('rejects page=-1 with 400', async () => {
const res = await fetch(`${baseUrl}/api/leaderboard?type=level&page=-1`);
const body = await res.json();
assert.equal(res.status, 400);
assert.equal(body.ok, false);
assert.equal(body.error.code, 'VALIDATION_ERROR');
});
it('rejects page=abc with 400', async () => {
const res = await fetch(`${baseUrl}/api/leaderboard?type=level&page=abc`);
const body = await res.json();
assert.equal(res.status, 400);
assert.equal(body.ok, false);
assert.equal(body.error.code, 'VALIDATION_ERROR');
});
// ─── Hero profile: anonymous access (already public, verify) ──────────
it('anonymous GET /api/heroes/:name returns 200', async () => {
// Create a hero first (fresh DB has no heroes).
const regRes = await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'pubuser1', password: 'password123' }),
});
assert.equal(regRes.status, 201);
const loginRes = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'pubuser1', password: 'password123' }),
});
assert.equal(loginRes.status, 200);
const cookie = loginRes.headers.get('set-cookie').split(';')[0];
const charRes = await fetch(`${baseUrl}/api/character`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ name: 'PublicHero16', class: 'warrior' }),
});
assert.equal(charRes.status, 201);
// Now access the profile anonymously.
const res = await fetch(`${baseUrl}/api/heroes/PublicHero16`);
assert.equal(res.status, 200);
const json = await res.json();
assert.equal(json.ok, true);
assert.equal(json.data.name, 'PublicHero16');
});
it('hero profile response contains no password / user_id', async () => {
// Create a hero first.
const regRes = await fetch(`${baseUrl}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'pubuser2', password: 'password123' }),
});
assert.equal(regRes.status, 201);
const loginRes = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'pubuser2', password: 'password123' }),
});
assert.equal(loginRes.status, 200);
const cookie = loginRes.headers.get('set-cookie').split(';')[0];
const charRes = await fetch(`${baseUrl}/api/character`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ name: 'PublicHero17', class: 'mage' }),
});
assert.equal(charRes.status, 201);
// Access the profile anonymously.
const res = await fetch(`${baseUrl}/api/heroes/PublicHero17`);
assert.equal(res.status, 200);
const json = await res.json();
const data = json.data;
assert.ok(!('password' in data), 'should not have password');
assert.ok(!('password_hash' in data), 'should not have password_hash');
assert.ok(!('user_id' in data), 'should not have user_id');
});
});