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.
|
> 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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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