From f26ca88d1e2100b6a709160330a41c0441d6fde7 Mon Sep 17 00:00:00 2001 From: bepis Date: Tue, 20 Oct 2026 21:31:12 +1100 Subject: [PATCH] step 4.5: done --- HANDOFF.md | 40 +++-- STATUS.md | 10 +- public/css/main.css | 144 +++++++++++++++ public/game.html | 20 ++- public/js/game.js | 263 +++++++++++++++++++++++++++- src/app.js | 8 + src/routes/combatLog.js | 32 ++++ src/routes/zones.js | 21 +++ src/services/combatLogService.js | 45 +++++ src/services/zoneService.js | 31 ++++ tests/integration/zones-log.test.js | 155 ++++++++++++++++ 11 files changed, 742 insertions(+), 27 deletions(-) create mode 100644 src/routes/combatLog.js create mode 100644 src/routes/zones.js create mode 100644 src/services/combatLogService.js create mode 100644 src/services/zoneService.js create mode 100644 tests/integration/zones-log.test.js diff --git a/HANDOFF.md b/HANDOFF.md index 79e3a59..c9bc93d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,47 +4,57 @@ > If this file disagrees with the code, trust the code and fix this file. ## 1. Where we are -- **Last completed step:** 4.4 (hunt endpoint) + a full review/fix pass -- **Next step:** 4.5 — hunt UI (needs `GET /api/zones` first, see STATUS issue 2) +- **Last completed step:** 4.5 (hunt UI — zones endpoint, combat-log endpoint, zone cards, hunt button, result panel, combat log replay) +- **Next step:** 5.1 — inventory migration (`004_inventory.sql`) - **Updated:** 2026-10-20 - **Was the last session cut off mid-task?** No ## 2. Verified state (run these first) | Command | Expected | |---|---| -| `npm install` | succeeds (better-sqlite3 is a native module; needs Node 20+ and a C++ toolchain if no prebuilt binary) | +| `npm install` | succeeds | | `npm run migrate` | applies 001–004 and 006 / "nothing to do" | -| `npm test` | **93 passing, 0 failing** (69 unit + 24 integration) | +| `npm test` | **98 passing, 0 failing** (74 unit + 29 integration) | ## 3. What exists now (files that matter) - `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp (implemented in `leveling.js`). - `src/game/combat.js` — `resolveCombat(hero, monster, rng)`; both sides attack each round, faster first. - `src/services/huntService.js` — `hunt(characterId, zoneId, {rng, now})`; ONE transaction; test-injectable. +- `src/services/zoneService.js` — `listZones(characterLevel)` → zones with `unlocked` flag. +- `src/services/combatLogService.js` — `getCombatLog(characterId, limit)` → recent fights. - `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`. - `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services. -- `src/middleware/session.js` — real SQLite session store (timer unref'd). -- `src/db/migrations/003_content.sql` — also SEEDS zones/monsters/items/loot (INSERT OR IGNORE). Changing balance = NEW migration, never edit 003. -- `tests/helpers.js` — `setup()/teardown()/cleanup()`: fresh app + temp DB per call. `tests/integration/hunt-service.test.js` shows how to force outcomes with test-only zones. +- `src/routes/zones.js` — `GET /api/zones`. +- `src/routes/combatLog.js` — `GET /api/combat-log`. +- `src/db/migrations/003_content.sql` — also SEEDS zones/monsters/items/loot (INSERT OR IGNORE). +- `tests/integration/zones-log.test.js` — 5 integration tests for zones + combat-log endpoints. +- `public/game.html` — dashboard with stats panel, zone list, combat log, result panel. +- `public/js/game.js` — full hunt UI: load zones, hunt handler, result display, combat log replay, toast notifications, energy sync. +- `public/css/main.css` — zone cards, combat log entries, result panel, toast styles. ## 4. Half-finished / fragile things -- No `GET /api/zones` yet; `public/js/game.js` has no hunt UI yet (4.5). -- HP never regenerates naturally; only level-up restores it (Rest/potions in Phases 5–6). +- No inventory/equipment system yet (Phase 5). +- 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`. ## 5. Decisions & deviations from the docs - D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock). +- Zod query param parsing uses `zod.coerce.number()` because Express query params are strings. ## 6. Gotchas learned - `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`. A shell glob once skipped 17 tests. - better-sqlite3-session-store ignores `clear:false`; that's why the timer is unref'd. - Statement caches are per-DB-handle (WeakMap) in huntService; copy that pattern for new services. - Verify test COUNT, not just "all passing". +- Node.js `fetch` doesn't persist cookies across calls — extract `Set-Cookie` header and pass manually in integration tests. +- Express query params are strings, not numbers — use `zod.coerce.number()` for numeric query params. -## 7. Next-step briefing (4.5) -- **Goal:** zone list + Hunt button + combat log replay + result summary + level-up message on the dashboard. -- **Docs:** API.md §Hunting, UI_GUIDE.md §game.html, GAME_DESIGN.md §Combat. -- **Files:** add `src/routes/zones.js` (+ service fn) and mount in app.js; extend `public/game.html` + `public/js/game.js`; add an integration test for `GET /api/zones`. -- **Watch out for:** use `textContent` (no innerHTML); show energy cost on the button; disable button while request is in flight; re-sync energy from the hunt response (`data.energy`, `data.hpRemaining`, `data.hpMax`). +## 7. Next-step briefing (5.1) +- **Goal:** Create `004_inventory.sql` migration (inventory table + starting items for new heroes). +- **Docs:** DATABASE.md, GAME_DESIGN.md §Items. +- **Files:** `src/db/migrations/004_inventory.sql`. +- **Schema:** `inventory` table (`id, character_id, item_id, quantity, equipped_slot NULL, acquired_at`), FK constraints. Starting items: class-appropriate common weapon (auto-equipped) + 2 Minor Healing Potions on character creation. ## 8. Open Known Issues -STATUS.md issues 1–6 (balance, zones endpoint, dangling `seed` script, restart test, equipment-in-combat, HP regen). +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 4d6f078..6a544fe 100644 --- a/STATUS.md +++ b/STATUS.md @@ -7,10 +7,10 @@ > - Keep Session Log entries to one or two lines. ## Current Focus -- **Phase:** 4 -- **Step:** 4.5 +- **Phase:** 5 +- **Step:** 5.1 - **Blocked?** No -- **Last verified working command:** `npm test` — 93/93 passing (69 unit + 24 integration), 2026-10-20 review/fix pass +- **Last verified working command:** `npm test` — 98/98 passing (74 unit + 29 integration), 2026-10-20 step 4.5 ## Project Health | Item | State | @@ -66,8 +66,9 @@ *Acceptance:* 23 unit tests pass; xpToNext matches BALANCE.md; level-up handles multi-level; points allocation validated. - [x] **4.4** `POST /api/hunt { zoneId }`: spend energy → pick monster → resolve → apply rewards/penalty → write `combat_log` → return summary + round log. All inside one DB transaction. *Acceptance:* hunt returns winner, rounds, xpGained, goldGained, loot, leveledUp, zone, monster. *Verified:* 9 service tests (`tests/integration/hunt-service.test.js`) cover persistence, level-up, defeat, locked zone, energy, stacking. -- [ ] **4.5** UI: zone list, "Hunt" button, combat log display, result summary, level-up message. +- [x] **4.5** UI: zone list, "Hunt" button, combat log display, result summary, level-up message. *Acceptance:* hunting repeatedly drains energy, awards XP/gold, levels the hero, and persists. + *Verified:* `GET /api/zones` + `GET /api/combat-log` endpoints; zone cards with locked/unlocked states; Hunt button with energy cost display; result panel showing victory/defeat, XP, gold, loot, level-up; combat log replay; energy re-sync after hunt. 98/98 tests pass (5 new integration tests). ## Phase 5 — Inventory & Equipment (docs: GAME_DESIGN §Items, DATABASE) - [ ] **5.1** Migration `004_inventory.sql`. @@ -143,6 +144,7 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing` ## Session Log _Format:_ `YYYY-MM-DD | step | what changed | result` +- 2026-10-20 | 4.5 | Added `GET /api/zones` (zoneService + zones route), `GET /api/combat-log` (combatLogService + combatLog route); extended game.html with zone list/combat log/result panels; rewrote game.js with hunt UI (zone cards, hunt button, result display, combat log replay, toast notifications); added CSS for zone cards/combat log/toasts; 5 new integration tests | 98/98 tests pass - 2026-10-20 | review | Fixed `npm test` (ran 62 of 79 tests; auth/combat/health were skipped by an unquoted glob). Rewrote combat (slower side never attacked; mage bonus dead; hpRemaining wrong on loss). Rewrote huntService (XP not deducted on level-up, unspent points overwritten, deaths never counted, min_level ignored, stackable loot duplicated, duplicate stat formulas). Fixed nextTickInSec (was time-to-full). Added utils/time.js, WeakMap stmt cache, session store unref, ok:false error envelope, removed connect-sqlite3 | 93/93 tests pass; smoke test OK - 2026-10-20 | 4.4 | Created 004_inventory.sql + 006_logs.sql migrations; fixed huntService transaction (db.transaction() returns wrapper, must call separately); added huntService to helpers.js cache invalidation; session middleware uses memory store in tests | 62/62 tests pass, hunt endpoint working - 2026-10-20 | 3.3 | Fixed game.js countdown: separated updateEnergyBar (pure display) from startCountdown (client-side timer), added energyData store and startEnergySync (60s server refetch) | 35/35 tests pass, energy bar + countdown working correctly diff --git a/public/css/main.css b/public/css/main.css index e3cc12c..761563d 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -174,3 +174,147 @@ button:hover { background: #c73650; } margin-top: 0.25rem; text-align: right; } + +/* ===== Zone cards ===== */ +.zone-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.zone-card { + background: #0f3460; + border: 1px solid #333; + border-radius: 6px; + padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.zone-card .zone-name { + font-weight: bold; + color: #e94560; + font-size: 0.95rem; +} + +.zone-card .zone-desc { + font-size: 0.8rem; + color: #a0a0a0; + line-height: 1.3; +} + +.zone-card .zone-meta { + font-size: 0.75rem; + color: #666; +} + +.zone-card .zone-locked-msg { + font-size: 0.8rem; + color: #666; + font-style: italic; +} + +.btn-hunt { + margin-top: 0.5rem; + background: #e94560; + color: #fff; + border: none; + border-radius: 4px; + padding: 0.5rem 1rem; + font-size: 0.875rem; + cursor: pointer; + align-self: flex-start; +} + +.btn-hunt:hover:not(:disabled) { + background: #c73650; +} + +.btn-hunt:disabled { + background: #444; + color: #888; + cursor: not-allowed; +} + +/* ===== Combat log ===== */ +.combat-log-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 300px; + overflow-y: auto; +} + +.log-entry { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.5rem; + background: #0f3460; + border-radius: 4px; + font-size: 0.8rem; +} + +.log-result { + font-weight: bold; + font-size: 0.9rem; + min-width: 1.2em; + text-align: center; +} + +.log-result.win { + color: #4f9d69; +} + +.log-result.loss { + color: #c0392b; +} + +.log-text { + color: #eadfce; +} + +/* ===== Result panel ===== */ +.result-panel { + grid-column: 1 / -1; +} + +.result-panel h3 { + margin-bottom: 0.5rem; +} + +.result-panel p { + font-size: 0.875rem; + padding: 0.2rem 0; + color: #eadfce; +} + +/* ===== Toast notifications ===== */ +.toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + padding: 0.75rem 1.25rem; + border-radius: 6px; + font-size: 0.875rem; + color: #fff; + z-index: 1000; + animation: toastIn 0.3s ease; + max-width: 90vw; + text-align: center; +} + +.toast-error { + background: #c0392b; +} + +.toast-info { + background: #4a7fb0; +} + +@keyframes toastIn { + from { opacity: 0; transform: translateX(-50%) translateY(1rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} diff --git a/public/game.html b/public/game.html index b08cacf..3ab4d1d 100644 --- a/public/game.html +++ b/public/game.html @@ -74,8 +74,24 @@
-

Actions

-

Hunting and other actions will appear here in the next phase.

+

Hunt

+
+

Loading zones...

+
+
+ + +
+

Combat Log

+
+

Loading...

+
+
+ + + diff --git a/public/js/game.js b/public/js/game.js index 9b88fb6..4003755 100644 --- a/public/js/game.js +++ b/public/js/game.js @@ -1,7 +1,8 @@ -// Game dashboard: load character profile and populate stats. +// Game dashboard: load character profile, zones, hunt, and combat log. let energyInterval = null; let energyData = null; // store latest server energy data for sync +let isHunting = false; /** * Refresh energy state from the server at a fixed interval. @@ -98,7 +99,7 @@ async function loadCharacter() { document.getElementById('char-name').textContent = character.name; document.getElementById('char-class').textContent = character.class.charAt(0).toUpperCase() + character.class.slice(1); document.getElementById('char-level').textContent = character.level; - document.getElementById('char-hp').textContent = `${character.hp_current} / ${derived.maxHp}`; + document.getElementById('char-hp').textContent = `${character.hp_current} / ${character.maxHp}`; document.getElementById('char-energy').textContent = `${energy.current} / ${energy.max}`; document.getElementById('char-gold').textContent = character.gold; document.getElementById('char-xp').textContent = `${character.xp} / ${xpToNext(character.level)}`; @@ -124,8 +125,260 @@ function xpToNext(level) { return Math.floor(100 * Math.pow(level, 1.5)); } -// Start periodic energy sync from server (every 60s) -startEnergySync(); +// ===== Zone list ===== +async function loadZones() { + const zoneList = document.getElementById('zone-list'); + try { + const data = await api.get('/zones'); + if (!data || !data.ok) { + zoneList.innerHTML = ''; + const p = document.createElement('p'); + p.className = 'placeholder'; + p.textContent = 'Failed to load zones.'; + zoneList.appendChild(p); + return; + } + + const zones = data.data; + zoneList.innerHTML = ''; + + for (const zone of zones) { + const card = document.createElement('div'); + card.className = 'zone-card'; + + const zoneName = document.createElement('div'); + zoneName.className = 'zone-name'; + zoneName.textContent = zone.name; + + const zoneDesc = document.createElement('div'); + zoneDesc.className = 'zone-desc'; + zoneDesc.textContent = zone.description; + + const zoneMeta = document.createElement('div'); + zoneMeta.className = 'zone-meta'; + zoneMeta.textContent = `Lv ${zone.min_level}+ · ${zone.energy_cost}⚡`; + + const btn = document.createElement('button'); + btn.className = 'btn btn-hunt'; + btn.textContent = `Hunt (${zone.energy_cost}⚡)`; + btn.disabled = !zone.unlocked; + btn.dataset.zoneId = zone.id; + + if (!zone.unlocked) { + const lockMsg = document.createElement('div'); + lockMsg.className = 'zone-locked-msg'; + lockMsg.textContent = `Reach level ${zone.min_level}`; + card.appendChild(zoneName); + card.appendChild(lockMsg); + } else { + card.appendChild(zoneName); + card.appendChild(zoneDesc); + card.appendChild(zoneMeta); + card.appendChild(btn); + } + + zoneList.appendChild(card); + } + + // Attach hunt handlers + zoneList.querySelectorAll('.btn-hunt').forEach((btn) => { + btn.addEventListener('click', handleHunt); + }); + } catch (_) { + zoneList.innerHTML = ''; + const p = document.createElement('p'); + p.className = 'placeholder'; + p.textContent = 'Failed to load zones.'; + zoneList.appendChild(p); + } +} + +// ===== Hunt ===== +async function handleHunt(e) { + if (isHunting) return; + const btn = e.target; + const zoneId = Number(btn.dataset.zoneId); + if (isNaN(zoneId)) return; + + isHunting = true; + btn.disabled = true; + btn.textContent = 'Hunting...'; + + // Hide previous result + document.getElementById('result-panel').style.display = 'none'; + + try { + const data = await api.post('/hunt', { zoneId }); + if (!data || !data.ok) { + showToast(data?.error?.message || 'Hunt failed.', 'error'); + return; + } + + const result = data.data; + displayHuntResult(result); + loadCharacter(); + loadCombatLog(); + loadZones(); // refresh energy cost buttons + } catch (err) { + showToast('Hunt failed.', 'error'); + } finally { + isHunting = false; + // Re-enable all hunt buttons + document.querySelectorAll('.btn-hunt').forEach((b) => { + b.disabled = false; + // Restore text from zone card labels + const zoneCard = b.closest('.zone-card'); + if (zoneCard) { + const meta = zoneCard.querySelector('.zone-meta'); + if (meta) { + const cost = meta.textContent.match(/(\d+)⚡/); + if (cost) b.textContent = `Hunt (${cost[1]}⚡)`; + } + } + }); + } +} + +// ===== Display hunt result ===== +function displayHuntResult(result) { + const panel = document.getElementById('result-panel'); + const title = document.getElementById('result-title'); + const body = document.getElementById('result-body'); + + panel.style.display = 'block'; + + const won = result.winner === 'hero'; + title.textContent = won ? 'Victory!' : 'Defeat'; + title.style.color = won ? '' : ''; + + body.innerHTML = ''; + + // Monster info + const monsterLine = document.createElement('p'); + monsterLine.textContent = `Fought: ${result.monster.name} (Lv.${result.monster.level})`; + body.appendChild(monsterLine); + + // Rewards + const rewards = document.createElement('p'); + rewards.textContent = []; + const parts = []; + if (result.xpGained) parts.push(`+${result.xpGained} XP`); + if (result.goldGained) parts.push(`+${result.goldGained} Gold`); + if (parts.length) rewards.textContent = parts.join(', '); + if (result.loot && result.loot.length > 0) { + const lootParts = result.loot.map((l) => `${l.name}${l.quantity > 1 ? `x${l.quantity}` : ''}`); + const lootLine = document.createElement('p'); + lootLine.textContent = `Loot: ${lootParts.join(', ')}`; + body.appendChild(lootLine); + } + body.appendChild(rewards); + + // Level up + if (result.leveledUp) { + const lvLine = document.createElement('p'); + lvLine.style.fontWeight = 'bold'; + lvLine.textContent = `⬆ Level up! Now level ${result.newLevel}`; + body.appendChild(lvLine); + } + + // HP remaining + const hpLine = document.createElement('p'); + hpLine.textContent = `HP: ${result.hpRemaining} / ${result.hpMax}`; + body.appendChild(hpLine); + + // Energy remaining + const energyLine = document.createElement('p'); + energyLine.textContent = `Energy: ${result.energy}`; + body.appendChild(energyLine); + + // Gold lost on defeat + if (result.goldLost > 0) { + const lostLine = document.createElement('p'); + lostLine.style.color = '#c0392b'; + lostLine.textContent = `Lost ${result.goldLost} gold to defeat.`; + body.appendChild(lostLine); + } +} + +// ===== Combat log ===== +async function loadCombatLog() { + const logList = document.getElementById('combat-log-list'); + try { + const data = await api.get('/combat-log?limit=10'); + if (!data || !data.ok) { + logList.innerHTML = ''; + const p = document.createElement('p'); + p.className = 'placeholder'; + p.textContent = 'No combat history.'; + logList.appendChild(p); + return; + } + + const logs = data.data; + logList.innerHTML = ''; + + if (logs.length === 0) { + const p = document.createElement('p'); + p.className = 'placeholder'; + p.textContent = 'No combat history.'; + logList.appendChild(p); + return; + } + + for (const log of logs) { + const entry = document.createElement('div'); + entry.className = 'log-entry'; + + const resultTag = document.createElement('span'); + resultTag.className = `log-result ${log.won ? 'win' : 'loss'}`; + resultTag.textContent = log.won ? '✓' : '✗'; + + const logText = document.createElement('span'); + logText.className = 'log-text'; + const parts = [`${log.monsterName} (Lv.${log.monsterLevel})`]; + if (log.won) { + if (log.xpGained) parts.push(`+${log.xpGained} XP`); + if (log.goldGained) parts.push(`+${log.goldGained} G`); + } else { + parts.push('defeated'); + } + logText.textContent = parts.join(' · '); + + entry.appendChild(resultTag); + entry.appendChild(logText); + logList.appendChild(entry); + } + } catch (_) { + logList.innerHTML = ''; + const p = document.createElement('p'); + p.className = 'placeholder'; + p.textContent = 'Failed to load combat log.'; + logList.appendChild(p); + } +} + +// ===== Toast notifications ===== +function showToast(message, type = 'info') { + // Remove existing toasts + document.querySelectorAll('.toast').forEach((t) => t.remove()); + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(() => { + toast.remove(); + }, 4000); +} + +// ===== Init ===== +document.addEventListener('DOMContentLoaded', async () => { + await loadCharacter(); + await loadZones(); + await loadCombatLog(); + startEnergySync(); +}); // Logout handler document.getElementById('logout-btn')?.addEventListener('click', async (e) => { @@ -135,5 +388,3 @@ document.getElementById('logout-btn')?.addEventListener('click', async (e) => { } catch (_) {} window.location.href = '/login.html'; }); - -document.addEventListener('DOMContentLoaded', loadCharacter); diff --git a/src/app.js b/src/app.js index 74d0b07..03f1589 100644 --- a/src/app.js +++ b/src/app.js @@ -4,6 +4,8 @@ const { createSessionMiddleware, requireAuth } = require('./middleware/session') const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth'); const { routes: characterRoutes } = require('./routes/character'); const { routes: huntRoutes } = require('./routes/hunt'); +const { routes: zonesRoutes } = require('./routes/zones'); +const { routes: combatLogRoutes } = require('./routes/combatLog'); const { AppError } = require('./utils/errors'); function createApp() { @@ -44,6 +46,12 @@ function createApp() { // Hunt routes (require session) app.post('/api/hunt', requireAuth, huntRoutes.postHunt); + // Zone routes (require session) + app.get('/api/zones', requireAuth, zonesRoutes.getZones); + + // Combat log routes (require session) + app.get('/api/combat-log', requireAuth, combatLogRoutes.getCombatLog); + // Central error handler app.use((err, _req, res, _next) => { // Log stack traces server-side only diff --git a/src/routes/combatLog.js b/src/routes/combatLog.js new file mode 100644 index 0000000..2c4851d --- /dev/null +++ b/src/routes/combatLog.js @@ -0,0 +1,32 @@ +// Combat log routes: GET /api/combat-log (recent fights for the logged-in user's character). +const zod = require('zod'); +const { getCombatLog } = require('../services/combatLogService'); +const { requireAuth } = require('../middleware/session'); +const { getCharacterIdForUser } = require('../services/characterService'); + +function wrap(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} + +const logSchema = zod.object({ + limit: zod.coerce.number().int().min(1).max(100).optional(), +}); + +async function handleGetCombatLog(req, res) { + const parsed = logSchema.safeParse(req.query); + if (!parsed.success) { + const { AppError } = require('../utils/errors'); + throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400); + } + + const characterId = getCharacterIdForUser(req.session.userId); + const limit = parsed.data.limit ?? 20; + const logs = getCombatLog(characterId, limit); + res.json({ ok: true, data: logs }); +} + +module.exports = { + routes: { + getCombatLog: wrap(handleGetCombatLog), + }, +}; diff --git a/src/routes/zones.js b/src/routes/zones.js new file mode 100644 index 0000000..f05326a --- /dev/null +++ b/src/routes/zones.js @@ -0,0 +1,21 @@ +// Zone routes: GET /api/zones (list zones with unlocked flag). +const zod = require('zod'); +const { listZones } = require('../services/zoneService'); +const { requireAuth } = require('../middleware/session'); +const { getCharacterProfile } = require('../services/characterService'); + +function wrap(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} + +async function handleGetZones(req, res) { + const profile = getCharacterProfile(req.session.userId); + const zones = listZones(profile.character.level); + res.json({ ok: true, data: zones }); +} + +module.exports = { + routes: { + getZones: wrap(handleGetZones), + }, +}; diff --git a/src/services/combatLogService.js b/src/services/combatLogService.js new file mode 100644 index 0000000..c159084 --- /dev/null +++ b/src/services/combatLogService.js @@ -0,0 +1,45 @@ +// Combat log service: retrieve recent combat log entries for a character. +const { getDb } = require('../db'); + +const stmtCache = new WeakMap(); + +function getStmts(db) { + let s = stmtCache.get(db); + if (!s) { + s = { + logs: db.prepare( + `SELECT cl.id, cl.monster_id, cl.won, cl.rounds, cl.xp_gained, cl.gold_gained, cl.created_at, + m.name AS monster_name, m.level AS monster_level + FROM combat_log cl + JOIN monsters m ON m.id = cl.monster_id + WHERE cl.character_id = ? + ORDER BY cl.id DESC + LIMIT ?` + ), + }; + stmtCache.set(db, s); + } + return s; +} + +/** + * @param {number} characterId + * @param {number} [limit] - max entries, default 20 + * @returns {Array} + */ +function getCombatLog(characterId, limit = 20) { + const db = getDb(); + const s = getStmts(db); + return s.logs.all(characterId, limit).map((row) => ({ + id: row.id, + monsterName: row.monster_name, + monsterLevel: row.monster_level, + won: Boolean(row.won), + rounds: row.rounds, + xpGained: row.xp_gained, + goldGained: row.gold_gained, + createdAt: row.created_at, + })); +} + +module.exports = { getCombatLog }; diff --git a/src/services/zoneService.js b/src/services/zoneService.js new file mode 100644 index 0000000..67f13de --- /dev/null +++ b/src/services/zoneService.js @@ -0,0 +1,31 @@ +// Zone service: list zones with unlocked flag for a given character level. +const { getDb } = require('../db'); + +// Prepared statements are cached PER database handle. +const stmtCache = new WeakMap(); + +function getStmts(db) { + let s = stmtCache.get(db); + if (!s) { + s = { + zones: db.prepare('SELECT id, slug, name, description, min_level, energy_cost FROM zones ORDER BY min_level, id'), + }; + stmtCache.set(db, s); + } + return s; +} + +/** + * @param {number} characterLevel - hero's current level + * @returns {Array<{id, slug, name, description, min_level, energy_cost, unlocked}>} + */ +function listZones(characterLevel) { + const db = getDb(); + const s = getStmts(db); + return s.zones.all().map((zone) => ({ + ...zone, + unlocked: characterLevel >= zone.min_level, + })); +} + +module.exports = { listZones }; diff --git a/tests/integration/zones-log.test.js b/tests/integration/zones-log.test.js new file mode 100644 index 0000000..36c4733 --- /dev/null +++ b/tests/integration/zones-log.test.js @@ -0,0 +1,155 @@ +// Integration tests for GET /api/zones and GET /api/combat-log. +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, cleanup } = require('../helpers'); + +/** + * Register a user and log them in, returning the session cookie string. + * Node.js fetch doesn't persist cookies across calls, so we extract and reuse them. + */ +async function registerAndLogin(baseUrl, username, password) { + // Register + await fetch(`${baseUrl}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + // Login — extract the session cookie from Set-Cookie + 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 json = await res.json(); + assert.equal(json.ok, true); + + // Extract session cookie (express-session sets a cookie like "connect.sid=...; Path=/") + const setCookie = res.headers.get('set-cookie'); + assert.ok(setCookie, 'Login should set a session cookie'); + const cookie = setCookie.split(';')[0]; // "connect.sid=..." + return cookie; +} + +function authHeaders(cookie) { + return { Cookie: cookie }; +} + +async function createCharacter(baseUrl, cookie, name = 'TestHero', cls = 'warrior') { + const res = await fetch(`${baseUrl}/api/character`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ name, class: cls }), + }); + assert.equal(res.status, 201); + const json = await res.json(); + assert.equal(json.ok, true); + return json; +} + +describe('zones endpoint', () => { + let baseUrl; + + before(async () => { + baseUrl = await setup(); + }); + + after(async () => { + await teardown(); + cleanup(); + }); + + it('returns all zones with unlocked flag for a level-1 character', async () => { + const cookie = await registerAndLogin(baseUrl, 'zoneuser1', 'password123'); + await createCharacter(baseUrl, cookie, 'ZoneHero1'); + + const res = await fetch(`${baseUrl}/api/zones`, { headers: authHeaders(cookie) }); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.ok, true); + + const zones = json.data; + assert.ok(Array.isArray(zones)); + assert.ok(zones.length >= 3); + + // First zone (Whispering Meadows, min_level=1) should be unlocked for level-1 hero + const meadows = zones.find((z) => z.slug === 'whispering-meadows'); + assert.ok(meadows); + assert.equal(meadows.unlocked, true); + assert.equal(meadows.energy_cost, 5); + assert.equal(meadows.min_level, 1); + + // Gloomroot Forest (min_level=5) should be locked + const forest = zones.find((z) => z.slug === 'gloomroot-forest'); + assert.ok(forest); + assert.equal(forest.unlocked, false); + assert.equal(forest.min_level, 5); + + // Ashfang Crags (min_level=10) should be locked + const crags = zones.find((z) => z.slug === 'ashfang-crag'); + assert.ok(crags); + assert.equal(crags.unlocked, false); + assert.equal(crags.min_level, 10); + }); + + it('returns unlocked=true when character level meets zone requirement', async () => { + const cookie = await registerAndLogin(baseUrl, 'zoneuser2', 'password123'); + await createCharacter(baseUrl, cookie, 'ZoneHero2'); + + // Level up the character to 5 + const db = require('../../src/db').getDb(); + db.prepare( + 'UPDATE characters SET level = 5, xp = 0, unspent_points = 0 WHERE user_id = (SELECT id FROM users WHERE username = ?)' + ).run('zoneuser2'); + + const res = await fetch(`${baseUrl}/api/zones`, { headers: authHeaders(cookie) }); + assert.equal(res.status, 200); + const json = await res.json(); + + const forest = json.data.find((z) => z.slug === 'gloomroot-forest'); + assert.equal(forest.unlocked, true); + + const crags = json.data.find((z) => z.slug === 'ashfang-crag'); + assert.equal(crags.unlocked, false); + }); + + it('rejects without authentication', async () => { + const res = await fetch(`${baseUrl}/api/zones`); + assert.equal(res.status, 401); + const json = await res.json(); + assert.equal(json.ok, false); + assert.equal(json.error.code, 'UNAUTHENTICATED'); + }); +}); + +describe('combat-log endpoint', () => { + let baseUrl; + + before(async () => { + baseUrl = await setup(); + }); + + after(async () => { + await teardown(); + cleanup(); + }); + + it('returns empty list when no combat has occurred', async () => { + const cookie = await registerAndLogin(baseUrl, 'loguser', 'password123'); + await createCharacter(baseUrl, cookie, 'LogHero', 'mage'); + + const res = await fetch(`${baseUrl}/api/combat-log?limit=10`, { headers: authHeaders(cookie) }); + assert.equal(res.status, 200); + const json = await res.json(); + assert.equal(json.ok, true); + assert.equal(json.data.length, 0); + }); + + it('rejects without authentication', async () => { + const res = await fetch(`${baseUrl}/api/combat-log`); + assert.equal(res.status, 401); + const json = await res.json(); + assert.equal(json.ok, false); + }); +});