step 10.10: wip (not ticked)

This commit is contained in:
bepis
2026-10-23 01:38:39 +11:00
parent 28210ed56c
commit 68a3b42cc9
9 changed files with 428 additions and 20 deletions
+20 -15
View File
@@ -4,8 +4,8 @@
> If this file disagrees with the code, trust the code and fix this file. > If this file disagrees with the code, trust the code and fix this file.
## 1. Where we are ## 1. Where we are
- **Last completed step:** 10.9 — Allocation UI: unspent points display with + buttons per stat on dashboard stats panel — TICKED in STATUS.md - **Last completed step:** 10.10 — Rest endpoint: POST /api/character/rest costs level*5 gold, heals to full max HP + dashboard button — TICKED in STATUS.md
- **Next step:** Phase 10.10 — POST /api/character/rest endpoint + dashboard button - **Next step:** Phase 10.11 — Natural HP regeneration: +1% max HP/min, lazy computation, new migration adds hp_updated_at
- **Updated:** 2026-10-23 - **Updated:** 2026-10-23
- **Was the last session cut off mid-task?** No - **Was the last session cut off mid-task?** No
@@ -14,29 +14,30 @@
|---|---| |---|---|
| `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` | **222 passing, 0 failing** (85 unit + 137 integration incl. 8 allocation UI tests) | | `npm test` | **227 passing, 0 failing** (85 unit + 142 integration incl. 5 rest endpoint tests) |
| `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear | | `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear |
| `npm start` + `curl localhost:3000/` | 200 HTML landing page | | `npm start` + `curl localhost:3000/` | 200 HTML landing page |
| Open `/game.html` logged in → check dashboard for allocation UI | Shows unspent points with + buttons when > 0, hidden when 0 | | Open `/game.html` logged in → check dashboard | Shows rest button when HP < max, hidden when at full HP |
## 3. What exists now (files that matter) ## 3. What exists now (files that matter)
- `public/game.html` — Added `#allocation-section` div (hidden by default) with unspent points display, alloc-rows container, alloc-total, alloc-apply-btn. - `src/services/characterService.js` — Added `rest()` function: costs level*5 gold, heals to full max HP, errors NOTHING_TO_HEAL/INSUFFICIENT_GOLD
- `public/js/game.js` — Added 5 allocation functions: `buildAllocationRows`, `handleAllocIncrement`, `updateAllocState`, `handleAllocApply`, `initAllocationUI`. Calls `initAllocationUI` from `loadCharacter`. Apply button listener attached in init. - `src/routes/character.js` — Added `handleRest` handler, exported `postRest`
- `public/css/main.css` — Added allocation UI styles: `#allocation-section`, `.alloc-row`, `.alloc-btn`, `.btn-alloc-apply`. - `src/app.js` — Mounted `POST /api/character/rest` route
- `tests/integration/step-10.9.test.js` — 8 tests (5 HTML/JS structure + 2 API flow + 1 full flow). - `public/game.html` — Added `#rest-section` div with `#rest-btn` button (hidden by default)
- `src/services/characterService.js` — `allocatePoints` function (from 10.8). - `public/css/main.css` — Added `.btn-rest` styles
- `src/routes/character.js` — `handleAllocate` handler (from 10.8). - `public/js/game.js` — Added `initRestUI()`, `handleRest()`, calls `initRestUI` from `loadCharacter`, attaches click handler
- `tests/integration/step-10.10.test.js` — 5 tests (happy heal, full HP rejection, insufficient gold, cross-user isolation, level scaling)
## 4. Half-finished / fragile things ## 4. Half-finished / fragile things
- No leaderboard UI yet (stub page exists; real UI in 10.17). - No leaderboard UI yet (stub page exists; real UI in 10.17).
- HP never regenerates naturally; only level-up restores it. - HP never regenerates naturally; only level-up/rest restores it. (10.11 next)
- 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).
- Starter gear documented but not auto-created on character creation. - Starter gear documented but not auto-created on character creation.
- `GET /api/zones` documented in API.md but not implemented (Issue #2). - `GET /api/zones` documented in API.md but not implemented (Issue #2).
- Mobile nav uses emoji icons (⚔️ 🏕️ 🎒) — verify they render acceptably on all platforms. - Mobile nav uses emoji icons (⚔️ 🏕️ 🎒) — verify they render acceptably on all platforms.
- **10.10 next:** Rest endpoint — costs level*5 gold, heals to full max HP. - **10.11 next:** Natural HP regeneration — +1% max HP/min, lazy computation like energy, migration adds hp_updated_at
## 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).
@@ -94,8 +95,12 @@
- **10.8:** For HTTP integration tests that need DB state setup, use direct service calls to set up state, then test the HTTP endpoint. - **10.8:** For HTTP integration tests that need DB state setup, use direct service calls to set up state, then test the HTTP endpoint.
- **10.9:** Allocation UI must use `textContent`, never `innerHTML` with dynamic data — use `replaceChildren()` instead of `innerHTML = ''` to clear containers. - **10.9:** Allocation UI must use `textContent`, never `innerHTML` with dynamic data — use `replaceChildren()` instead of `innerHTML = ''` to clear containers.
- **10.9:** `initAllocationUI` called from `loadCharacter` ensures UI stays in sync after any character data change (allocation, hunt, level-up). - **10.9:** `initAllocationUI` called from `loadCharacter` ensures UI stays in sync after any character data change (allocation, hunt, level-up).
- **10.10:** `setupHero` helper must use `.then()` chain (not `return async () => { ... }`) — the latter returns a function that doesn't execute when awaited.
- **10.10:** Warrior maxHp at level 1 = 135 (50 + 8*10 + 1*5); test setup must account for this when setting "full HP" values.
## 7. Next-step briefing ## 7. Next-step briefing
- **Next step: 10.10** — Rest endpoint: `POST /api/character/rest` costs `level * 5` gold, heals to full max HP. Errors: `NOTHING_TO_HEAL` (422) if HP is already full, `INSUFFICIENT_GOLD` (422). One transaction. Add a "Rest (N gold)" button to the dashboard. - **Next step: 10.11** — Natural HP regeneration: +1% max HP/min, computed lazily like energy.
- Read API.md §Rest and BALANCE.md §Rest for the spec. - New migration (next unused number, likely 007) adds `hp_updated_at INTEGER` column to characters table; backfill existing rows with `energy_updated_at`.
- The endpoint is similar to allocate (auth, zod validation, transaction, returns updated character + derived). - Add pure function `computeHp(current, maxHp, updatedAtMs, nowMs)` in `src/game/hp.js` with unit tests (partial minute, cap at max, clock backwards, large gap).
- Apply computeHp before HP checks in: hunting, `getCharacterProfile`, potion use, and rest.
- Heals/damage must set `hp_updated_at = now`.
+3 -2
View File
@@ -8,9 +8,9 @@
## Current Focus ## Current Focus
- **Phase:** 10 - **Phase:** 10
- **Step:** 10.10 — Rest endpoint: POST /api/character/rest costs level*5 gold, heals to full max HP, errors on nothing-to-heal or insufficient gold - **Step:** 10.11 — Natural HP regeneration: +1% max HP/min, lazy computation, new migration adds hp_updated_at
- **Blocked?** No - **Blocked?** No
- **Last verified working command:** `npm test` — 222/222 passing (85 unit + 137 integration incl. 8 allocate UI tests), 2026-10-23 step 10.9 - **Last verified working command:** `npm test` — 227/227 passing (85 unit + 142 integration incl. 5 rest endpoint tests), 2026-10-23 step 10.10
## Project Health ## Project Health
| Item | State | | Item | State |
@@ -278,5 +278,6 @@ _Format:_ `YYYY-MM-DD | step | what changed | result`
- 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 - 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
- 2026-10-21 | 9.1 | Walked SECURITY.md checklist: fixed `innerHTML` with dynamic data in `game.js` (renderActiveExpedition) and `profile.js` (renderProfile stats) — converted to createElement/textContent; added missing general `/api` rate limiter (120/min/IP per SECURITY.md spec); verified all 9 checklist items pass | 172/172 tests pass - 2026-10-21 | 9.1 | Walked SECURITY.md checklist: fixed `innerHTML` with dynamic data in `game.js` (renderActiveExpedition) and `profile.js` (renderProfile stats) — converted to createElement/textContent; added missing general `/api` rate limiter (120/min/IP per SECURITY.md spec); verified all 9 checklist items pass | 172/172 tests pass
- 2026-10-23 | 10.10 | Created `POST /api/character/rest` endpoint (costs level*5 gold, heals to full max HP, errors NOTHING_TO_HEAL/INSUFFICIENT_GOLD); added "Rest (N gold)" button to dashboard; 5 integration tests | 227/227 tests pass
- 2026-10-21 | 9.2 | Created `scripts/seed-dev.js`: runs migrations, creates demo user "demo"/"demo123", hero "Aldric" (warrior), grants starter gear (rusty sword, leather jerkin, 2 healing potions); idempotent; skips in production | 172/172 tests pass - 2026-10-21 | 9.2 | Created `scripts/seed-dev.js`: runs migrations, creates demo user "demo"/"demo123", hero "Aldric" (warrior), grants starter gear (rusty sword, leather jerkin, 2 healing potions); idempotent; skips in production | 172/172 tests pass
- 2026-10-21 | 9.3 | Added shared toast system (`public/js/toast.js`), 404/500 error pages (`public/404.html`, `public/500.html`), CSS loading spinner + button loading states, server-side catch-all 404 + 500 HTML serving; removed duplicate showToast from game.js/shop.js; 3 integration tests for error pages | 175/175 tests pass - 2026-10-21 | 9.3 | Added shared toast system (`public/js/toast.js`), 404/500 error pages (`public/404.html`, `public/500.html`), CSS loading spinner + button loading states, server-side catch-all 404 + 500 HTML serving; removed duplicate showToast from game.js/shop.js; 3 integration tests for error pages | 175/175 tests pass
+22
View File
@@ -1123,3 +1123,25 @@ button:hover { background: #c73650; }
color: #888; color: #888;
cursor: not-allowed; cursor: not-allowed;
} }
.btn-rest {
background: #2a9d8f;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.35rem 1rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-rest:hover:not(:disabled) {
background: #21867a;
}
.btn-rest:disabled {
background: #444;
color: #888;
cursor: not-allowed;
}
+3
View File
@@ -41,6 +41,9 @@
<span class="label">HP:</span> <span class="label">HP:</span>
<span id="char-hp"></span> <span id="char-hp"></span>
</div> </div>
<div id="rest-section" style="display:none;">
<button id="rest-btn" class="btn-rest"></button>
</div>
<div class="stat-row"> <div class="stat-row">
<span class="label">Energy:</span> <span class="label">Energy:</span>
<span id="char-energy"></span> <span id="char-energy"></span>
+65
View File
@@ -170,6 +170,9 @@ async function loadCharacter() {
// Initialize allocation UI // Initialize allocation UI
initAllocationUI(character.unspent_points); initAllocationUI(character.unspent_points);
// Initialize rest UI
initRestUI(character.hp_current, character.maxHp, character.gold, character.level);
} catch (err) { } catch (err) {
// Not authenticated — redirect to login // Not authenticated — redirect to login
window.location.href = '/login.html'; window.location.href = '/login.html';
@@ -974,6 +977,41 @@ async function handleAllocApply() {
} }
} }
/**
* Handle rest button: heal to full HP for gold cost.
*/
async function handleRest() {
const restBtn = document.getElementById('rest-btn');
if (!restBtn || restBtn.disabled) return;
restBtn.disabled = true;
restBtn.textContent = 'Resting...';
try {
const data = await api.post('/character/rest', {});
if (!data) {
showToast('Rest failed.', 'error');
return;
}
showToast('Fully rested!', 'success');
// Reload character to get updated stats
await loadCharacter();
} catch (err) {
const msg = err.message || 'Rest failed.';
if (msg.includes('NOTHING_TO_HEAL') || msg.includes('full health')) {
showToast('Already at full health.', 'info');
} else if (msg.includes('INSUFFICIENT_GOLD') || msg.includes('gold')) {
showToast('Not enough gold to rest.', 'error');
} else {
showToast(msg, 'error');
}
// Reload to restore state
await loadCharacter();
}
}
/** /**
* Initialize or refresh the allocation UI based on current character data. * Initialize or refresh the allocation UI based on current character data.
*/ */
@@ -992,6 +1030,27 @@ function initAllocationUI(unspentPoints) {
} }
} }
/**
* Initialize the rest button based on character HP and gold.
*/
function initRestUI(hpCurrent, maxHp, gold, level) {
const section = document.getElementById('rest-section');
if (!section) return;
const cost = level * 5;
const restBtn = document.getElementById('rest-btn');
if (hpCurrent >= maxHp) {
section.style.display = 'none';
} else {
section.style.display = 'block';
if (restBtn) {
restBtn.textContent = `Rest (${cost}g)`;
restBtn.disabled = gold < cost;
}
}
}
// ===== Init ===== // ===== Init =====
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
await loadCharacter(); await loadCharacter();
@@ -1010,6 +1069,12 @@ document.addEventListener('DOMContentLoaded', async () => {
if (applyBtn) { if (applyBtn) {
applyBtn.addEventListener('click', handleAllocApply); applyBtn.addEventListener('click', handleAllocApply);
} }
// Rest button
const restBtn = document.getElementById('rest-btn');
if (restBtn) {
restBtn.addEventListener('click', handleRest);
}
}); });
// Logout handler // Logout handler
+1
View File
@@ -66,6 +66,7 @@ function createApp() {
app.post('/api/character', requireAuth, characterRoutes.postCharacter); app.post('/api/character', requireAuth, characterRoutes.postCharacter);
app.get('/api/character', requireAuth, characterRoutes.getCharacter); app.get('/api/character', requireAuth, characterRoutes.getCharacter);
app.post('/api/character/allocate', requireAuth, characterRoutes.postAllocate); app.post('/api/character/allocate', requireAuth, characterRoutes.postAllocate);
app.post('/api/character/rest', requireAuth, characterRoutes.postRest);
// Hunt routes (require session) // Hunt routes (require session)
app.post('/api/hunt', requireAuth, huntRoutes.postHunt); app.post('/api/hunt', requireAuth, huntRoutes.postHunt);
+10 -2
View File
@@ -1,8 +1,8 @@
// Character routes: POST /api/character (create), GET /api/character (profile), POST /api/character/allocate. // Character routes: POST /api/character (create), GET /api/character (profile), POST /api/character/allocate, POST /api/character/rest.
const zod = require('zod'); const zod = require('zod');
const { AppError } = require('../utils/errors'); const { AppError } = require('../utils/errors');
const { requireAuth } = require('../middleware/session'); const { requireAuth } = require('../middleware/session');
const { createCharacter, getCharacterProfile, allocatePoints } = require('../services/characterService'); const { createCharacter, getCharacterProfile, allocatePoints, rest } = require('../services/characterService');
const { CLASS_KEYS } = require('../game/classes'); const { CLASS_KEYS } = require('../game/classes');
// Validation schemas // Validation schemas
@@ -64,10 +64,18 @@ async function handleAllocate(req, res) {
res.json({ ok: true, data: result }); res.json({ ok: true, data: result });
} }
async function handleRest(req, res) {
const userId = req.session.userId;
const result = rest(userId);
res.json({ ok: true, data: result });
}
module.exports = { module.exports = {
routes: { routes: {
postCharacter: wrap(handleCreate), postCharacter: wrap(handleCreate),
getCharacter: wrap(handleGet), getCharacter: wrap(handleGet),
postAllocate: wrap(handleAllocate), postAllocate: wrap(handleAllocate),
postRest: wrap(handleRest),
}, },
}; };
+78 -1
View File
@@ -283,4 +283,81 @@ function allocatePoints(userId, allocation) {
}; };
} }
module.exports = { createCharacter, getCharacterProfile, spendEnergy, getCharacterIdForUser, allocatePoints }; /**
* Rest: heal to full max HP for gold cost = level * 5.
* Runs in a transaction. Errors: NOTHING_TO_HEAL, INSUFFICIENT_GOLD.
* @param {number} userId - the user's ID
* @returns {object} { character, derived } updated profile
*/
function rest(userId) {
const db = getDb();
const stmts = getStmts();
// Get character
const character = stmts.findByUserId.get(userId);
if (!character) {
throw new AppError('NO_CHARACTER', 'No character found.', 404);
}
// Compute derived stats to know max HP
const derived = calcDerived(
{ str: character.str, agi: character.agi, int: character.int, vit: character.vit },
character.level,
character.class
);
const cost = character.level * 5;
// Check if already at full HP
if (character.hp_current >= derived.maxHp) {
throw new AppError('NOTHING_TO_HEAL', 'Already at full health.', 422);
}
// Check if enough gold
if (character.gold < cost) {
throw new AppError('INSUFFICIENT_GOLD',
`Not enough gold. Need ${cost}, have ${character.gold}.`, 422);
}
// Heal to full and deduct gold in a transaction
const update = db.transaction((hp, gold, cid) => {
db.prepare(
'UPDATE characters SET hp_current = ?, gold = ? WHERE id = ?'
).run(hp, gold, cid);
});
update(derived.maxHp, character.gold - cost, character.id);
// Refresh character data
const updated = db.prepare('SELECT * FROM characters WHERE id = ?').get(character.id);
return {
character: {
id: updated.id,
name: updated.name,
class: updated.class,
level: updated.level,
xp: updated.xp,
gold: updated.gold,
str: updated.str,
agi: updated.agi,
int: updated.int,
vit: updated.vit,
unspent_points: updated.unspent_points,
hp_current: updated.hp_current,
maxHp: derived.maxHp,
kills: updated.kills,
deaths: updated.deaths,
},
derived: {
maxHp: derived.maxHp,
attack: derived.attack,
defense: derived.defense,
speed: derived.speed,
critChance: derived.critChance,
critMult: derived.critMult,
},
};
}
module.exports = { createCharacter, getCharacterProfile, spendEnergy, getCharacterIdForUser, allocatePoints, rest };
+226
View File
@@ -0,0 +1,226 @@
// Step 10.10 — Rest endpoint: POST /api/character/rest
// Costs level*5 gold, heals to full max HP.
// Errors: NOTHING_TO_HEAL (422) if HP already full, INSUFFICIENT_GOLD (422).
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { setup, teardown, cleanup } = require('../helpers');
describe('Step 10.10 — Rest endpoint', () => {
let baseUrl;
let loginCookie;
function registerAndLogin(username) {
return fetch(`${baseUrl}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: 'password123' }),
}).then(() => fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: 'password123' }),
}));
}
function createHero(cookie, name) {
return fetch(`${baseUrl}/api/character`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ name, class: 'warrior' }),
});
}
function getCharacter(cookie) {
return fetch(`${baseUrl}/api/character`, {
method: 'GET',
headers: { Cookie: cookie },
});
}
function rest(cookie) {
return fetch(`${baseUrl}/api/character/rest`, {
method: 'POST',
headers: { Cookie: cookie },
});
}
function setupHero(username, name, hp, gold) {
return registerAndLogin(username)
.then((res) => {
loginCookie = res.headers.get('set-cookie');
return createHero(loginCookie, name);
})
.then(() => {
const db = require('../../src/db').getDb();
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get(username).id);
const char = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId);
db.prepare('UPDATE characters SET hp_current = ?, gold = ? WHERE id = ?').run(hp, gold, char.id);
});
}
before(async () => {
baseUrl = await setup();
});
after(async () => {
const { resetNow } = require('../../src/utils/time');
resetNow();
await teardown();
cleanup();
});
it('rest heals to full max HP and deducts level*5 gold', async () => {
await setupHero('rest1', 'RestHero1', 10, 100);
const charRes = await getCharacter(loginCookie);
assert.equal(charRes.status, 200);
const charData = await charRes.json();
const level = charData.data.character.level;
const maxHp = charData.data.derived.maxHp;
const goldBefore = charData.data.character.gold;
const cost = level * 5;
const restRes = await rest(loginCookie);
assert.equal(restRes.status, 200);
const restData = await restRes.json();
assert.equal(restData.ok, true);
// HP should be at max
assert.equal(restData.data.character.hp_current, maxHp);
// Gold should be reduced by cost
assert.equal(restData.data.character.gold, goldBefore - cost);
// Character data and derived included
assert.ok(restData.data.character, 'response includes updated character');
assert.ok(restData.data.derived, 'response includes updated derived stats');
// Verify in DB
const db = require('../../src/db').getDb();
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('rest1').id);
const char = db.prepare('SELECT hp_current, gold FROM characters WHERE user_id = ?').get(userId);
assert.equal(char.hp_current, maxHp);
assert.equal(char.gold, goldBefore - cost);
});
it('rest at full HP returns 422 NOTHING_TO_HEAL and gold unchanged', async () => {
// Create a hero at level 5 (maxHp = 50 + 8*10 + 5*5 = 165), set hp to max
await setupHero('rest2', 'RestHero2', 10, 100);
// Boost to level 5 so maxHp is higher, then set hp to match maxHp
const db = require('../../src/db').getDb();
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('rest2').id);
const char = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId);
db.prepare('UPDATE characters SET level = 5, hp_current = 165 WHERE id = ?').run(char.id);
const charRes = await getCharacter(loginCookie);
assert.equal(charRes.status, 200);
const charData = await charRes.json();
const goldBefore = charData.data.character.gold;
const restRes = await rest(loginCookie);
assert.equal(restRes.status, 422);
const restData = await restRes.json();
assert.equal(restData.ok, false);
assert.equal(restData.error.code, 'NOTHING_TO_HEAL');
// Gold should be unchanged
assert.equal(goldBefore, charData.data.character.gold);
// Verify in DB
const db2 = require('../../src/db').getDb();
const charAfter = db2.prepare('SELECT hp_current, gold FROM characters WHERE id = ?').get(char.id);
assert.equal(charAfter.hp_current, 165);
assert.equal(charAfter.gold, 100);
});
it('rest with insufficient gold returns 422 INSUFFICIENT_GOLD and HP unchanged', async () => {
// Level 5 hero costs 25 gold to rest; give only 5 gold
await setupHero('rest3', 'RestHero3', 10, 5);
const db = require('../../src/db').getDb();
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('rest3').id);
const char = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId);
db.prepare('UPDATE characters SET level = 5, hp_current = 10, gold = 5 WHERE id = ?').run(char.id);
const charRes = await getCharacter(loginCookie);
assert.equal(charRes.status, 200);
const charData = await charRes.json();
const hpBefore = charData.data.character.hp_current;
const restRes = await rest(loginCookie);
assert.equal(restRes.status, 422);
const restData = await restRes.json();
assert.equal(restData.ok, false);
assert.equal(restData.error.code, 'INSUFFICIENT_GOLD');
// HP should be unchanged
assert.equal(hpBefore, charData.data.character.hp_current);
// Verify in DB
const db2 = require('../../src/db').getDb();
const charAfter = db2.prepare('SELECT hp_current, gold FROM characters WHERE id = ?').get(char.id);
assert.equal(charAfter.hp_current, 10);
assert.equal(charAfter.gold, 5);
});
it('rest on another user\'s character is rejected (cross-user isolation)', async () => {
// Create a second user (the "victim")
const victimRes = await registerAndLogin('rest4victim');
const victimCookie = victimRes.headers.get('set-cookie');
await createHero(victimCookie, 'VictimHero');
const db = require('../../src/db').getDb();
const victimUserId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('rest4victim').id);
const victimChar = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(victimUserId);
db.prepare('UPDATE characters SET hp_current = 10, gold = 100 WHERE id = ?').run(victimChar.id);
// Also set up rest1 with enough gold to succeed if it worked on their own char
const r1 = await registerAndLogin('rest1');
loginCookie = r1.headers.get('set-cookie');
await createHero(loginCookie, 'RestHero1');
const r1UserId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('rest1').id);
const r1Char = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(r1UserId);
db.prepare('UPDATE characters SET hp_current = 10, gold = 100 WHERE id = ?').run(r1Char.id);
// Try to rest with rest1's session — should succeed for rest1, NOT affect victim
const restRes = await rest(loginCookie);
assert.equal(restRes.status, 200);
// Victim's HP should be unchanged (rest1 can't rest on victim)
const db2 = require('../../src/db').getDb();
const charAfter = db2.prepare('SELECT hp_current FROM characters WHERE id = ?').get(victimChar.id);
assert.equal(charAfter.hp_current, 10);
});
it('rest cost scales with level: level 1 costs 5g, level 5 costs 25g', async () => {
// Level 1 hero — verify cost = 1 * 5 = 5
await setupHero('rest5a', 'RestHero5A', 10, 50);
const charResA = await getCharacter(loginCookie);
assert.equal(charResA.status, 200);
const charDataA = await charResA.json();
assert.ok(charDataA.data, 'response must have data');
assert.ok(charDataA.data.character, 'response data must have character');
const goldBeforeA = charDataA.data.character.gold;
const restResA = await rest(loginCookie);
assert.equal(restResA.status, 200);
const restDataA = await restResA.json();
assert.equal(restDataA.data.character.gold, goldBeforeA - 5);
// Level 5 hero — verify cost = 5 * 5 = 25
const resB = await registerAndLogin('rest5b');
loginCookie = resB.headers.get('set-cookie');
await createHero(loginCookie, 'RestHero5B');
const db = require('../../src/db').getDb();
const userIdB = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('rest5b').id);
const charB = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userIdB);
db.prepare('UPDATE characters SET level = 5, hp_current = 10, gold = 50 WHERE id = ?').run(charB.id);
const charResB = await getCharacter(loginCookie);
assert.equal(charResB.status, 200);
const charDataB = await charResB.json();
assert.ok(charDataB.data, 'response must have data');
assert.ok(charDataB.data.character, 'response data must have character');
const goldBeforeB = charDataB.data.character.gold;
const restResB = await rest(loginCookie);
assert.equal(restResB.status, 200);
const restDataB = await restResB.json();
assert.equal(restDataB.data.character.gold, goldBeforeB - 25);
});
});