step 4.5: done

This commit is contained in:
bepis
2026-10-20 21:31:12 +11:00
parent 9a308b697d
commit f26ca88d1e
11 changed files with 742 additions and 27 deletions
+25 -15
View File
@@ -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).
+6 -4
View File
@@ -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
+144
View File
@@ -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); }
}
+18 -2
View File
@@ -74,8 +74,24 @@
<!-- Action panel -->
<section class="panel action-panel" id="action-panel">
<h3>Actions</h3>
<p class="placeholder">Hunting and other actions will appear here in the next phase.</p>
<h3>Hunt</h3>
<div id="zone-list" class="zone-list">
<p class="loading">Loading zones...</p>
</div>
</section>
<!-- Combat log panel -->
<section class="panel combat-panel" id="combat-panel">
<h3>Combat Log</h3>
<div id="combat-log-list" class="combat-log-list">
<p class="loading">Loading...</p>
</div>
</section>
<!-- Result panel -->
<section class="panel result-panel" id="result-panel" style="display:none;">
<h3 id="result-title"></h3>
<div id="result-body"></div>
</section>
</main>
</div>
+257 -6
View File
@@ -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);
+8
View File
@@ -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
+32
View File
@@ -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),
},
};
+21
View File
@@ -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),
},
};
+45
View File
@@ -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 };
+31
View File
@@ -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 };
+155
View File
@@ -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);
});
});