From 28210ed56c74887618cd70b2199b359252020395 Mon Sep 17 00:00:00 2001 From: bepis Date: Fri, 23 Oct 2026 01:06:48 +1100 Subject: [PATCH] step 10.9: done --- HANDOFF.md | 29 +++-- STATUS.md | 7 +- public/css/main.css | 101 +++++++++++++++ public/game.html | 9 ++ public/js/game.js | 149 ++++++++++++++++++++++ tests/integration/step-10.9.test.js | 184 ++++++++++++++++++++++++++++ 6 files changed, 464 insertions(+), 15 deletions(-) create mode 100644 tests/integration/step-10.9.test.js diff --git a/HANDOFF.md b/HANDOFF.md index 56c57c2..40674a3 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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). diff --git a/STATUS.md b/STATUS.md index de6daf9..90382e9 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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`. diff --git a/public/css/main.css b/public/css/main.css index 18694a2..3eb398c 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -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; +} diff --git a/public/game.html b/public/game.html index 2c21d7e..3f03a48 100644 --- a/public/game.html +++ b/public/game.html @@ -70,6 +70,15 @@ Crit: % + + diff --git a/public/js/game.js b/public/js/game.js index f4f159c..5e706dc 100644 --- a/public/js/game.js +++ b/public/js/game.js @@ -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 diff --git a/tests/integration/step-10.9.test.js b/tests/integration/step-10.9.test.js new file mode 100644 index 0000000..273441a --- /dev/null +++ b/tests/integration/step-10.9.test.js @@ -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) + }); +});