step 10.9: done

This commit is contained in:
bepis
2026-10-23 01:06:48 +11:00
parent bf0b3867bd
commit 28210ed56c
6 changed files with 464 additions and 15 deletions
+17 -12
View File
@@ -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.8 — POST /api/character/allocate: stat point allocation endpoint with zod validation, service transaction, and 12 integration tests — TICKED in STATUS.md
- **Next step:** Phase 10.9 — Allocation UI: unspent points display with + buttons per stat on dashboard stats panel
- **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
- **Updated:** 2026-10-23
- **Was the last session cut off mid-task?** No
@@ -14,18 +14,18 @@
|---|---|
| `npm install` | succeeds |
| `npm run migrate` | applies 001–006 / "nothing to do" |
| `npm test` | **214 passing, 0 failing** (85 unit + 129 integration incl. 12 allocate tests) |
| `npm test` | **222 passing, 0 failing** (85 unit + 137 integration incl. 8 allocation UI tests) |
| `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear |
| `npm start` + `curl localhost:3000/` | 200 HTML landing page |
| `curl -X POST localhost:3000/api/character/allocate -H 'Content-Type: application/json' -H 'Cookie: ...' -d '{"str":1}'` | 200 with updated stats (requires authenticated session) |
| Open `/game.html` logged in → check dashboard for allocation UI | Shows unspent points with + buttons when > 0, hidden when 0 |
## 3. What exists now (files that matter)
- `src/services/characterService.js` — New `allocatePoints(userId, allocation)` function: validates input, uses `allocatePoints` from `leveling.js`, computes `remainingPoints = character.unspent_points - total`, updates in transaction, returns `{character, derived}`.
- `src/routes/character.js` — New `handleAllocate` handler with zod `.strictObject` schema (optional stat fields + `.refine` requiring at least one).
- `src/game/leveling.js` — `allocatePoints` now accepts non-negative integers (including 0 for unallocated stats).
- `src/app.js` — Route `POST /api/character/allocate` mounted with `requireAuth`.
- `tests/integration/step-10.8.test.js` — 6 HTTP integration tests (spend partial, overspend, negative, float, unknown keys, cross-user).
- `tests/integration/allocate-service.test.js` — 6 direct service tests (same scenarios via service call).
- `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).
## 4. Half-finished / fragile things
- No leaderboard UI yet (stub page exists; real UI in 10.17).
@@ -36,7 +36,7 @@
- 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.9 next:** Allocation UI — show unspent points with + buttons per stat on dashboard.
- **10.10 next:** Rest endpoint — costs level*5 gold, heals to full max HP.
## 5. Decisions & deviations from the docs
- D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock).
@@ -47,6 +47,7 @@
- **D-018:** Link-checker test skips API endpoints (`/api/...`) since they're not static files.
- **D-019:** Rate limiters use `process.env.NODE_ENV === 'test' ? 100 : 100` for test env (relaxed to avoid test interference; prod limit same as test).
- **D-020:** `allocatePoints` in `leveling.js` accepts non-negative integers (0 allowed for unallocated stats) — changed from "positive integer" to support partial allocation via `{str, agi, int, vit}` where unmentioned stats default to 0.
- **D-021:** Allocation UI uses `container.replaceChildren()` instead of `innerHTML = ''` to avoid innerHTML entirely (per NOTES.md constraint).
## 6. Gotchas learned
- `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`.
@@ -91,6 +92,10 @@
- **10.8:** `leveling.js`'s `allocatePoints` always returns `remainingPoints: 0` — the service must compute `character.unspent_points - total` itself.
- **10.8:** zod `.strictObject` with optional fields needs `.refine()` to require at least one field present.
- **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).
## 7. Next-step briefing
- **Next step: 10.9** — Allocation UI: on the dashboard stats panel show unspent points and a `+` button per stat (only when points remain), with a confirm/apply button, loading state, and error toast. Use `textContent`, never `innerHTML` with dynamic data.
- **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).
+4 -3
View File
@@ -8,9 +8,9 @@
## Current Focus
- **Phase:** 10
- **Step:** 10.9 — Allocation UI: unspent points display with + buttons per stat on dashboard stats panel
- **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
- **Blocked?** No
- **Last verified working command:** `npm test` — 214/214 passing (85 unit + 129 integration incl. 12 allocate tests), 2026-10-23 step 10.8
- **Last verified working command:** `npm test` — 222/222 passing (85 unit + 137 integration incl. 8 allocate UI tests), 2026-10-23 step 10.9
## Project Health
| Item | State |
@@ -165,8 +165,9 @@
- [x] **10.8** Add `POST /api/character/allocate` (API.md): body `{str,agi,int,vit}` as points to add (zod `.strict()`, non-negative integers). Use the existing, tested `allocatePoints` from `src/game/leveling.js`. One transaction; reject if the total exceeds `unspent_points` (422 `NOT_ENOUGH_POINTS`); return `{character, derived}`.
*Acceptance:* tests: spend 2 of 3 points on STR → `str` +2, `unspent_points` 1, derived attack increased by the exact expected amount; spending 4 of 3 → 422 and nothing changed; negative / float / unknown keys → 400; another user's character cannot be affected.
*Verified:* 12 integration tests (6 HTTP + 6 direct service) covering partial allocation, overspend rejection, negative/float/unknown validation, cross-user isolation; `allocatePoints` in `characterService.js` uses `allocatePoints` from `leveling.js`, computes `remainingPoints` itself, updates in transaction; 214/214 tests pass.
- [ ] **10.9** Allocation UI: on the dashboard stats panel show unspent points and a `+` button per stat (only when points remain), with a confirm/apply button, loading state, and error toast. Use `textContent`, never `innerHTML` with dynamic data.
- [x] **10.9** Allocation UI: on the dashboard stats panel show unspent points and a `+` button per stat (only when points remain), with a confirm/apply button, loading state, and error toast. Use `textContent`, never `innerHTML` with dynamic data.
*Acceptance:* in the frontend, applying points calls the endpoint and refreshes stats without a page reload. Add a link-checker/DOM-free test only if feasible; otherwise list manual verification steps in HANDOFF.md.
*Verified:* allocation section added to game.html (hidden by default, shown when unspent_points > 0); game.js has buildAllocationRows, handleAllocIncrement, updateAllocState, handleAllocApply, initAllocationUI; + buttons disabled when total reaches unspent; apply button disabled when total is 0 or exceeds unspent; loading state ("Applying...") on apply; success/error toasts; reloads character on success/failure; 8 new integration tests (5 HTML/JS structure + 2 API flow); 222/222 tests pass.
- [ ] **10.10** Add `POST /api/character/rest` (API.md, BALANCE.md §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.
*Acceptance:* tests: HP 10/135 level 3 with gold 100 → HP 135, gold 85; full HP → 422 and gold unchanged; gold 5 → 422; HP unchanged on failure.
- [ ] **10.11** Natural HP regeneration (GAME_DESIGN §Time): +1% of max HP per minute, computed lazily like energy. New migration (next unused number) adds `hp_updated_at INTEGER` (backfill existing rows with `energy_updated_at`). Add a 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 it before the HP check in hunting, in `getCharacterProfile`, in potion use, and in rest. Heals/damage must set `hp_updated_at = now`.
+101
View File
@@ -1022,3 +1022,104 @@ button:hover { background: #c73650; }
background: var(--accent, #e2822b);
color: #fff;
}
/* ===== Stat allocation UI ===== */
#allocation-section {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
}
#allocation-section h4 {
color: #e94560;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.alloc-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.2rem 0;
font-size: 0.8rem;
}
.alloc-label {
color: #a0a0a0;
min-width: 2.5em;
text-transform: uppercase;
}
.alloc-value {
font-weight: 600;
color: #eadfce;
min-width: 1.5em;
text-align: right;
}
.alloc-btn {
width: 24px;
height: 24px;
padding: 0;
margin: 0 0.35rem;
font-size: 1rem;
font-weight: bold;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
background: #0f3460;
border: 1px solid #4a7fb0;
border-radius: 3px;
color: #4a90d9;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.alloc-btn:hover:not(:disabled) {
background: #4a7fb0;
color: #fff;
}
.alloc-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
border-color: #444;
color: #666;
}
#alloc-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #1a1a2e;
}
.alloc-total {
font-size: 0.8rem;
color: #a0a0a0;
}
.btn-alloc-apply {
background: #e94560;
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-alloc-apply:hover:not(:disabled) {
background: #c73650;
}
.btn-alloc-apply:disabled {
background: #444;
color: #888;
cursor: not-allowed;
}
+9
View File
@@ -70,6 +70,15 @@
<span>Crit: <span id="derived-crit"></span>%</span>
</div>
</div>
<!-- Stat allocation UI (shown when unspent points > 0) -->
<div id="allocation-section" style="display:none;">
<h4>Allocate Points (<span id="unspent-points">0</span> left)</h4>
<div id="alloc-rows"></div>
<div id="alloc-actions">
<div id="alloc-total" class="alloc-total">Allocated: 0</div>
<button id="alloc-apply-btn" class="btn-alloc-apply" disabled>Apply</button>
</div>
</div>
</section>
<!-- Center column: Tabs + action area -->
+149
View File
@@ -167,6 +167,9 @@ async function loadCharacter() {
// Update energy bar and start countdown
updateEnergyBar(energy.current, energy.max, energy.nextTickInSec);
startCountdown();
// Initialize allocation UI
initAllocationUI(character.unspent_points);
} catch (err) {
// Not authenticated — redirect to login
window.location.href = '/login.html';
@@ -849,6 +852,146 @@ function startExpeditionSync() {
}, 5000);
}
// ===== Stat allocation UI =====
const STAT_KEYS = ['str', 'agi', 'int', 'vit'];
const STAT_LABELS = { str: 'STR', agi: 'AGI', int: 'INT', vit: 'VIT' };
// Temporary allocation state (what the user has clicked, not yet sent)
let allocTemp = { str: 0, agi: 0, int: 0, vit: 0 };
/**
* Build the allocation rows (one per stat) with + buttons.
*/
function buildAllocationRows() {
const container = document.getElementById('alloc-rows');
if (!container) return;
container.replaceChildren();
for (const key of STAT_KEYS) {
const row = document.createElement('div');
row.className = 'alloc-row';
const label = document.createElement('span');
label.className = 'alloc-label';
label.textContent = STAT_LABELS[key];
const value = document.createElement('span');
value.className = 'alloc-value';
value.id = 'alloc-val-' + key;
value.textContent = '0';
const btn = document.createElement('button');
btn.className = 'alloc-btn';
btn.type = 'button';
btn.textContent = '+';
btn.dataset.stat = key;
btn.addEventListener('click', handleAllocIncrement);
row.appendChild(label);
row.appendChild(value);
row.appendChild(btn);
container.appendChild(row);
}
}
/**
* Handle + button click: increment temp allocation for that stat.
*/
function handleAllocIncrement(e) {
const stat = e.target.dataset.stat;
if (!stat) return;
allocTemp[stat]++;
document.getElementById('alloc-val-' + stat).textContent = allocTemp[stat];
updateAllocState();
}
/**
* Update button states and apply button enable/disable.
*/
function updateAllocState() {
const total = STAT_KEYS.reduce((sum, k) => sum + allocTemp[k], 0);
const totalEl = document.getElementById('alloc-total');
const applyBtn = document.getElementById('alloc-apply-btn');
if (totalEl) totalEl.textContent = 'Allocated: ' + total;
// Enable apply only when at least one point allocated and unspent points remain
const unspent = parseInt(document.getElementById('unspent-points').textContent, 10) || 0;
const canApply = total > 0 && total <= unspent;
if (applyBtn) applyBtn.disabled = !canApply;
// Disable + buttons when total would exceed unspent
for (const key of STAT_KEYS) {
const btn = document.querySelector('.alloc-btn[data-stat="' + key + '"]');
if (btn) {
btn.disabled = (total >= unspent);
}
}
}
/**
* Handle apply button: send allocation to server, refresh stats.
*/
async function handleAllocApply() {
const applyBtn = document.getElementById('alloc-apply-btn');
if (applyBtn.disabled) return;
// Build allocation object (only stats that were incremented)
const allocation = {};
let hasPoints = false;
for (const key of STAT_KEYS) {
if (allocTemp[key] > 0) {
allocation[key] = allocTemp[key];
hasPoints = true;
}
}
if (!hasPoints) return;
applyBtn.disabled = true;
applyBtn.textContent = 'Applying...';
try {
const data = await api.post('/character/allocate', allocation);
if (!data) {
showToast('Allocation failed.', 'error');
return;
}
showToast('Stats allocated!', 'success');
// Reset temp allocation
allocTemp = { str: 0, agi: 0, int: 0, vit: 0 };
// Reload character to get updated stats
await loadCharacter();
} catch (err) {
showToast(err.message || 'Allocation failed.', 'error');
// Reload to restore state
await loadCharacter();
}
}
/**
* Initialize or refresh the allocation UI based on current character data.
*/
function initAllocationUI(unspentPoints) {
const section = document.getElementById('allocation-section');
if (!section) return;
if (unspentPoints > 0) {
section.style.display = 'block';
document.getElementById('unspent-points').textContent = unspentPoints;
buildAllocationRows();
updateAllocState();
} else {
section.style.display = 'none';
allocTemp = { str: 0, agi: 0, int: 0, vit: 0 };
}
}
// ===== Init =====
document.addEventListener('DOMContentLoaded', async () => {
await loadCharacter();
@@ -861,6 +1004,12 @@ document.addEventListener('DOMContentLoaded', async () => {
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
// Allocation apply button
const applyBtn = document.getElementById('alloc-apply-btn');
if (applyBtn) {
applyBtn.addEventListener('click', handleAllocApply);
}
});
// Logout handler
+184
View File
@@ -0,0 +1,184 @@
// Step 10.9 — Allocation UI tests
// Verifies: allocation UI elements exist in HTML, JS uses textContent,
// allocation flow via API works end-to-end.
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { setup, teardown, cleanup } = require('../helpers');
describe('Step 10.9 — Allocation UI', () => {
let baseUrl;
let loginCookie;
before(async () => {
baseUrl = await setup();
});
after(async () => {
const { resetNow } = require('../../src/utils/time');
resetNow();
await teardown();
cleanup();
});
// ===== HTML/JS structure tests =====
it('game.html contains allocation section with all required elements', () => {
const html = fs.readFileSync(path.resolve(__dirname, '../../public/game.html'), 'utf8');
assert.ok(html.includes('id="allocation-section"'),
'game.html must contain allocation-section div');
assert.ok(html.includes('id="unspent-points"'),
'game.html must contain unspent-points span');
assert.ok(html.includes('id="alloc-rows"'),
'game.html must contain alloc-rows div');
assert.ok(html.includes('id="alloc-apply-btn"'),
'game.html must contain alloc-apply-btn button');
assert.ok(html.includes('id="alloc-total"'),
'game.html must contain alloc-total div');
});
it('allocation section is hidden by default', () => {
const html = fs.readFileSync(path.resolve(__dirname, '../../public/game.html'), 'utf8');
assert.ok(html.includes('id="allocation-section" style="display:none;"'),
'allocation section must be hidden by default');
});
it('game.js defines all allocation functions', () => {
const js = fs.readFileSync(path.resolve(__dirname, '../../public/js/game.js'), 'utf8');
assert.ok(js.includes('function buildAllocationRows'),
'game.js must define buildAllocationRows');
assert.ok(js.includes('function handleAllocIncrement'),
'game.js must define handleAllocIncrement');
assert.ok(js.includes('function updateAllocState'),
'game.js must define updateAllocState');
assert.ok(js.includes('function handleAllocApply'),
'game.js must define handleAllocApply');
assert.ok(js.includes('function initAllocationUI'),
'game.js must define initAllocationUI');
});
it('allocation UI uses textContent, not innerHTML, for dynamic data', () => {
const js = fs.readFileSync(path.resolve(__dirname, '../../public/js/game.js'), 'utf8');
const start = js.indexOf('// ===== Stat allocation UI =====');
const end = js.indexOf('// ===== Init =====');
const allocCode = js.substring(start, end);
assert.ok(!allocCode.includes('innerHTML = '),
'Allocation UI must not use innerHTML with dynamic data');
assert.ok(allocCode.includes('textContent'),
'Allocation UI must use textContent for values');
});
it('loadCharacter calls initAllocationUI with unspent_points', () => {
const js = fs.readFileSync(path.resolve(__dirname, '../../public/js/game.js'), 'utf8');
assert.ok(js.includes('initAllocationUI(character.unspent_points)'),
'loadCharacter must call initAllocationUI with unspent_points');
});
it('init attaches handleAllocApply to the apply button', () => {
const js = fs.readFileSync(path.resolve(__dirname, '../../public/js/game.js'), 'utf8');
assert.ok(js.includes('handleAllocApply'),
'init must attach handleAllocApply to apply button');
});
// ===== Allocation flow via HTTP =====
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 allocate(cookie, body) {
return fetch(`${baseUrl}/api/character/allocate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify(body),
});
}
function getCharacter(cookie) {
return fetch(`${baseUrl}/api/character`, {
method: 'GET',
headers: { Cookie: cookie },
});
}
it('full allocation flow: grant points → allocate → verify stats updated', async () => {
const res = await registerAndLogin('ui109');
loginCookie = res.headers.get('set-cookie');
await createHero(loginCookie, 'UI109Hero');
// Grant 5 unspent points via direct DB update
const db = require('../../src/db').getDb();
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('ui109').id);
const char = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId);
db.prepare('UPDATE characters SET unspent_points = 5 WHERE id = ?').run(char.id);
// Verify initial state
const charRes = await getCharacter(loginCookie);
assert.equal(charRes.status, 200);
const charData = await charRes.json();
assert.equal(charData.data.character.unspent_points, 5);
const baseStr = charData.data.character.str;
const baseMaxHp = charData.data.derived.maxHp;
// Allocate 2 STR + 1 VIT = 3 points
const allocRes = await allocate(loginCookie, { str: 2, vit: 1 });
assert.equal(allocRes.status, 200);
const allocData = await allocRes.json();
assert.equal(allocData.ok, true);
// Verify stats updated
assert.equal(allocData.data.character.str, baseStr + 2);
assert.equal(allocData.data.character.unspent_points, 2); // 5 - 3 = 2
// Warrior maxHp = 50 + vit*10; +1 VIT = +10 maxHp
assert.equal(allocData.data.derived.maxHp, baseMaxHp + 10);
// Single-page flow: no page reload needed, server returns full updated profile
assert.ok(allocData.data.character, 'response includes updated character');
assert.ok(allocData.data.derived, 'response includes updated derived stats');
});
it('allocation UI hides when no points remain (allocate all → verify 0 unspent)', async () => {
const res = await registerAndLogin('ui109b');
loginCookie = res.headers.get('set-cookie');
await createHero(loginCookie, 'UI109B');
// Grant 3 points
const db = require('../../src/db').getDb();
const userId = Number(db.prepare('SELECT id FROM users WHERE username = ?').get('ui109b').id);
const char = db.prepare('SELECT id FROM characters WHERE user_id = ?').get(userId);
db.prepare('UPDATE characters SET unspent_points = 3 WHERE id = ?').run(char.id);
// Allocate all 3 points
const allocRes = await allocate(loginCookie, { str: 3 });
assert.equal(allocRes.status, 200);
// Verify 0 unspent points remain
const charRes = await getCharacter(loginCookie);
assert.equal(charRes.status, 200);
const charData = await charRes.json();
assert.equal(charData.data.character.unspent_points, 0);
// initAllocationUI(0) → section.style.display = 'none'
// (verified by code inspection; the section is hidden when unspentPoints is 0)
});
});