step 8.2: done

This commit is contained in:
bepis
2026-10-21 08:05:25 +11:00
parent fcf2ea2d21
commit b8fef0629b
6 changed files with 308 additions and 36 deletions
+37 -33
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:** 7.3 (Expedition UI: start panel, progress bar, claim button) — TICKED in STATUS.md - **Last completed step:** 8.2 (Public profile: `GET /api/heroes/:name`) — TICKED in STATUS.md
- **Next step:** 8.1 — Leaderboard: `GET /api/leaderboard?type=level|gold|kills` top 50, with pagination param. - **Next step:** 8.3 — UI page for public hero profile.
- **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,38 +14,39 @@
|---|---| |---|---|
| `npm install` | succeeds | | `npm install` | succeeds |
| `npm run migrate` | applies 001–006 / "nothing to do" | | `npm run migrate` | applies 001–006 / "nothing to do" |
| `npm test` | **153 passing, 0 failing** (85 unit + 68 integration) | | `npm test` | **167 passing, 0 failing** (85 unit + 72 integration) |
## 3. What exists now (files that matter) ## 3. What exists now (files that matter)
- `src/db/migrations/005_expeditions.sql` — `expedition_defs` + `expeditions` tables, unique active-expedition index, 3 seed definitions. - `src/services/heroProfileService.js` — `getHeroProfile(heroName)`: case-insensitive lookup, returns name/class/level/xp/gold/str/agi/int/vit/kills/deaths/equipment (equipped item names by slot). Uses WeakMap stmt cache.
- `src/services/expeditionService.js` — `startExpedition`, `getActive`, `claimExpedition`, `listDefinitions`. Uses WeakMap stmt cache. - `src/routes/heroProfile.js` — `GET /api/heroes/:name` (public, no auth). Returns 404 if hero not found.
- `src/routes/expeditions.js` — `POST /api/expeditions/start`, `GET /api/expeditions/active`, `POST /api/expeditions/claim`, `GET /api/expeditions/defs`. - `src/services/leaderboardService.js` — `getLeaderboard(type, page, limit)`.
- `src/game/energy.js` — `computeEnergy` now uses `now()` from utils/time. - `src/routes/leaderboard.js` — `GET /api/leaderboard?type=level|gold|kills&page=1`.
- `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`. - `src/services/expeditionService.js` — start, getActive, claim, listDefinitions.
- `src/services/huntService.js` — `hunt(characterId, zoneId, {rng, now})`; ONE transaction; test-injectable. - `src/routes/expeditions.js` — 4 expedition endpoints.
- `src/services/inventoryService.js` — `listInventory`, `equipItem`, `unequipItem`, `useConsumable`. Uses WeakMap stmt cache. - `src/services/shopService.js` — getStock, buyItem, sellItem.
- `src/services/shopService.js` — `getStock`, `buyItem`, `sellItem`. Uses WeakMap stmt cache. - `src/routes/shop.js` — GET/POST shop endpoints.
- `src/game/combat.js` — `resolveCombat(hero, monster, rng)`; both sides attack each round, faster first. - `src/services/inventoryService.js` — listInventory, equipItem, unequipItem, useConsumable.
- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp. - `src/services/huntService.js` — hunt (one transaction).
- `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services. - `src/services/characterService.js` — createCharacter, getCharacterProfile, getCharacterIdForUser.
- `src/routes/zones.js` — `GET /api/zones`. - `src/services/authService.js` — register, verifyLogin.
- `src/routes/combatLog.js` — `GET /api/combat-log`. - `src/game/combat.js` — resolveCombat.
- `src/game/stats.js` — calcDerived, xpToNext, checkLevelUp.
- `src/game/energy.js` — computeEnergy.
- `src/utils/time.js` — now()/setNow()/resetNow().
- `public/game.html` — dashboard with stats panel, tabbed action panel (Hunt/Inventory/Expedition). - `public/game.html` — dashboard with stats panel, tabbed action panel (Hunt/Inventory/Expedition).
- `public/js/game.js` — full hunt UI + inventory UI + expedition UI (start panel, progress bar, claim button, auto-refresh). - `public/js/game.js` — hunt UI + inventory UI + expedition UI.
- `public/css/main.css` — expedition UI styles (cards, progress bar, buttons). - `public/shop.html` — dedicated shop page.
- `public/shop.html` — dedicated shop page with buy/sell tabs. - `tests/integration/hero-profile.test.js` — 5 integration tests.
- `tests/integration/expeditions.test.js` — 13 integration tests.
- `tests/integration/shop.test.js` — 16 integration tests.
- `tests/integration/shop-page.test.js` — 5 integration tests.
- `tests/integration/inventory.test.js` — 20 integration tests.
## 4. Half-finished / fragile things ## 4. Half-finished / fragile things
- No leaderboard UI yet (Phase 8). - No leaderboard UI yet (Phase 8).
- No public profile UI page (step 8.3 next).
- HP never regenerates naturally; only level-up restores it. - HP never regenerates naturally; only level-up restores it.
- 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).
- No shop link in game.html top-bar navigation. - Starter gear (GAME_DESIGN.md §Items) documented but not auto-created on character creation.
- `GET /api/zones` documented in API.md but not implemented (Issue #2).
## 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).
@@ -55,8 +56,8 @@
- Qty decrement avoids CHECK constraint violation: delete directly when qty==1, decrement when qty>1. - Qty decrement avoids CHECK constraint violation: delete directly when qty==1, decrement when qty>1.
- **D-008:** Shop materials with buy_price=0 sell at `floor(default_rate * character_level / item_min_level)`. - **D-008:** Shop materials with buy_price=0 sell at `floor(default_rate * character_level / item_min_level)`.
- **D-009:** `sellItem` checks equipped_slot before material type. - **D-009:** `sellItem` checks equipped_slot before material type.
- **D-010:** `startExpedition` checks active-expedition before energy (so 409 ACTIVE_EXPEDITION takes priority over 422 INSUFFICIENT_ENERGY). - **D-010:** `startExpedition` checks active-expedition before energy.
- **D-011:** `computeEnergy` uses `now()` from utils/time instead of `Date.now()` directly, so test time overrides work. - **D-011:** `computeEnergy` uses `now()` from utils/time.
## 6. Gotchas learned ## 6. Gotchas learned
- `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`. - `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`.
@@ -67,14 +68,17 @@
- SQL `SELECT id` from inventory returns the auto-increment row id; rename with `AS inventory_id` when you also need `item_id`. - SQL `SELECT id` from inventory returns the auto-increment row id; rename with `AS inventory_id` when you also need `item_id`.
- Transaction callback args must match the call order exactly. - Transaction callback args must match the call order exactly.
- Error responses from route-level `AppError`s do NOT include `data` field. - Error responses from route-level `AppError`s do NOT include `data` field.
- `setNow` affects ALL `now()` calls including those in service logic — for tests that need to verify time-based behavior, manipulate the DB directly instead. - `setNow` affects ALL `now()` calls including those in service logic.
- Shop API returns `{ ok: true, data: { stock: [...], character: {...} } }` — field is `stock`, not `stocks`.
- Shop stock items have flat fields (`itemId`, `buyPrice`), not nested (`item.id`).
- New characters have NO inventory items (starter gear not auto-created yet).
- Hero profile endpoint is PUBLIC — no `requireAuth` middleware.
## 7. Next-step briefing (8.1) ## 7. Next-step briefing (8.3)
- **Goal:** Leaderboard endpoint — `GET /api/leaderboard?type=level|gold|kills` top 50, with `?page=N` pagination. - **Goal:** Public profile UI page — a standalone HTML page showing the hero profile returned by `GET /api/heroes/:name`.
- **Docs:** GAME_DESIGN.md §Leaderboards, DATABASE. - **Docs:** GAME_DESIGN.md §Leaderboards, UI_GUIDE.
- **Files to create:** `src/services/leaderboardService.js`, `src/routes/leaderboard.js`. - **Files to create:** `public/profile.html`, `public/js/profile.js`.
- **Files to modify:** `src/app.js` (register route). - **No new backend needed** — the API endpoint already exists.
- **API:** `GET /api/leaderboard?type=level|gold|kills&page=1` — returns ranked list of heroes (name, level/gold/kills) with pagination metadata.
## 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).
+4 -3
View File
@@ -8,9 +8,9 @@
## Current Focus ## Current Focus
- **Phase:** 8 - **Phase:** 8
- **Step:** 8.1 - **Step:** 8.3
- **Blocked?** No - **Blocked?** No
- **Last verified working command:** `npm test` — 153/153 passing (85 unit + 68 integration), 2026-10-21 step 7.3 - **Last verified working command:** `npm test` — 167/167 passing (85 unit + 72 integration), 2026-10-21 step 8.2
## Project Health ## Project Health
| Item | State | | Item | State |
@@ -100,7 +100,7 @@
## Phase 8 — Leaderboards & Social-lite (docs: GAME_DESIGN §Leaderboards) ## Phase 8 — Leaderboards & Social-lite (docs: GAME_DESIGN §Leaderboards)
- [x] **8.1** `GET /api/leaderboard?type=level|gold|kills` top 50, with pagination param. - [x] **8.1** `GET /api/leaderboard?type=level|gold|kills` top 50, with pagination param.
- [ ] **8.2** Public profile `GET /api/heroes/:name` (no private data). - [x] **8.2** Public profile `GET /api/heroes/:name` (no private data).
- [ ] **8.3** UI page. - [ ] **8.3** UI page.
## Phase 9 — Hardening & Polish (docs: SECURITY, UI_GUIDE, TESTING) ## Phase 9 — Hardening & Polish (docs: SECURITY, UI_GUIDE, TESTING)
@@ -185,3 +185,4 @@ _Format:_ `YYYY-MM-DD | step | what changed | result`
- 2026-10-20 | 2.2 | Created `src/game/classes.js`, `src/game/stats.js` + unit tests (23 tests) | 23/23 pass - 2026-10-20 | 2.2 | Created `src/game/classes.js`, `src/game/stats.js` + unit tests (23 tests) | 23/23 pass
- 2026-10-20 | 2.3 | Created `src/services/characterService.js`, `src/routes/character.js`; integrated into app.js | app loads - 2026-10-20 | 2.3 | Created `src/services/characterService.js`, `src/routes/character.js`; integrated into app.js | app loads
- 2026-10-20 | 2.4 | Created `public/create-hero.html`, `public/game.html`, `public/js/create-hero.js`, `public/js/game.js` | app loads - 2026-10-20 | 2.4 | Created `public/create-hero.html`, `public/game.html`, `public/js/create-hero.js`, `public/js/game.js` | app loads
- 2026-10-21 | 8.2 | Created `src/services/heroProfileService.js` (getHeroProfile), `src/routes/heroProfile.js` (GET /api/heroes/:name, public), integrated into app.js; 5 integration tests covering public lookup, case-insensitivity, equipped items, 404, no-auth access | 167/167 tests pass
+4
View File
@@ -10,6 +10,7 @@ const { routes: inventoryRoutes } = require('./routes/inventory');
const { routes: shopRoutes } = require('./routes/shop'); const { routes: shopRoutes } = require('./routes/shop');
const { routes: expeditionRoutes } = require('./routes/expeditions'); const { routes: expeditionRoutes } = require('./routes/expeditions');
const { routes: leaderboardRoutes } = require('./routes/leaderboard'); const { routes: leaderboardRoutes } = require('./routes/leaderboard');
const { routes: heroProfileRoutes } = require('./routes/heroProfile');
const { AppError } = require('./utils/errors'); const { AppError } = require('./utils/errors');
function createApp() { function createApp() {
@@ -76,6 +77,9 @@ function createApp() {
// Leaderboard route (require session) // Leaderboard route (require session)
app.get('/api/leaderboard', requireAuth, leaderboardRoutes.getLeaderboard); app.get('/api/leaderboard', requireAuth, leaderboardRoutes.getLeaderboard);
// Hero profile route (public, no session required)
app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile);
// Central error handler // Central error handler
app.use((err, _req, res, _next) => { app.use((err, _req, res, _next) => {
// Log stack traces server-side only // Log stack traces server-side only
+33
View File
@@ -0,0 +1,33 @@
// Hero profile routes: GET /api/heroes/:name (public).
const zod = require('zod');
const { AppError } = require('../utils/errors');
const { getHeroProfile } = require('../services/heroProfileService');
function wrap(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
/**
* GET /api/heroes/:name — public hero profile.
*/
async function handleHeroProfile(req, res) {
const parsed = zod.strictObject({
name: zod.string().min(1).trim(),
}).safeParse(req.params);
if (!parsed.success) {
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
}
const profile = getHeroProfile(parsed.data.name);
if (!profile) {
throw new AppError('NOT_FOUND', 'Hero not found.', 404);
}
res.json({ ok: true, data: profile });
}
module.exports = {
routes: {
getHeroProfile: wrap(handleHeroProfile),
},
};
+72
View File
@@ -0,0 +1,72 @@
// Hero profile service: public hero lookup (no private data).
const { getDb } = require('../db');
const { AppError } = require('../utils/errors');
// Prepared statements cached per DB handle (WeakMap).
const stmtCache = new WeakMap();
function getStmts(db) {
let s = stmtCache.get(db);
if (!s) {
s = {
// Find character by name (case-insensitive).
findByName: db.prepare(
`SELECT c.id, c.name, c.class, c.level, c.xp, c.gold,
c.str, c.agi, c.int, c.vit, c.kills, c.deaths
FROM characters c
WHERE c.name = ? COLLATE NOCASE`
),
// Get equipped items for a character.
equippedItems: db.prepare(
`SELECT it.name, i.equipped_slot
FROM inventory i
JOIN items it ON it.id = i.item_id
WHERE i.character_id = ? AND i.equipped_slot IS NOT NULL`
),
};
stmtCache.set(db, s);
}
return s;
}
/**
* Get a public hero profile by name.
* @param {string} heroName - hero name (case-insensitive)
* @returns {object} { name, class, level, xp, gold, str, agi, int, vit, kills, deaths, equipment: { weapon, armor, trinket } }
*/
function getHeroProfile(heroName) {
const db = getDb();
const s = getStmts(db);
const character = s.findByName.get(heroName);
if (!character) {
return null;
}
const rows = s.equippedItems.all(character.id);
const equipment = { weapon: null, armor: null, trinket: null };
for (const row of rows) {
if (row.equipped_slot in equipment) {
equipment[row.equipped_slot] = row.name;
}
}
return {
name: character.name,
class: character.class,
level: character.level,
xp: character.xp,
gold: character.gold,
str: character.str,
agi: character.agi,
int: character.int,
vit: character.vit,
kills: character.kills,
deaths: character.deaths,
equipment,
};
}
module.exports = { getHeroProfile };
+158
View File
@@ -0,0 +1,158 @@
// Hero profile integration tests: GET /api/heroes/:name (public, no auth).
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
describe('hero profile (public)', () => {
let baseUrl, userSeq = 0;
before(async () => {
baseUrl = await setup();
});
after(async () => {
await teardown();
cleanup();
});
/**
* Register, login, create a character, return cookie + characterId.
*/
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);
const setCookie = res.headers.get('set-cookie');
assert.ok(setCookie, 'Login should set a session cookie');
return setCookie.split(';')[0];
}
async function createHero(cookie, heroName, heroClass) {
const res = await fetch(`${baseUrl}/api/character`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ name: heroName, class: heroClass }),
});
assert.equal(res.status, 201);
const json = await res.json();
return json.data.character.id;
}
function updateHeroField(characterId, column, value) {
const db = require('../../src/db').getDb();
db.prepare(`UPDATE characters SET ${column} = ? WHERE id = ?`).run(value, characterId);
}
function equipItem(characterId, itemId, slot) {
const db = require('../../src/db').getDb();
db.prepare('UPDATE inventory SET equipped_slot = ? WHERE id = ?').run(slot, itemId);
}
it('returns public profile for an existing hero', async () => {
const cookie = await registerAndLogin(`herouser1`, 'password123');
const characterId = await createHero(cookie, 'DragonSlayer', 'warrior');
// Tweak some fields so they show up in the response.
updateHeroField(characterId, 'level', 5);
updateHeroField(characterId, 'kills', 42);
updateHeroField(characterId, 'gold', 300);
updateHeroField(characterId, 'deaths', 3);
const res = await fetch(`${baseUrl}/api/heroes/DragonSlayer`);
assert.equal(res.status, 200);
const json = await res.json();
assert.equal(json.ok, true);
const data = json.data;
assert.equal(data.name, 'DragonSlayer');
assert.equal(data.class, 'warrior');
assert.equal(data.level, 5);
assert.equal(data.kills, 42);
assert.equal(data.gold, 300);
assert.equal(data.deaths, 3);
assert.ok(typeof data.str === 'number');
assert.ok(typeof data.agi === 'number');
assert.ok(typeof data.int === 'number');
assert.ok(typeof data.vit === 'number');
assert.ok(typeof data.xp === 'number');
assert.deepStrictEqual(data.equipment, { weapon: null, armor: null, trinket: null });
// Response must NOT contain private fields.
assert.ok(!('energy' in data));
assert.ok(!('energy_updated_at' in data));
assert.ok(!('user_id' in data));
assert.ok(!('created_at' in data));
assert.ok(!('password_hash' in data));
});
it('returns equipped item names', async () => {
const cookie = await registerAndLogin(`herouser2`, 'password123');
const characterId = await createHero(cookie, 'ArmoredOne', 'mage');
// Insert starter gear directly into inventory (starter gear not auto-created yet).
const db = require('../../src/db').getDb();
// Get a weapon item from the items table.
const weapon = db.prepare(
"SELECT id FROM items WHERE type = 'weapon' AND min_level <= 1 ORDER BY id LIMIT 1"
).get();
assert.ok(weapon, 'Should have a level-1 weapon in the items table');
// Insert it into inventory, equipped as weapon.
const now = Date.now();
db.prepare(
'INSERT INTO inventory (character_id, item_id, quantity, equipped_slot, acquired_at) VALUES (?, ?, 1, ?, ?)'
).run(characterId, weapon.id, 'weapon', now);
const res = await fetch(`${baseUrl}/api/heroes/ArmoredOne`);
assert.equal(res.status, 200);
const json = await res.json();
assert.equal(json.ok, true);
assert.ok(json.data.equipment.weapon, 'Should have a weapon equipped');
});
it('is case-insensitive', async () => {
const cookie = await registerAndLogin(`herouser3`, 'password123');
await createHero(cookie, 'TeStHeRo', 'ranger');
const res1 = await fetch(`${baseUrl}/api/heroes/TeStHeRo`);
assert.equal(res1.status, 200);
const res2 = await fetch(`${baseUrl}/api/heroes/testhero`);
assert.equal(res2.status, 200);
const json1 = await res1.json();
const json2 = await res2.json();
assert.equal(json1.data.name, json2.data.name);
});
it('returns 404 for a non-existent hero', async () => {
const res = await fetch(`${baseUrl}/api/heroes/NonExistentHero99`);
assert.equal(res.status, 404);
const json = await res.json();
assert.equal(json.ok, false);
assert.equal(json.error.code, 'NOT_FOUND');
});
it('works without authentication', async () => {
// This endpoint should be accessible without any session cookie.
const cookie = await registerAndLogin(`herouser4`, 'password123');
await createHero(cookie, 'PublicHero', 'warrior');
// No cookie at all — should still work.
const res = await fetch(`${baseUrl}/api/heroes/PublicHero`);
assert.equal(res.status, 200);
const json = await res.json();
assert.equal(json.ok, true);
assert.equal(json.data.name, 'PublicHero');
});
});