step 4.5: done
This commit is contained in:
+25
-15
@@ -4,47 +4,57 @@
|
|||||||
> 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:** 4.4 (hunt endpoint) + a full review/fix pass
|
- **Last completed step:** 4.5 (hunt UI — zones endpoint, combat-log endpoint, zone cards, hunt button, result panel, combat log replay)
|
||||||
- **Next step:** 4.5 — hunt UI (needs `GET /api/zones` first, see STATUS issue 2)
|
- **Next step:** 5.1 — inventory migration (`004_inventory.sql`)
|
||||||
- **Updated:** 2026-10-20
|
- **Updated:** 2026-10-20
|
||||||
- **Was the last session cut off mid-task?** No
|
- **Was the last session cut off mid-task?** No
|
||||||
|
|
||||||
## 2. Verified state (run these first)
|
## 2. Verified state (run these first)
|
||||||
| Command | Expected |
|
| Command | Expected |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `npm install` | succeeds (better-sqlite3 is a native module; needs Node 20+ and a C++ toolchain if no prebuilt binary) |
|
| `npm install` | succeeds |
|
||||||
| `npm run migrate` | applies 001–004 and 006 / "nothing to do" |
|
| `npm run migrate` | applies 001–004 and 006 / "nothing to do" |
|
||||||
| `npm test` | **93 passing, 0 failing** (69 unit + 24 integration) |
|
| `npm test` | **98 passing, 0 failing** (74 unit + 29 integration) |
|
||||||
|
|
||||||
## 3. What exists now (files that matter)
|
## 3. What exists now (files that matter)
|
||||||
- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp (implemented in `leveling.js`).
|
- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp (implemented in `leveling.js`).
|
||||||
- `src/game/combat.js` — `resolveCombat(hero, monster, rng)`; both sides attack each round, faster first.
|
- `src/game/combat.js` — `resolveCombat(hero, monster, rng)`; both sides attack each round, faster first.
|
||||||
- `src/services/huntService.js` — `hunt(characterId, zoneId, {rng, now})`; ONE transaction; test-injectable.
|
- `src/services/huntService.js` — `hunt(characterId, zoneId, {rng, now})`; ONE transaction; test-injectable.
|
||||||
|
- `src/services/zoneService.js` — `listZones(characterLevel)` → zones with `unlocked` flag.
|
||||||
|
- `src/services/combatLogService.js` — `getCombatLog(characterId, limit)` → recent fights.
|
||||||
- `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`.
|
- `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`.
|
||||||
- `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services.
|
- `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services.
|
||||||
- `src/middleware/session.js` — real SQLite session store (timer unref'd).
|
- `src/routes/zones.js` — `GET /api/zones`.
|
||||||
- `src/db/migrations/003_content.sql` — also SEEDS zones/monsters/items/loot (INSERT OR IGNORE). Changing balance = NEW migration, never edit 003.
|
- `src/routes/combatLog.js` — `GET /api/combat-log`.
|
||||||
- `tests/helpers.js` — `setup()/teardown()/cleanup()`: fresh app + temp DB per call. `tests/integration/hunt-service.test.js` shows how to force outcomes with test-only zones.
|
- `src/db/migrations/003_content.sql` — also SEEDS zones/monsters/items/loot (INSERT OR IGNORE).
|
||||||
|
- `tests/integration/zones-log.test.js` — 5 integration tests for zones + combat-log endpoints.
|
||||||
|
- `public/game.html` — dashboard with stats panel, zone list, combat log, result panel.
|
||||||
|
- `public/js/game.js` — full hunt UI: load zones, hunt handler, result display, combat log replay, toast notifications, energy sync.
|
||||||
|
- `public/css/main.css` — zone cards, combat log entries, result panel, toast styles.
|
||||||
|
|
||||||
## 4. Half-finished / fragile things
|
## 4. Half-finished / fragile things
|
||||||
- No `GET /api/zones` yet; `public/js/game.js` has no hunt UI yet (4.5).
|
- No inventory/equipment system yet (Phase 5).
|
||||||
- HP never regenerates naturally; only level-up restores it (Rest/potions in Phases 5–6).
|
- HP never regenerates naturally; only level-up restores it.
|
||||||
- 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`.
|
||||||
|
|
||||||
## 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).
|
||||||
|
- Zod query param parsing uses `zod.coerce.number()` because Express query params are strings.
|
||||||
|
|
||||||
## 6. Gotchas learned
|
## 6. Gotchas learned
|
||||||
- `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`. A shell glob once skipped 17 tests.
|
- `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`. A shell glob once skipped 17 tests.
|
||||||
- better-sqlite3-session-store ignores `clear:false`; that's why the timer is unref'd.
|
- better-sqlite3-session-store ignores `clear:false`; that's why the timer is unref'd.
|
||||||
- Statement caches are per-DB-handle (WeakMap) in huntService; copy that pattern for new services.
|
- Statement caches are per-DB-handle (WeakMap) in huntService; copy that pattern for new services.
|
||||||
- Verify test COUNT, not just "all passing".
|
- Verify test COUNT, not just "all passing".
|
||||||
|
- Node.js `fetch` doesn't persist cookies across calls — extract `Set-Cookie` header and pass manually in integration tests.
|
||||||
|
- Express query params are strings, not numbers — use `zod.coerce.number()` for numeric query params.
|
||||||
|
|
||||||
## 7. Next-step briefing (4.5)
|
## 7. Next-step briefing (5.1)
|
||||||
- **Goal:** zone list + Hunt button + combat log replay + result summary + level-up message on the dashboard.
|
- **Goal:** Create `004_inventory.sql` migration (inventory table + starting items for new heroes).
|
||||||
- **Docs:** API.md §Hunting, UI_GUIDE.md §game.html, GAME_DESIGN.md §Combat.
|
- **Docs:** DATABASE.md, GAME_DESIGN.md §Items.
|
||||||
- **Files:** add `src/routes/zones.js` (+ service fn) and mount in app.js; extend `public/game.html` + `public/js/game.js`; add an integration test for `GET /api/zones`.
|
- **Files:** `src/db/migrations/004_inventory.sql`.
|
||||||
- **Watch out for:** use `textContent` (no innerHTML); show energy cost on the button; disable button while request is in flight; re-sync energy from the hunt response (`data.energy`, `data.hpRemaining`, `data.hpMax`).
|
- **Schema:** `inventory` table (`id, character_id, item_id, quantity, equipped_slot NULL, acquired_at`), FK constraints. Starting items: class-appropriate common weapon (auto-equipped) + 2 Minor Healing Potions on character creation.
|
||||||
|
|
||||||
## 8. Open Known Issues
|
## 8. Open Known Issues
|
||||||
STATUS.md issues 1–6 (balance, zones endpoint, dangling `seed` script, restart test, equipment-in-combat, HP regen).
|
STATUS.md issues 1–6 (balance, dangling `seed` script, restart test, equipment-in-combat, HP regen, zone min_level balance).
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
> - Keep Session Log entries to one or two lines.
|
> - Keep Session Log entries to one or two lines.
|
||||||
|
|
||||||
## Current Focus
|
## Current Focus
|
||||||
- **Phase:** 4
|
- **Phase:** 5
|
||||||
- **Step:** 4.5
|
- **Step:** 5.1
|
||||||
- **Blocked?** No
|
- **Blocked?** No
|
||||||
- **Last verified working command:** `npm test` — 93/93 passing (69 unit + 24 integration), 2026-10-20 review/fix pass
|
- **Last verified working command:** `npm test` — 98/98 passing (74 unit + 29 integration), 2026-10-20 step 4.5
|
||||||
|
|
||||||
## Project Health
|
## Project Health
|
||||||
| Item | State |
|
| Item | State |
|
||||||
@@ -66,8 +66,9 @@
|
|||||||
*Acceptance:* 23 unit tests pass; xpToNext matches BALANCE.md; level-up handles multi-level; points allocation validated.
|
*Acceptance:* 23 unit tests pass; xpToNext matches BALANCE.md; level-up handles multi-level; points allocation validated.
|
||||||
- [x] **4.4** `POST /api/hunt { zoneId }`: spend energy → pick monster → resolve → apply rewards/penalty → write `combat_log` → return summary + round log. All inside one DB transaction.
|
- [x] **4.4** `POST /api/hunt { zoneId }`: spend energy → pick monster → resolve → apply rewards/penalty → write `combat_log` → return summary + round log. All inside one DB transaction.
|
||||||
*Acceptance:* hunt returns winner, rounds, xpGained, goldGained, loot, leveledUp, zone, monster. *Verified:* 9 service tests (`tests/integration/hunt-service.test.js`) cover persistence, level-up, defeat, locked zone, energy, stacking.
|
*Acceptance:* hunt returns winner, rounds, xpGained, goldGained, loot, leveledUp, zone, monster. *Verified:* 9 service tests (`tests/integration/hunt-service.test.js`) cover persistence, level-up, defeat, locked zone, energy, stacking.
|
||||||
- [ ] **4.5** UI: zone list, "Hunt" button, combat log display, result summary, level-up message.
|
- [x] **4.5** UI: zone list, "Hunt" button, combat log display, result summary, level-up message.
|
||||||
*Acceptance:* hunting repeatedly drains energy, awards XP/gold, levels the hero, and persists.
|
*Acceptance:* hunting repeatedly drains energy, awards XP/gold, levels the hero, and persists.
|
||||||
|
*Verified:* `GET /api/zones` + `GET /api/combat-log` endpoints; zone cards with locked/unlocked states; Hunt button with energy cost display; result panel showing victory/defeat, XP, gold, loot, level-up; combat log replay; energy re-sync after hunt. 98/98 tests pass (5 new integration tests).
|
||||||
|
|
||||||
## Phase 5 — Inventory & Equipment (docs: GAME_DESIGN §Items, DATABASE)
|
## Phase 5 — Inventory & Equipment (docs: GAME_DESIGN §Items, DATABASE)
|
||||||
- [ ] **5.1** Migration `004_inventory.sql`.
|
- [ ] **5.1** Migration `004_inventory.sql`.
|
||||||
@@ -143,6 +144,7 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing`
|
|||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
_Format:_ `YYYY-MM-DD | step | what changed | result`
|
_Format:_ `YYYY-MM-DD | step | what changed | result`
|
||||||
|
- 2026-10-20 | 4.5 | Added `GET /api/zones` (zoneService + zones route), `GET /api/combat-log` (combatLogService + combatLog route); extended game.html with zone list/combat log/result panels; rewrote game.js with hunt UI (zone cards, hunt button, result display, combat log replay, toast notifications); added CSS for zone cards/combat log/toasts; 5 new integration tests | 98/98 tests pass
|
||||||
- 2026-10-20 | review | Fixed `npm test` (ran 62 of 79 tests; auth/combat/health were skipped by an unquoted glob). Rewrote combat (slower side never attacked; mage bonus dead; hpRemaining wrong on loss). Rewrote huntService (XP not deducted on level-up, unspent points overwritten, deaths never counted, min_level ignored, stackable loot duplicated, duplicate stat formulas). Fixed nextTickInSec (was time-to-full). Added utils/time.js, WeakMap stmt cache, session store unref, ok:false error envelope, removed connect-sqlite3 | 93/93 tests pass; smoke test OK
|
- 2026-10-20 | review | Fixed `npm test` (ran 62 of 79 tests; auth/combat/health were skipped by an unquoted glob). Rewrote combat (slower side never attacked; mage bonus dead; hpRemaining wrong on loss). Rewrote huntService (XP not deducted on level-up, unspent points overwritten, deaths never counted, min_level ignored, stackable loot duplicated, duplicate stat formulas). Fixed nextTickInSec (was time-to-full). Added utils/time.js, WeakMap stmt cache, session store unref, ok:false error envelope, removed connect-sqlite3 | 93/93 tests pass; smoke test OK
|
||||||
- 2026-10-20 | 4.4 | Created 004_inventory.sql + 006_logs.sql migrations; fixed huntService transaction (db.transaction() returns wrapper, must call separately); added huntService to helpers.js cache invalidation; session middleware uses memory store in tests | 62/62 tests pass, hunt endpoint working
|
- 2026-10-20 | 4.4 | Created 004_inventory.sql + 006_logs.sql migrations; fixed huntService transaction (db.transaction() returns wrapper, must call separately); added huntService to helpers.js cache invalidation; session middleware uses memory store in tests | 62/62 tests pass, hunt endpoint working
|
||||||
- 2026-10-20 | 3.3 | Fixed game.js countdown: separated updateEnergyBar (pure display) from startCountdown (client-side timer), added energyData store and startEnergySync (60s server refetch) | 35/35 tests pass, energy bar + countdown working correctly
|
- 2026-10-20 | 3.3 | Fixed game.js countdown: separated updateEnergyBar (pure display) from startCountdown (client-side timer), added energyData store and startEnergySync (60s server refetch) | 35/35 tests pass, energy bar + countdown working correctly
|
||||||
|
|||||||
@@ -174,3 +174,147 @@ button:hover { background: #c73650; }
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Zone cards ===== */
|
||||||
|
.zone-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-card {
|
||||||
|
background: #0f3460;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-card .zone-name {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #e94560;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-card .zone-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #a0a0a0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-card .zone-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-card .zone-locked-msg {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hunt {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: #e94560;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hunt:hover:not(:disabled) {
|
||||||
|
background: #c73650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hunt:disabled {
|
||||||
|
background: #444;
|
||||||
|
color: #888;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Combat log ===== */
|
||||||
|
.combat-log-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: #0f3460;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-result {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-width: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-result.win {
|
||||||
|
color: #4f9d69;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-result.loss {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-text {
|
||||||
|
color: #eadfce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Result panel ===== */
|
||||||
|
.result-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-panel h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-panel p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
color: #eadfce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Toast notifications ===== */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #fff;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: toastIn 0.3s ease;
|
||||||
|
max-width: 90vw;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-info {
|
||||||
|
background: #4a7fb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateX(-50%) translateY(1rem); }
|
||||||
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
}
|
||||||
|
|||||||
+18
-2
@@ -74,8 +74,24 @@
|
|||||||
|
|
||||||
<!-- Action panel -->
|
<!-- Action panel -->
|
||||||
<section class="panel action-panel" id="action-panel">
|
<section class="panel action-panel" id="action-panel">
|
||||||
<h3>Actions</h3>
|
<h3>Hunt</h3>
|
||||||
<p class="placeholder">Hunting and other actions will appear here in the next phase.</p>
|
<div id="zone-list" class="zone-list">
|
||||||
|
<p class="loading">Loading zones...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Combat log panel -->
|
||||||
|
<section class="panel combat-panel" id="combat-panel">
|
||||||
|
<h3>Combat Log</h3>
|
||||||
|
<div id="combat-log-list" class="combat-log-list">
|
||||||
|
<p class="loading">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Result panel -->
|
||||||
|
<section class="panel result-panel" id="result-panel" style="display:none;">
|
||||||
|
<h3 id="result-title"></h3>
|
||||||
|
<div id="result-body"></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+256
-5
@@ -1,7 +1,8 @@
|
|||||||
// Game dashboard: load character profile and populate stats.
|
// Game dashboard: load character profile, zones, hunt, and combat log.
|
||||||
|
|
||||||
let energyInterval = null;
|
let energyInterval = null;
|
||||||
let energyData = null; // store latest server energy data for sync
|
let energyData = null; // store latest server energy data for sync
|
||||||
|
let isHunting = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh energy state from the server at a fixed interval.
|
* Refresh energy state from the server at a fixed interval.
|
||||||
@@ -98,7 +99,7 @@ async function loadCharacter() {
|
|||||||
document.getElementById('char-name').textContent = character.name;
|
document.getElementById('char-name').textContent = character.name;
|
||||||
document.getElementById('char-class').textContent = character.class.charAt(0).toUpperCase() + character.class.slice(1);
|
document.getElementById('char-class').textContent = character.class.charAt(0).toUpperCase() + character.class.slice(1);
|
||||||
document.getElementById('char-level').textContent = character.level;
|
document.getElementById('char-level').textContent = character.level;
|
||||||
document.getElementById('char-hp').textContent = `${character.hp_current} / ${derived.maxHp}`;
|
document.getElementById('char-hp').textContent = `${character.hp_current} / ${character.maxHp}`;
|
||||||
document.getElementById('char-energy').textContent = `${energy.current} / ${energy.max}`;
|
document.getElementById('char-energy').textContent = `${energy.current} / ${energy.max}`;
|
||||||
document.getElementById('char-gold').textContent = character.gold;
|
document.getElementById('char-gold').textContent = character.gold;
|
||||||
document.getElementById('char-xp').textContent = `${character.xp} / ${xpToNext(character.level)}`;
|
document.getElementById('char-xp').textContent = `${character.xp} / ${xpToNext(character.level)}`;
|
||||||
@@ -124,8 +125,260 @@ function xpToNext(level) {
|
|||||||
return Math.floor(100 * Math.pow(level, 1.5));
|
return Math.floor(100 * Math.pow(level, 1.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start periodic energy sync from server (every 60s)
|
// ===== Zone list =====
|
||||||
|
async function loadZones() {
|
||||||
|
const zoneList = document.getElementById('zone-list');
|
||||||
|
try {
|
||||||
|
const data = await api.get('/zones');
|
||||||
|
if (!data || !data.ok) {
|
||||||
|
zoneList.innerHTML = '';
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'placeholder';
|
||||||
|
p.textContent = 'Failed to load zones.';
|
||||||
|
zoneList.appendChild(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zones = data.data;
|
||||||
|
zoneList.innerHTML = '';
|
||||||
|
|
||||||
|
for (const zone of zones) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'zone-card';
|
||||||
|
|
||||||
|
const zoneName = document.createElement('div');
|
||||||
|
zoneName.className = 'zone-name';
|
||||||
|
zoneName.textContent = zone.name;
|
||||||
|
|
||||||
|
const zoneDesc = document.createElement('div');
|
||||||
|
zoneDesc.className = 'zone-desc';
|
||||||
|
zoneDesc.textContent = zone.description;
|
||||||
|
|
||||||
|
const zoneMeta = document.createElement('div');
|
||||||
|
zoneMeta.className = 'zone-meta';
|
||||||
|
zoneMeta.textContent = `Lv ${zone.min_level}+ · ${zone.energy_cost}⚡`;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'btn btn-hunt';
|
||||||
|
btn.textContent = `Hunt (${zone.energy_cost}⚡)`;
|
||||||
|
btn.disabled = !zone.unlocked;
|
||||||
|
btn.dataset.zoneId = zone.id;
|
||||||
|
|
||||||
|
if (!zone.unlocked) {
|
||||||
|
const lockMsg = document.createElement('div');
|
||||||
|
lockMsg.className = 'zone-locked-msg';
|
||||||
|
lockMsg.textContent = `Reach level ${zone.min_level}`;
|
||||||
|
card.appendChild(zoneName);
|
||||||
|
card.appendChild(lockMsg);
|
||||||
|
} else {
|
||||||
|
card.appendChild(zoneName);
|
||||||
|
card.appendChild(zoneDesc);
|
||||||
|
card.appendChild(zoneMeta);
|
||||||
|
card.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneList.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach hunt handlers
|
||||||
|
zoneList.querySelectorAll('.btn-hunt').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', handleHunt);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
zoneList.innerHTML = '';
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'placeholder';
|
||||||
|
p.textContent = 'Failed to load zones.';
|
||||||
|
zoneList.appendChild(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Hunt =====
|
||||||
|
async function handleHunt(e) {
|
||||||
|
if (isHunting) return;
|
||||||
|
const btn = e.target;
|
||||||
|
const zoneId = Number(btn.dataset.zoneId);
|
||||||
|
if (isNaN(zoneId)) return;
|
||||||
|
|
||||||
|
isHunting = true;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Hunting...';
|
||||||
|
|
||||||
|
// Hide previous result
|
||||||
|
document.getElementById('result-panel').style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.post('/hunt', { zoneId });
|
||||||
|
if (!data || !data.ok) {
|
||||||
|
showToast(data?.error?.message || 'Hunt failed.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = data.data;
|
||||||
|
displayHuntResult(result);
|
||||||
|
loadCharacter();
|
||||||
|
loadCombatLog();
|
||||||
|
loadZones(); // refresh energy cost buttons
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Hunt failed.', 'error');
|
||||||
|
} finally {
|
||||||
|
isHunting = false;
|
||||||
|
// Re-enable all hunt buttons
|
||||||
|
document.querySelectorAll('.btn-hunt').forEach((b) => {
|
||||||
|
b.disabled = false;
|
||||||
|
// Restore text from zone card labels
|
||||||
|
const zoneCard = b.closest('.zone-card');
|
||||||
|
if (zoneCard) {
|
||||||
|
const meta = zoneCard.querySelector('.zone-meta');
|
||||||
|
if (meta) {
|
||||||
|
const cost = meta.textContent.match(/(\d+)⚡/);
|
||||||
|
if (cost) b.textContent = `Hunt (${cost[1]}⚡)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Display hunt result =====
|
||||||
|
function displayHuntResult(result) {
|
||||||
|
const panel = document.getElementById('result-panel');
|
||||||
|
const title = document.getElementById('result-title');
|
||||||
|
const body = document.getElementById('result-body');
|
||||||
|
|
||||||
|
panel.style.display = 'block';
|
||||||
|
|
||||||
|
const won = result.winner === 'hero';
|
||||||
|
title.textContent = won ? 'Victory!' : 'Defeat';
|
||||||
|
title.style.color = won ? '' : '';
|
||||||
|
|
||||||
|
body.innerHTML = '';
|
||||||
|
|
||||||
|
// Monster info
|
||||||
|
const monsterLine = document.createElement('p');
|
||||||
|
monsterLine.textContent = `Fought: ${result.monster.name} (Lv.${result.monster.level})`;
|
||||||
|
body.appendChild(monsterLine);
|
||||||
|
|
||||||
|
// Rewards
|
||||||
|
const rewards = document.createElement('p');
|
||||||
|
rewards.textContent = [];
|
||||||
|
const parts = [];
|
||||||
|
if (result.xpGained) parts.push(`+${result.xpGained} XP`);
|
||||||
|
if (result.goldGained) parts.push(`+${result.goldGained} Gold`);
|
||||||
|
if (parts.length) rewards.textContent = parts.join(', ');
|
||||||
|
if (result.loot && result.loot.length > 0) {
|
||||||
|
const lootParts = result.loot.map((l) => `${l.name}${l.quantity > 1 ? `x${l.quantity}` : ''}`);
|
||||||
|
const lootLine = document.createElement('p');
|
||||||
|
lootLine.textContent = `Loot: ${lootParts.join(', ')}`;
|
||||||
|
body.appendChild(lootLine);
|
||||||
|
}
|
||||||
|
body.appendChild(rewards);
|
||||||
|
|
||||||
|
// Level up
|
||||||
|
if (result.leveledUp) {
|
||||||
|
const lvLine = document.createElement('p');
|
||||||
|
lvLine.style.fontWeight = 'bold';
|
||||||
|
lvLine.textContent = `⬆ Level up! Now level ${result.newLevel}`;
|
||||||
|
body.appendChild(lvLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HP remaining
|
||||||
|
const hpLine = document.createElement('p');
|
||||||
|
hpLine.textContent = `HP: ${result.hpRemaining} / ${result.hpMax}`;
|
||||||
|
body.appendChild(hpLine);
|
||||||
|
|
||||||
|
// Energy remaining
|
||||||
|
const energyLine = document.createElement('p');
|
||||||
|
energyLine.textContent = `Energy: ${result.energy}`;
|
||||||
|
body.appendChild(energyLine);
|
||||||
|
|
||||||
|
// Gold lost on defeat
|
||||||
|
if (result.goldLost > 0) {
|
||||||
|
const lostLine = document.createElement('p');
|
||||||
|
lostLine.style.color = '#c0392b';
|
||||||
|
lostLine.textContent = `Lost ${result.goldLost} gold to defeat.`;
|
||||||
|
body.appendChild(lostLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Combat log =====
|
||||||
|
async function loadCombatLog() {
|
||||||
|
const logList = document.getElementById('combat-log-list');
|
||||||
|
try {
|
||||||
|
const data = await api.get('/combat-log?limit=10');
|
||||||
|
if (!data || !data.ok) {
|
||||||
|
logList.innerHTML = '';
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'placeholder';
|
||||||
|
p.textContent = 'No combat history.';
|
||||||
|
logList.appendChild(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = data.data;
|
||||||
|
logList.innerHTML = '';
|
||||||
|
|
||||||
|
if (logs.length === 0) {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'placeholder';
|
||||||
|
p.textContent = 'No combat history.';
|
||||||
|
logList.appendChild(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const log of logs) {
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'log-entry';
|
||||||
|
|
||||||
|
const resultTag = document.createElement('span');
|
||||||
|
resultTag.className = `log-result ${log.won ? 'win' : 'loss'}`;
|
||||||
|
resultTag.textContent = log.won ? '✓' : '✗';
|
||||||
|
|
||||||
|
const logText = document.createElement('span');
|
||||||
|
logText.className = 'log-text';
|
||||||
|
const parts = [`${log.monsterName} (Lv.${log.monsterLevel})`];
|
||||||
|
if (log.won) {
|
||||||
|
if (log.xpGained) parts.push(`+${log.xpGained} XP`);
|
||||||
|
if (log.goldGained) parts.push(`+${log.goldGained} G`);
|
||||||
|
} else {
|
||||||
|
parts.push('defeated');
|
||||||
|
}
|
||||||
|
logText.textContent = parts.join(' · ');
|
||||||
|
|
||||||
|
entry.appendChild(resultTag);
|
||||||
|
entry.appendChild(logText);
|
||||||
|
logList.appendChild(entry);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
logList.innerHTML = '';
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'placeholder';
|
||||||
|
p.textContent = 'Failed to load combat log.';
|
||||||
|
logList.appendChild(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Toast notifications =====
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
// Remove existing toasts
|
||||||
|
document.querySelectorAll('.toast').forEach((t) => t.remove());
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Init =====
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await loadCharacter();
|
||||||
|
await loadZones();
|
||||||
|
await loadCombatLog();
|
||||||
startEnergySync();
|
startEnergySync();
|
||||||
|
});
|
||||||
|
|
||||||
// Logout handler
|
// Logout handler
|
||||||
document.getElementById('logout-btn')?.addEventListener('click', async (e) => {
|
document.getElementById('logout-btn')?.addEventListener('click', async (e) => {
|
||||||
@@ -135,5 +388,3 @@ document.getElementById('logout-btn')?.addEventListener('click', async (e) => {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
window.location.href = '/login.html';
|
window.location.href = '/login.html';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadCharacter);
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const { createSessionMiddleware, requireAuth } = require('./middleware/session')
|
|||||||
const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth');
|
const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth');
|
||||||
const { routes: characterRoutes } = require('./routes/character');
|
const { routes: characterRoutes } = require('./routes/character');
|
||||||
const { routes: huntRoutes } = require('./routes/hunt');
|
const { routes: huntRoutes } = require('./routes/hunt');
|
||||||
|
const { routes: zonesRoutes } = require('./routes/zones');
|
||||||
|
const { routes: combatLogRoutes } = require('./routes/combatLog');
|
||||||
const { AppError } = require('./utils/errors');
|
const { AppError } = require('./utils/errors');
|
||||||
|
|
||||||
function createApp() {
|
function createApp() {
|
||||||
@@ -44,6 +46,12 @@ function createApp() {
|
|||||||
// Hunt routes (require session)
|
// Hunt routes (require session)
|
||||||
app.post('/api/hunt', requireAuth, huntRoutes.postHunt);
|
app.post('/api/hunt', requireAuth, huntRoutes.postHunt);
|
||||||
|
|
||||||
|
// Zone routes (require session)
|
||||||
|
app.get('/api/zones', requireAuth, zonesRoutes.getZones);
|
||||||
|
|
||||||
|
// Combat log routes (require session)
|
||||||
|
app.get('/api/combat-log', requireAuth, combatLogRoutes.getCombatLog);
|
||||||
|
|
||||||
// Central error handler
|
// Central error handler
|
||||||
app.use((err, _req, res, _next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
// Log stack traces server-side only
|
// Log stack traces server-side only
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Combat log routes: GET /api/combat-log (recent fights for the logged-in user's character).
|
||||||
|
const zod = require('zod');
|
||||||
|
const { getCombatLog } = require('../services/combatLogService');
|
||||||
|
const { requireAuth } = require('../middleware/session');
|
||||||
|
const { getCharacterIdForUser } = require('../services/characterService');
|
||||||
|
|
||||||
|
function wrap(fn) {
|
||||||
|
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logSchema = zod.object({
|
||||||
|
limit: zod.coerce.number().int().min(1).max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleGetCombatLog(req, res) {
|
||||||
|
const parsed = logSchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const { AppError } = require('../utils/errors');
|
||||||
|
throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterId = getCharacterIdForUser(req.session.userId);
|
||||||
|
const limit = parsed.data.limit ?? 20;
|
||||||
|
const logs = getCombatLog(characterId, limit);
|
||||||
|
res.json({ ok: true, data: logs });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
routes: {
|
||||||
|
getCombatLog: wrap(handleGetCombatLog),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Zone routes: GET /api/zones (list zones with unlocked flag).
|
||||||
|
const zod = require('zod');
|
||||||
|
const { listZones } = require('../services/zoneService');
|
||||||
|
const { requireAuth } = require('../middleware/session');
|
||||||
|
const { getCharacterProfile } = require('../services/characterService');
|
||||||
|
|
||||||
|
function wrap(fn) {
|
||||||
|
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetZones(req, res) {
|
||||||
|
const profile = getCharacterProfile(req.session.userId);
|
||||||
|
const zones = listZones(profile.character.level);
|
||||||
|
res.json({ ok: true, data: zones });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
routes: {
|
||||||
|
getZones: wrap(handleGetZones),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Combat log service: retrieve recent combat log entries for a character.
|
||||||
|
const { getDb } = require('../db');
|
||||||
|
|
||||||
|
const stmtCache = new WeakMap();
|
||||||
|
|
||||||
|
function getStmts(db) {
|
||||||
|
let s = stmtCache.get(db);
|
||||||
|
if (!s) {
|
||||||
|
s = {
|
||||||
|
logs: db.prepare(
|
||||||
|
`SELECT cl.id, cl.monster_id, cl.won, cl.rounds, cl.xp_gained, cl.gold_gained, cl.created_at,
|
||||||
|
m.name AS monster_name, m.level AS monster_level
|
||||||
|
FROM combat_log cl
|
||||||
|
JOIN monsters m ON m.id = cl.monster_id
|
||||||
|
WHERE cl.character_id = ?
|
||||||
|
ORDER BY cl.id DESC
|
||||||
|
LIMIT ?`
|
||||||
|
),
|
||||||
|
};
|
||||||
|
stmtCache.set(db, s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} characterId
|
||||||
|
* @param {number} [limit] - max entries, default 20
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
function getCombatLog(characterId, limit = 20) {
|
||||||
|
const db = getDb();
|
||||||
|
const s = getStmts(db);
|
||||||
|
return s.logs.all(characterId, limit).map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
monsterName: row.monster_name,
|
||||||
|
monsterLevel: row.monster_level,
|
||||||
|
won: Boolean(row.won),
|
||||||
|
rounds: row.rounds,
|
||||||
|
xpGained: row.xp_gained,
|
||||||
|
goldGained: row.gold_gained,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getCombatLog };
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Zone service: list zones with unlocked flag for a given character level.
|
||||||
|
const { getDb } = require('../db');
|
||||||
|
|
||||||
|
// Prepared statements are cached PER database handle.
|
||||||
|
const stmtCache = new WeakMap();
|
||||||
|
|
||||||
|
function getStmts(db) {
|
||||||
|
let s = stmtCache.get(db);
|
||||||
|
if (!s) {
|
||||||
|
s = {
|
||||||
|
zones: db.prepare('SELECT id, slug, name, description, min_level, energy_cost FROM zones ORDER BY min_level, id'),
|
||||||
|
};
|
||||||
|
stmtCache.set(db, s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} characterLevel - hero's current level
|
||||||
|
* @returns {Array<{id, slug, name, description, min_level, energy_cost, unlocked}>}
|
||||||
|
*/
|
||||||
|
function listZones(characterLevel) {
|
||||||
|
const db = getDb();
|
||||||
|
const s = getStmts(db);
|
||||||
|
return s.zones.all().map((zone) => ({
|
||||||
|
...zone,
|
||||||
|
unlocked: characterLevel >= zone.min_level,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listZones };
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Integration tests for GET /api/zones and GET /api/combat-log.
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { setup, teardown, cleanup } = require('../helpers');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a user and log them in, returning the session cookie string.
|
||||||
|
* Node.js fetch doesn't persist cookies across calls, so we extract and reuse them.
|
||||||
|
*/
|
||||||
|
async function registerAndLogin(baseUrl, username, password) {
|
||||||
|
// Register
|
||||||
|
await fetch(`${baseUrl}/api/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login — extract the session cookie from Set-Cookie
|
||||||
|
const res = await fetch(`${baseUrl}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
|
||||||
|
// Extract session cookie (express-session sets a cookie like "connect.sid=...; Path=/")
|
||||||
|
const setCookie = res.headers.get('set-cookie');
|
||||||
|
assert.ok(setCookie, 'Login should set a session cookie');
|
||||||
|
const cookie = setCookie.split(';')[0]; // "connect.sid=..."
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(cookie) {
|
||||||
|
return { Cookie: cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCharacter(baseUrl, cookie, name = 'TestHero', cls = 'warrior') {
|
||||||
|
const res = await fetch(`${baseUrl}/api/character`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||||
|
body: JSON.stringify({ name, class: cls }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('zones endpoint', () => {
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
baseUrl = await setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await teardown();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all zones with unlocked flag for a level-1 character', async () => {
|
||||||
|
const cookie = await registerAndLogin(baseUrl, 'zoneuser1', 'password123');
|
||||||
|
await createCharacter(baseUrl, cookie, 'ZoneHero1');
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/zones`, { headers: authHeaders(cookie) });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
|
||||||
|
const zones = json.data;
|
||||||
|
assert.ok(Array.isArray(zones));
|
||||||
|
assert.ok(zones.length >= 3);
|
||||||
|
|
||||||
|
// First zone (Whispering Meadows, min_level=1) should be unlocked for level-1 hero
|
||||||
|
const meadows = zones.find((z) => z.slug === 'whispering-meadows');
|
||||||
|
assert.ok(meadows);
|
||||||
|
assert.equal(meadows.unlocked, true);
|
||||||
|
assert.equal(meadows.energy_cost, 5);
|
||||||
|
assert.equal(meadows.min_level, 1);
|
||||||
|
|
||||||
|
// Gloomroot Forest (min_level=5) should be locked
|
||||||
|
const forest = zones.find((z) => z.slug === 'gloomroot-forest');
|
||||||
|
assert.ok(forest);
|
||||||
|
assert.equal(forest.unlocked, false);
|
||||||
|
assert.equal(forest.min_level, 5);
|
||||||
|
|
||||||
|
// Ashfang Crags (min_level=10) should be locked
|
||||||
|
const crags = zones.find((z) => z.slug === 'ashfang-crag');
|
||||||
|
assert.ok(crags);
|
||||||
|
assert.equal(crags.unlocked, false);
|
||||||
|
assert.equal(crags.min_level, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unlocked=true when character level meets zone requirement', async () => {
|
||||||
|
const cookie = await registerAndLogin(baseUrl, 'zoneuser2', 'password123');
|
||||||
|
await createCharacter(baseUrl, cookie, 'ZoneHero2');
|
||||||
|
|
||||||
|
// Level up the character to 5
|
||||||
|
const db = require('../../src/db').getDb();
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE characters SET level = 5, xp = 0, unspent_points = 0 WHERE user_id = (SELECT id FROM users WHERE username = ?)'
|
||||||
|
).run('zoneuser2');
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/zones`, { headers: authHeaders(cookie) });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
const forest = json.data.find((z) => z.slug === 'gloomroot-forest');
|
||||||
|
assert.equal(forest.unlocked, true);
|
||||||
|
|
||||||
|
const crags = json.data.find((z) => z.slug === 'ashfang-crag');
|
||||||
|
assert.equal(crags.unlocked, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects without authentication', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/zones`);
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, false);
|
||||||
|
assert.equal(json.error.code, 'UNAUTHENTICATED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combat-log endpoint', () => {
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
baseUrl = await setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await teardown();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty list when no combat has occurred', async () => {
|
||||||
|
const cookie = await registerAndLogin(baseUrl, 'loguser', 'password123');
|
||||||
|
await createCharacter(baseUrl, cookie, 'LogHero', 'mage');
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/combat-log?limit=10`, { headers: authHeaders(cookie) });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, true);
|
||||||
|
assert.equal(json.data.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects without authentication', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/combat-log`);
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.equal(json.ok, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user