step 10.10: wip (not ticked)
This commit is contained in:
+20
-15
@@ -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:** 10.9 — Allocation UI: unspent points display with + buttons per stat on dashboard stats panel — TICKED in STATUS.md
|
||||
- **Next step:** Phase 10.10 — POST /api/character/rest endpoint + dashboard button
|
||||
- **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.11 — Natural HP regeneration: +1% max HP/min, lazy computation, new migration adds hp_updated_at
|
||||
- **Updated:** 2026-10-23
|
||||
- **Was the last session cut off mid-task?** No
|
||||
|
||||
@@ -14,29 +14,30 @@
|
||||
|---|---|
|
||||
| `npm install` | succeeds |
|
||||
| `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 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)
|
||||
- `public/game.html` — Added `#allocation-section` div (hidden by default) with unspent points display, alloc-rows container, alloc-total, alloc-apply-btn.
|
||||
- `public/js/game.js` — Added 5 allocation functions: `buildAllocationRows`, `handleAllocIncrement`, `updateAllocState`, `handleAllocApply`, `initAllocationUI`. Calls `initAllocationUI` from `loadCharacter`. Apply button listener attached in init.
|
||||
- `public/css/main.css` — Added allocation UI styles: `#allocation-section`, `.alloc-row`, `.alloc-btn`, `.btn-alloc-apply`.
|
||||
- `tests/integration/step-10.9.test.js` — 8 tests (5 HTML/JS structure + 2 API flow + 1 full flow).
|
||||
- `src/services/characterService.js` — `allocatePoints` function (from 10.8).
|
||||
- `src/routes/character.js` — `handleAllocate` handler (from 10.8).
|
||||
- `src/services/characterService.js` — Added `rest()` function: costs level*5 gold, heals to full max HP, errors NOTHING_TO_HEAL/INSUFFICIENT_GOLD
|
||||
- `src/routes/character.js` — Added `handleRest` handler, exported `postRest`
|
||||
- `src/app.js` — Mounted `POST /api/character/rest` route
|
||||
- `public/game.html` — Added `#rest-section` div with `#rest-btn` button (hidden by default)
|
||||
- `public/css/main.css` — Added `.btn-rest` styles
|
||||
- `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
|
||||
- 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.
|
||||
- `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).
|
||||
- Starter gear documented but not auto-created on character creation.
|
||||
- `GET /api/zones` documented in API.md but not implemented (Issue #2).
|
||||
- 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
|
||||
- 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.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.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
|
||||
- **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.
|
||||
- Read API.md §Rest and BALANCE.md §Rest for the spec.
|
||||
- The endpoint is similar to allocate (auth, zod validation, transaction, returns updated character + derived).
|
||||
- **Next step: 10.11** — Natural HP regeneration: +1% max HP/min, computed lazily like energy.
|
||||
- New migration (next unused number, likely 007) adds `hp_updated_at INTEGER` column to characters table; backfill existing rows with `energy_updated_at`.
|
||||
- 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`.
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
## Current Focus
|
||||
- **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
|
||||
- **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
|
||||
| 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-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-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.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
|
||||
|
||||
@@ -1123,3 +1123,25 @@ button:hover { background: #c73650; }
|
||||
color: #888;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
<span class="label">HP:</span>
|
||||
<span id="char-hp"></span>
|
||||
</div>
|
||||
<div id="rest-section" style="display:none;">
|
||||
<button id="rest-btn" class="btn-rest"></button>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="label">Energy:</span>
|
||||
<span id="char-energy"></span>
|
||||
|
||||
@@ -170,6 +170,9 @@ async function loadCharacter() {
|
||||
|
||||
// Initialize allocation UI
|
||||
initAllocationUI(character.unspent_points);
|
||||
|
||||
// Initialize rest UI
|
||||
initRestUI(character.hp_current, character.maxHp, character.gold, character.level);
|
||||
} catch (err) {
|
||||
// Not authenticated — redirect to login
|
||||
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.
|
||||
*/
|
||||
@@ -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 =====
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadCharacter();
|
||||
@@ -1010,6 +1069,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (applyBtn) {
|
||||
applyBtn.addEventListener('click', handleAllocApply);
|
||||
}
|
||||
|
||||
// Rest button
|
||||
const restBtn = document.getElementById('rest-btn');
|
||||
if (restBtn) {
|
||||
restBtn.addEventListener('click', handleRest);
|
||||
}
|
||||
});
|
||||
|
||||
// Logout handler
|
||||
|
||||
@@ -66,6 +66,7 @@ function createApp() {
|
||||
app.post('/api/character', requireAuth, characterRoutes.postCharacter);
|
||||
app.get('/api/character', requireAuth, characterRoutes.getCharacter);
|
||||
app.post('/api/character/allocate', requireAuth, characterRoutes.postAllocate);
|
||||
app.post('/api/character/rest', requireAuth, characterRoutes.postRest);
|
||||
|
||||
// Hunt routes (require session)
|
||||
app.post('/api/hunt', requireAuth, huntRoutes.postHunt);
|
||||
|
||||
+10
-2
@@ -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 { AppError } = require('../utils/errors');
|
||||
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');
|
||||
|
||||
// Validation schemas
|
||||
@@ -64,10 +64,18 @@ async function handleAllocate(req, res) {
|
||||
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 = {
|
||||
routes: {
|
||||
postCharacter: wrap(handleCreate),
|
||||
getCharacter: wrap(handleGet),
|
||||
postAllocate: wrap(handleAllocate),
|
||||
postRest: wrap(handleRest),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user