diff --git a/HANDOFF.md b/HANDOFF.md index 2b5d2a0..80cf0f3 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:** 7.3 (Expedition UI: start panel, progress bar, claim button) — TICKED in STATUS.md -- **Next step:** 8.1 — Leaderboard: `GET /api/leaderboard?type=level|gold|kills` top 50, with pagination param. +- **Last completed step:** 8.2 (Public profile: `GET /api/heroes/:name`) — TICKED in STATUS.md +- **Next step:** 8.3 — UI page for public hero profile. - **Updated:** 2026-10-21 - **Was the last session cut off mid-task?** No @@ -14,38 +14,39 @@ |---|---| | `npm install` | succeeds | | `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) -- `src/db/migrations/005_expeditions.sql` — `expedition_defs` + `expeditions` tables, unique active-expedition index, 3 seed definitions. -- `src/services/expeditionService.js` — `startExpedition`, `getActive`, `claimExpedition`, `listDefinitions`. Uses WeakMap stmt cache. -- `src/routes/expeditions.js` — `POST /api/expeditions/start`, `GET /api/expeditions/active`, `POST /api/expeditions/claim`, `GET /api/expeditions/defs`. -- `src/game/energy.js` — `computeEnergy` now uses `now()` from utils/time. -- `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`. -- `src/services/huntService.js` — `hunt(characterId, zoneId, {rng, now})`; ONE transaction; test-injectable. -- `src/services/inventoryService.js` — `listInventory`, `equipItem`, `unequipItem`, `useConsumable`. Uses WeakMap stmt cache. -- `src/services/shopService.js` — `getStock`, `buyItem`, `sellItem`. Uses WeakMap stmt cache. -- `src/game/combat.js` — `resolveCombat(hero, monster, rng)`; both sides attack each round, faster first. -- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp. -- `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services. -- `src/routes/zones.js` — `GET /api/zones`. -- `src/routes/combatLog.js` — `GET /api/combat-log`. +- `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/routes/heroProfile.js` — `GET /api/heroes/:name` (public, no auth). Returns 404 if hero not found. +- `src/services/leaderboardService.js` — `getLeaderboard(type, page, limit)`. +- `src/routes/leaderboard.js` — `GET /api/leaderboard?type=level|gold|kills&page=1`. +- `src/services/expeditionService.js` — start, getActive, claim, listDefinitions. +- `src/routes/expeditions.js` — 4 expedition endpoints. +- `src/services/shopService.js` — getStock, buyItem, sellItem. +- `src/routes/shop.js` — GET/POST shop endpoints. +- `src/services/inventoryService.js` — listInventory, equipItem, unequipItem, useConsumable. +- `src/services/huntService.js` — hunt (one transaction). +- `src/services/characterService.js` — createCharacter, getCharacterProfile, getCharacterIdForUser. +- `src/services/authService.js` — register, verifyLogin. +- `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/js/game.js` — full hunt UI + inventory UI + expedition UI (start panel, progress bar, claim button, auto-refresh). -- `public/css/main.css` — expedition UI styles (cards, progress bar, buttons). -- `public/shop.html` — dedicated shop page with buy/sell tabs. -- `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. +- `public/js/game.js` — hunt UI + inventory UI + expedition UI. +- `public/shop.html` — dedicated shop page. +- `tests/integration/hero-profile.test.js` — 5 integration tests. ## 4. Half-finished / fragile things - No leaderboard UI yet (Phase 8). +- No public profile UI page (step 8.3 next). - HP never regenerates naturally; only level-up restores it. - No restart-persistence test for sessions. - `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). -- 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 - 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. - **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-010:** `startExpedition` checks active-expedition before energy (so 409 ACTIVE_EXPEDITION takes priority over 422 INSUFFICIENT_ENERGY). -- **D-011:** `computeEnergy` uses `now()` from utils/time instead of `Date.now()` directly, so test time overrides work. +- **D-010:** `startExpedition` checks active-expedition before energy. +- **D-011:** `computeEnergy` uses `now()` from utils/time. ## 6. Gotchas learned - `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`. - Transaction callback args must match the call order exactly. - 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) -- **Goal:** Leaderboard endpoint — `GET /api/leaderboard?type=level|gold|kills` top 50, with `?page=N` pagination. -- **Docs:** GAME_DESIGN.md §Leaderboards, DATABASE. -- **Files to create:** `src/services/leaderboardService.js`, `src/routes/leaderboard.js`. -- **Files to modify:** `src/app.js` (register route). -- **API:** `GET /api/leaderboard?type=level|gold|kills&page=1` — returns ranked list of heroes (name, level/gold/kills) with pagination metadata. +## 7. Next-step briefing (8.3) +- **Goal:** Public profile UI page — a standalone HTML page showing the hero profile returned by `GET /api/heroes/:name`. +- **Docs:** GAME_DESIGN.md §Leaderboards, UI_GUIDE. +- **Files to create:** `public/profile.html`, `public/js/profile.js`. +- **No new backend needed** — the API endpoint already exists. ## 8. Open Known Issues STATUS.md issues 1–6 (balance, dangling `seed` script, restart test, equipment-in-combat, HP regen, zone min_level balance). diff --git a/STATUS.md b/STATUS.md index 4b3e28a..b013162 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,9 +8,9 @@ ## Current Focus - **Phase:** 8 -- **Step:** 8.1 +- **Step:** 8.3 - **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 | Item | State | @@ -100,7 +100,7 @@ ## Phase 8 — Leaderboards & Social-lite (docs: GAME_DESIGN §Leaderboards) - [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. ## 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.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-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 diff --git a/src/app.js b/src/app.js index 3e4c0fe..671490c 100644 --- a/src/app.js +++ b/src/app.js @@ -10,6 +10,7 @@ const { routes: inventoryRoutes } = require('./routes/inventory'); const { routes: shopRoutes } = require('./routes/shop'); const { routes: expeditionRoutes } = require('./routes/expeditions'); const { routes: leaderboardRoutes } = require('./routes/leaderboard'); +const { routes: heroProfileRoutes } = require('./routes/heroProfile'); const { AppError } = require('./utils/errors'); function createApp() { @@ -76,6 +77,9 @@ function createApp() { // Leaderboard route (require session) app.get('/api/leaderboard', requireAuth, leaderboardRoutes.getLeaderboard); + // Hero profile route (public, no session required) + app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile); + // Central error handler app.use((err, _req, res, _next) => { // Log stack traces server-side only diff --git a/src/routes/heroProfile.js b/src/routes/heroProfile.js new file mode 100644 index 0000000..6e2003f --- /dev/null +++ b/src/routes/heroProfile.js @@ -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), + }, +}; diff --git a/src/services/heroProfileService.js b/src/services/heroProfileService.js new file mode 100644 index 0000000..53027aa --- /dev/null +++ b/src/services/heroProfileService.js @@ -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 }; diff --git a/tests/integration/hero-profile.test.js b/tests/integration/hero-profile.test.js new file mode 100644 index 0000000..00d48b9 --- /dev/null +++ b/tests/integration/hero-profile.test.js @@ -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'); + }); +});