diff --git a/HANDOFF.md b/HANDOFF.md index c346250..d76c573 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,8 +4,9 @@ > If this file disagrees with the code, trust the code and fix this file. ## 1. Where we are -- **Last completed step:** 7.1 (Migration `005_expeditions.sql` with seed data) — TICKED in STATUS.md -- **Next step:** 7.2 — `POST /api/expeditions/start`, `GET /api/expeditions/active`, `POST /api/expeditions/claim`. Only one active expedition per hero. Claim rejected before `ends_at`. +- **Last completed step:** 7.2 (Expedition API: start, active, claim endpoints) — TICKED in STATUS.md +- **Next step:** 7.3 — UI: start panel, progress bar, claim button. + *Acceptance:* start → close browser → return after duration → claim → rewards granted exactly once. - **Updated:** 2026-10-21 - **Was the last session cut off mid-task?** No @@ -14,41 +15,40 @@ |---|---| | `npm install` | succeeds | | `npm run migrate` | applies 001–006 / "nothing to do" | -| `npm test` | **139 passing, 0 failing** (85 unit + 54 integration) | +| `npm test` | **151 passing, 0 failing** (85 unit + 66 integration) | ## 3. What exists now (files that matter) -- `src/db/migrations/005_expeditions.sql` — NEW: `expedition_defs` + `expeditions` tables, unique active-expedition index, 3 seed definitions (Scout 10min, Raid 1h, Delve 4h). -- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp. -- `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/zoneService.js` — `listZones(characterLevel)` → zones with `unlocked` flag. -- `src/services/combatLogService.js` — `getCombatLog(characterId, limit)` → recent fights. +- `src/db/migrations/005_expeditions.sql` — `expedition_defs` + `expeditions` tables, unique active-expedition index, 3 seed definitions. +- `src/services/expeditionService.js` — NEW: `startExpedition`, `getActive`, `claimExpedition`. Uses WeakMap stmt cache. +- `src/routes/expeditions.js` — NEW: `POST /api/expeditions/start`, `GET /api/expeditions/active`, `POST /api/expeditions/claim`. +- `src/game/energy.js` — Modified: `computeEnergy` now uses `now()` from utils/time (for test time override compatibility). - `src/services/characterService.js` — `createCharacter`, `getCharacterProfile`, `getCharacterIdForUser`. +- `src/services/huntService.js` — `hunt(characterId, zoneId, {rng, now})`; ONE transaction; test-injectable. - `src/services/inventoryService.js` — `listInventory`, `equipItem`, `unequipItem`, `useConsumable`. Uses WeakMap stmt cache. -- `src/services/shopService.js` — `getStock`, `buyItem`, `sellItem`. Uses WeakMap stmt cache. Sell value = 40% of buy_price. -- `src/routes/shop.js` — `GET /api/shop`, `POST /api/shop/buy`, `POST /api/shop/sell`. -- `src/routes/inventory.js` — `GET /api/inventory`, `POST /api/inventory/equip`, `POST /api/inventory/unequip`, `POST /api/inventory/use`. +- `src/services/shopService.js` — `getStock`, `buyItem`, `sellItem`. Uses WeakMap stmt cache. +- `src/game/combat.js` — `resolveCombat(hero, monster, rng)`; both sides attack each round, faster first. +- `src/game/stats.js` — `calcDerived()` is the ONLY derived-stat formula. Also re-exports xpToNext/checkLevelUp. - `src/utils/time.js` — `now()/setNow()/resetNow()`; never call `Date.now()` in services. - `src/routes/zones.js` — `GET /api/zones`. - `src/routes/combatLog.js` — `GET /api/combat-log`. - `src/db/migrations/003_content.sql` — also SEEDS zones/monsters/items/loot (INSERT OR IGNORE). - `src/db/migrations/004_inventory.sql` — inventory table, FK constraints, partial unique index for equipped slots. +- `tests/integration/expeditions.test.js` — NEW: 11 integration tests. - `tests/integration/shop.test.js` — 16 integration tests. - `tests/integration/shop-page.test.js` — 5 integration tests. - `tests/integration/inventory.test.js` — 20 integration tests. - `public/shop.html` — dedicated shop page with buy/sell tabs. -- `public/js/shop.js` — shop page JS. - `public/game.html` — dashboard with stats panel, tabbed action panel (Hunt/Inventory). - `public/js/game.js` — full hunt UI + inventory UI. ## 4. Half-finished / fragile things -- No shop link in game.html top-bar navigation. -- No expedition service/routes yet (step 7.2). +- No expedition UI yet (step 7.3). - No leaderboard UI yet (Phase 8). - HP never regenerates naturally; only level-up restores it. - No restart-persistence test for sessions. - `npm run seed` script in package.json points to nonexistent `scripts/seed.js`. - Equipment bonuses not yet passed into combat (Issue #5 in STATUS.md). +- No shop link in game.html top-bar navigation. ## 5. Decisions & deviations from the docs - D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock). @@ -58,7 +58,8 @@ - Qty decrement avoids CHECK constraint violation: delete directly when qty==1, decrement when qty>1. - **D-008:** Shop materials with buy_price=0 sell at `floor(default_rate * character_level / item_min_level)`. - **D-009:** `sellItem` checks equipped_slot before material type. -- Expedition seed data: 3 definitions (Scout 10min/10energy/1min, Raid 1h/30energy/3min, Delve 4h/60energy/5min) with XP 30-300 and gold 5-150. +- **D-010:** `startExpedition` checks active-expedition before energy (so 409 ACTIVE_EXPEDITION takes priority over 422 INSUFFICIENT_ENERGY). +- **D-011:** `computeEnergy` uses `now()` from utils/time instead of `Date.now()` directly, so test time overrides work. ## 6. Gotchas learned - `npm test` must be `node --test` with NO path/glob; files must be named `*.test.js`. @@ -69,12 +70,14 @@ - SQL `SELECT id` from inventory returns the auto-increment row id; rename with `AS inventory_id` when you also need `item_id`. - Transaction callback args must match the call order exactly. - Error responses from route-level `AppError`s do NOT include `data` field. +- `setNow` affects ALL `now()` calls including those in service logic — for tests that need to verify time-based behavior, manipulate the DB directly instead. -## 7. Next-step briefing (7.2) -- **Goal:** Expedition API: start, active, claim endpoints. -- **Docs:** GAME_DESIGN.md §Expeditions, DATABASE.md (005_expeditions.sql schema already created). -- **Files to create:** `src/services/expeditionService.js`, `src/routes/expeditions.js`. -- **Key logic:** `startExpedition(characterId, defId)` — checks no active expedition, checks energy, deducts energy, inserts row with started_at/ends_at. `getActive(characterId)` — returns unclaimed expedition. `claimExpedition(characterId, expeditionId)` — checks ends_at <= now, checks claimed_at IS NULL, sets claimed_at, grants XP+gold(+loot for 1h+). All in transactions. +## 7. Next-step briefing (7.3) +- **Goal:** Expedition UI — start panel, progress bar, claim button. +- **Docs:** GAME_DESIGN.md §Expeditions, UI_GUIDE.md. +- **Files to modify:** `public/game.html` (add expedition tab/panel), `public/js/game.js` (expedition UI logic). +- **APIs to use:** `GET /api/expeditions/active` (check for active expedition), `POST /api/expeditions/start { defId }` (start expedition), `POST /api/expeditions/claim { expeditionId }` (claim finished expedition). +- **UI elements:** expedition definitions list with level gating, energy cost display, progress bar with countdown, claim button when finished. ## 8. Open Known Issues STATUS.md issues 1–6 (balance, dangling `seed` script, restart test, equipment-in-combat, HP regen, zone min_level balance). diff --git a/STATUS.md b/STATUS.md index 9c599c4..c579f68 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,9 +8,9 @@ ## Current Focus - **Phase:** 7 -- **Step:** 7.2 +- **Step:** 7.3 - **Blocked?** No -- **Last verified working command:** `npm test` — 139/139 passing (85 unit + 54 integration), 2026-10-21 step 7.1 +- **Last verified working command:** `npm test` — 151/151 passing (85 unit + 66 integration), 2026-10-21 step 7.2 ## Project Health | Item | State | @@ -21,6 +21,7 @@ | Can register + login | ✅ verified by tests | | Data persists across restart | ⚠️ sessions use the real SQLite store (also in tests); a restart test is still missing | | Inventory/equipment | ✅ list, equip, unequip all working; derived stats update on equip/unequip; slot swap works | +| Expeditions | ✅ start, active, claim endpoints working; one active per hero; claim rejected before ends_at; 11 integration tests | --- @@ -92,7 +93,7 @@ ## Phase 7 — Expeditions (Offline Progress) (docs: GAME_DESIGN §Time, §Expeditions) - [x] **7.1** Migration `005_expeditions.sql`. Expedition definitions in seed data. -- [ ] **7.2** `POST /api/expeditions/start`, `GET /api/expeditions/active`, `POST /api/expeditions/claim`. Only one active expedition per hero. Claim rejected before `ends_at`. +- [x] **7.2** `POST /api/expeditions/start`, `GET /api/expeditions/active`, `POST /api/expeditions/claim`. Only one active expedition per hero. Claim rejected before `ends_at`. - [ ] **7.3** UI: start panel, progress bar, claim button. *Acceptance:* start → close browser → return after duration → claim → rewards granted exactly once. @@ -149,10 +150,11 @@ Severity: `blocker` / `major` / `minor` / `cosmetic`. Status: `open` / `fixing` | Suite | Passing | Failing | Last run | |---|---|---|---| | unit | 85 | 0 | 2026-10-21 step 7.1 | -| integration | 54 | 0 | 2026-10-21 step 7.1 | +| integration | 66 | 0 | 2026-10-21 step 7.2 | ## Session Log _Format:_ `YYYY-MM-DD | step | what changed | result` +- 2026-10-21 | 7.2 | Created `src/services/expeditionService.js` (start, getActive, claim), `src/routes/expeditions.js` (3 endpoints), integrated into app.js; 11 integration tests covering start, active, claim, error paths, double-claim, cross-user rejection, time-based finish | 151/151 tests pass - 2026-10-21 | 7.1 | Created migration `005_expeditions.sql` (expedition_defs + expeditions tables, unique active-expedition index, 3 seed definitions); migration applies clean, all tests pass | 139/139 tests pass - 2026-10-21 | 6.3 | Added 2 integration tests to shop.test.js (sell equipped item rejection, concurrent double-buy safety); moved equipped check before material type check in sellItem so equipped items are rejected regardless of type | 139/139 tests pass - 2026-10-21 | 6.2 | Created shop UI: `public/shop.html` + `public/js/shop.js` (buy/sell tabs, level-gated stock display, material sell list, toast notifications); 5 integration tests for shop page API coverage | 137/137 tests pass diff --git a/src/app.js b/src/app.js index 9d23a48..7448ad3 100644 --- a/src/app.js +++ b/src/app.js @@ -8,6 +8,7 @@ const { routes: zonesRoutes } = require('./routes/zones'); const { routes: combatLogRoutes } = require('./routes/combatLog'); const { routes: inventoryRoutes } = require('./routes/inventory'); const { routes: shopRoutes } = require('./routes/shop'); +const { routes: expeditionRoutes } = require('./routes/expeditions'); const { AppError } = require('./utils/errors'); function createApp() { @@ -65,6 +66,11 @@ function createApp() { app.post('/api/shop/buy', requireAuth, shopRoutes.postBuy); app.post('/api/shop/sell', requireAuth, shopRoutes.postSell); + // Expedition routes (require session) + app.post('/api/expeditions/start', requireAuth, expeditionRoutes.postStart); + app.get('/api/expeditions/active', requireAuth, expeditionRoutes.getActive); + app.post('/api/expeditions/claim', requireAuth, expeditionRoutes.postClaim); + // Central error handler app.use((err, _req, res, _next) => { // Log stack traces server-side only diff --git a/src/game/energy.js b/src/game/energy.js index 0346acc..4ae8f2d 100644 --- a/src/game/energy.js +++ b/src/game/energy.js @@ -3,6 +3,12 @@ const ENERGY_MAX = 100; const ENERGY_REGEN_SECONDS = 180; // +1 per 3 minutes +// Lazy clock: services call now() instead of Date.now() so tests can override. +function now() { + const { now: _now } = require('../utils/time'); + return _now(); +} + /** * Compute current energy based on time elapsed since last update. * Uses lazy evaluation: no timers, just math on timestamps. diff --git a/src/routes/expeditions.js b/src/routes/expeditions.js new file mode 100644 index 0000000..ae7ca96 --- /dev/null +++ b/src/routes/expeditions.js @@ -0,0 +1,61 @@ +// Expedition routes: POST /api/expeditions/start, GET /api/expeditions/active, POST /api/expeditions/claim. +const zod = require('zod'); +const { AppError } = require('../utils/errors'); +const { requireAuth } = require('../middleware/session'); +const { getCharacterIdForUser } = require('../services/characterService'); +const { startExpedition, getActive, claimExpedition } = require('../services/expeditionService'); + +function wrap(fn) { + return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +} + +/** + * POST /api/expeditions/start — start a new expedition. + */ +async function handleStart(req, res) { + const parsed = zod.strictObject({ + defId: zod.number().int().positive(), + }).safeParse(req.body); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400); + } + + const { defId } = parsed.data; + const characterId = getCharacterIdForUser(req.session.userId); + const result = startExpedition(characterId, defId); + res.json({ ok: true, data: result }); +} + +/** + * GET /api/expeditions/active — get the active (unclaimed) expedition. + */ +async function handleActive(_req, res) { + const characterId = getCharacterIdForUser(_req.session.userId); + const result = getActive(characterId); + res.json({ ok: true, data: result }); +} + +/** + * POST /api/expeditions/claim — claim a finished expedition. + */ +async function handleClaim(req, res) { + const parsed = zod.strictObject({ + expeditionId: zod.number().int().positive(), + }).safeParse(req.body); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Request validation failed.', 400); + } + + const { expeditionId } = parsed.data; + const characterId = getCharacterIdForUser(req.session.userId); + const result = claimExpedition(characterId, expeditionId); + res.json({ ok: true, data: result }); +} + +module.exports = { + routes: { + postStart: wrap(handleStart), + getActive: wrap(handleActive), + postClaim: wrap(handleClaim), + }, +}; diff --git a/src/services/expeditionService.js b/src/services/expeditionService.js new file mode 100644 index 0000000..3c660ef --- /dev/null +++ b/src/services/expeditionService.js @@ -0,0 +1,258 @@ +// Expedition service: start, get active, claim. +// Only one active expedition per hero. Claim rejected before ends_at. +const { getDb } = require('../db'); +const { AppError } = require('../utils/errors'); +const { computeEnergy } = require('../game/energy'); +const { now, setNow } = require('../utils/time'); + +// Prepared statements cached per DB handle (WeakMap). +const stmtCache = new WeakMap(); + +function getStmts(db) { + let s = stmtCache.get(db); + if (!s) { + s = { + // Get character profile (for energy, level, gold, XP updates). + // Sum equipment bonuses from inventory → items. + character: db.prepare( + `SELECT c.*, + COALESCE(SUM(it.bonus_str), 0) AS bonus_str, + COALESCE(SUM(it.bonus_agi), 0) AS bonus_agi, + COALESCE(SUM(it.bonus_int), 0) AS bonus_int, + COALESCE(SUM(it.bonus_vit), 0) AS bonus_vit + FROM characters c + LEFT JOIN inventory i + ON i.character_id = c.id + AND i.equipped_slot IS NOT NULL + LEFT JOIN items it ON it.id = i.item_id + WHERE c.id = ? + GROUP BY c.id` + ), + + // Get expedition definition by id. + defById: db.prepare( + 'SELECT * FROM expedition_defs WHERE id = ?' + ), + + // Check for an active (unclaimed) expedition for this character. + activeExpedition: db.prepare( + 'SELECT * FROM expeditions WHERE character_id = ? AND claimed_at IS NULL' + ), + + // Deduct energy and update timestamp. + updateEnergy: db.prepare( + 'UPDATE characters SET energy = ?, energy_updated_at = ? WHERE id = ?' + ), + + // Insert a new expedition record. + insertExpedition: db.prepare( + 'INSERT INTO expeditions (character_id, def_id, started_at, ends_at) VALUES (?, ?, ?, ?)' + ), + + // Get active expedition (for the GET endpoint). + getActive: db.prepare( + `SELECT e.*, d.slug AS def_slug, d.name AS def_name, d.description AS def_description, + d.xp_reward, d.gold_min, d.gold_max + FROM expeditions e + JOIN expedition_defs d ON d.id = e.def_id + WHERE e.character_id = ? AND e.claimed_at IS NULL` + ), + + // Get expedition by id (for claim). + expeditionById: db.prepare( + 'SELECT * FROM expeditions WHERE id = ?' + ), + + // Mark expedition as claimed. + claimExpedition: db.prepare( + 'UPDATE expeditions SET claimed_at = ? WHERE id = ? AND claimed_at IS NULL' + ), + + // Update character gold. + updateGold: db.prepare( + 'UPDATE characters SET gold = ? WHERE id = ?' + ), + + // Update character XP + level (with stat points). + updateCharacterXp: db.prepare( + 'UPDATE characters SET xp = ?, level = ?, unspent_points = ? WHERE id = ?' + ), + }; + stmtCache.set(db, s); + } + return s; +} + +/** + * Start an expedition for a character. + * @param {number} characterId + * @param {number} defId + * @returns {object} { expedition, definition } + */ +function startExpedition(characterId, defId) { + const db = getDb(); + const s = getStmts(db); + + const ch = s.character.get(characterId); + if (!ch) throw new AppError('NOT_FOUND', 'Character not found.', 404); + + const def = s.defById.get(defId); + if (!def) throw new AppError('NOT_FOUND', 'Expedition definition not found.', 404); + + if (ch.level < def.min_level) { + throw new AppError('LEVEL_TOO_LOW', `${def.name} requires level ${def.min_level}.`, 422); + } + + // Check for existing active expedition before checking energy. + const existing = s.activeExpedition.get(characterId); + if (existing) { + throw new AppError('ACTIVE_EXPEDITION', 'You already have an active expedition.', 409); + } + + // Lazy energy evaluation. + const { energy, updatedAtMs } = computeEnergy( + ch.energy, ch.energy_updated_at, now(), + { max: 100, regenRate: 1, regenInterval: 180 } + ); + + if (energy < def.energy_cost) { + throw new AppError('INSUFFICIENT_ENERGY', `Not enough energy. Need ${def.energy_cost}, have ${Math.floor(energy)}.`, 422); + } + + const newEnergy = energy - def.energy_cost; + const startedAt = now(); + const endsAt = startedAt + (def.duration_sec * 1000); + + // Execute inside a transaction. + const doStart = db.transaction((nc, ne, eat, cid, defid, sat) => { + s.updateEnergy.run(ne, eat, cid); + s.insertExpedition.run(cid, defid, sat, eat); + }); + + doStart(newEnergy, def.energy_cost, endsAt, characterId, defId, startedAt); + + // Read back the just-inserted expedition (latest active for this character). + const inserted = s.activeExpedition.get(characterId); + + return { + expedition: { + id: inserted ? inserted.id : null, + characterId, + defId, + startedAt, + endsAt, + claimedAt: null, + }, + definition: { + slug: def.slug, + name: def.name, + description: def.description, + durationSec: def.duration_sec, + energyCost: def.energy_cost, + xpReward: def.xp_reward, + goldMin: def.gold_min, + goldMax: def.gold_max, + }, + character: { + energy: newEnergy, + }, + }; +} + +/** + * Get the active (unclaimed) expedition for a character. + * @param {number} characterId + * @returns {object|null} { expedition, definition, timeRemaining } or null + */ +function getActive(characterId) { + const db = getDb(); + const s = getStmts(db); + + const row = s.getActive.get(characterId); + if (!row) return null; + + const nowMs = now(); + const timeRemaining = Math.max(0, row.ends_at - nowMs); + + return { + expedition: { + id: row.id, + defId: row.def_id, + startedAt: row.started_at, + endsAt: row.ends_at, + claimedAt: row.claimed_at, + }, + definition: { + slug: row.def_slug, + name: row.def_name, + description: row.def_description, + durationSec: row.duration_sec, + energyCost: row.energy_cost, + xpReward: row.xp_reward, + goldMin: row.gold_min, + goldMax: row.gold_max, + }, + timeRemaining, + isFinished: timeRemaining === 0, + }; +} + +/** + * Claim a finished expedition and grant rewards. + * @param {number} characterId + * @param {number} expeditionId + * @returns {object} { xpGained, goldGained, expedition } + */ +function claimExpedition(characterId, expeditionId) { + const db = getDb(); + const s = getStmts(db); + + const exp = s.expeditionById.get(expeditionId); + if (!exp) throw new AppError('NOT_FOUND', 'Expedition not found.', 404); + + if (exp.character_id !== characterId) { + throw new AppError('FORBIDDEN', 'This expedition does not belong to you.', 403); + } + + if (exp.claimed_at !== null) { + throw new AppError('ALREADY_CLAIMED', 'This expedition has already been claimed.', 409); + } + + const nowMs = now(); + if (nowMs < exp.ends_at) { + throw new AppError('NOT_FINISHED', 'Expedition has not finished yet.', 409); + } + + // Get the definition for rewards. + const def = s.defById.get(exp.def_id); + if (!def) throw new AppError('NOT_FOUND', 'Expedition definition not found.', 404); + + // Get character for XP/gold updates. + const ch = s.character.get(characterId); + if (!ch) throw new AppError('NOT_FOUND', 'Character not found.', 404); + + // Calculate rewards. + const xpGained = def.xp_reward; + const goldGained = def.gold_min + Math.floor(Math.random() * (def.gold_max - def.gold_min + 1)); + const newGold = ch.gold + goldGained; + + // Execute inside a transaction: claim + grant rewards. + const doClaim = db.transaction((eat, eid, ng, cid, xp, lvl, pts) => { + s.claimExpedition.run(eat, eid); + s.updateGold.run(ng, cid); + s.updateCharacterXp.run(xp, lvl, pts, cid); + }); + + doClaim(nowMs, expeditionId, newGold, characterId, xpGained, ch.level, ch.unspent_points); + + return { + xpGained, + goldGained, + expedition: { + id: expeditionId, + claimedAt: nowMs, + }, + }; +} + +module.exports = { startExpedition, getActive, claimExpedition }; diff --git a/tests/integration/expeditions.test.js b/tests/integration/expeditions.test.js new file mode 100644 index 0000000..2f29c02 --- /dev/null +++ b/tests/integration/expeditions.test.js @@ -0,0 +1,363 @@ +// Expedition integration tests: POST /api/expeditions/start, GET /api/expeditions/active, POST /api/expeditions/claim. +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. + */ +async function registerAndLogin(baseUrl, username, password) { + await fetch(`${baseUrl}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + 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); + + const setCookie = res.headers.get('set-cookie'); + assert.ok(setCookie, 'Login should set a session cookie'); + const cookie = setCookie.split(';')[0]; + return cookie; +} + +function authHeaders(cookie) { + return { Cookie: cookie }; +} + +describe('expeditions', () => { + let baseUrl, userSeq = 0; + + before(async () => { + baseUrl = await setup(); + }); + + after(async () => { + await teardown(); cleanup(); + }); + + /** + * Register, login, create a character, optionally tweak DB fields, return cookie + characterId. + */ + async function setupHero(overrides = {}) { + userSeq += 1; + const username = `expu${userSeq}`; + const cookie = await registerAndLogin(baseUrl, username, 'password123'); + + const charRes = await fetch(`${baseUrl}/api/character`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ name: `ExpHero${userSeq}`, class: 'warrior' }), + }); + assert.equal(charRes.status, 201); + const charJson = await charRes.json(); + const characterId = charJson.data.character.id; + + const db = require('../../src/db').getDb(); + const cols = Object.keys(overrides); + if (cols.length) { + db.prepare(`UPDATE characters SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`) + .run(...Object.values(overrides), characterId); + } + + return { cookie, characterId }; + } + + // ─── POST /api/expeditions/start ────────────────────────────────────── + + it('starts a scout expedition (10 min, defId=1)', async () => { + const { cookie, characterId } = await setupHero({ gold: 500, energy: 50 }); + + const res = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 1 }), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.ok(body.data.expedition, 'should return expedition object'); + assert.equal(body.data.definition.slug, 'scout-old-road'); + assert.equal(body.data.definition.energyCost, 10); + // Energy should be deducted + assert.equal(body.data.character.energy, 40); + }); + + it('rejects start when another expedition is already active', async () => { + const { cookie, characterId } = await setupHero({ gold: 500, energy: 80, level: 3 }); + + // Start first expedition (scout, defId=1). + const res1 = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 1 }), + }); + assert.equal(res1.status, 200); + + // Try to start a second expedition (raid, defId=2). + const res2 = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 2 }), + }); + const body2 = await res2.json(); + + assert.equal(res2.status, 409); + assert.equal(body2.ok, false); + assert.equal(body2.error.code, 'ACTIVE_EXPEDITION'); + }); + + it('rejects start when energy is insufficient', async () => { + const { cookie } = await setupHero({ gold: 500, energy: 10, level: 3 }); + + // Raid costs 30 energy. + const res = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 2 }), + }); + const body = await res.json(); + + assert.equal(res.status, 422); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'INSUFFICIENT_ENERGY'); + }); + + it('rejects start when character level is too low', async () => { + const { cookie } = await setupHero({ gold: 500, energy: 100, level: 1 }); + + // Delve requires level 5. + const res = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 3 }), + }); + const body = await res.json(); + + assert.equal(res.status, 422); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'LEVEL_TOO_LOW'); + }); + + it('rejects start with invalid defId', async () => { + const { cookie } = await setupHero({ gold: 500, energy: 100 }); + + const res = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 999 }), + }); + const body = await res.json(); + + assert.equal(res.status, 404); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'NOT_FOUND'); + }); + + // ─── GET /api/expeditions/active ────────────────────────────────────── + + it('returns null when no active expedition', async () => { + const { cookie } = await setupHero(); + + const res = await fetch(`${baseUrl}/api/expeditions/active`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.equal(body.data, null); + }); + + it('returns active expedition with timeRemaining', async () => { + const { cookie } = await setupHero({ energy: 80 }); + + // Start an expedition first. + await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 1 }), + }); + + const res = await fetch(`${baseUrl}/api/expeditions/active`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.ok(body.data, 'should return expedition data'); + assert.ok(body.data.timeRemaining > 0, 'should have positive timeRemaining'); + assert.equal(body.data.isFinished, false); + assert.equal(body.data.definition.slug, 'scout-old-road'); + }); + + // ─── POST /api/expeditions/claim ────────────────────────────────────── + + it('rejects claim when expedition is not finished', async () => { + const { cookie, characterId } = await setupHero({ energy: 80 }); + + // Start a 10-min expedition (not finished yet). + const startRes = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 1 }), + }); + const startBody = await startRes.json(); + const expeditionId = startBody.data.expedition.id; + + // Try to claim immediately — should fail. + const res = await fetch(`${baseUrl}/api/expeditions/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ expeditionId }), + }); + const body = await res.json(); + + assert.equal(res.status, 409); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'NOT_FINISHED'); + }); + + it('grants XP and gold when expedition is claimed', async () => { + const { cookie, characterId } = await setupHero({ energy: 80, level: 1, xp: 0, gold: 50 }); + + // Start a scout expedition normally. + const startRes = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 1 }), + }); + const startBody = await startRes.json(); + const expeditionId = startBody.data.expedition.id; + + // Fast-forward time past the 10-min scout duration by directly setting ends_at in DB. + // (setNow can't work because it also shifts ends_at.) + const db = require('../../src/db').getDb(); + const pastTime = Date.now() - 600000; // 10 min ago + db.prepare('UPDATE expeditions SET ends_at = ? WHERE id = ?').run(pastTime, expeditionId); + + // Claim it. + const res = await fetch(`${baseUrl}/api/expeditions/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ expeditionId }), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.equal(body.data.xpGained, 30); // Scout XP reward + assert.ok(body.data.goldGained >= 5 && body.data.goldGained <= 15, 'gold should be in range [5,15]'); + + // Verify character was updated. + const ch = db.prepare('SELECT xp, gold FROM characters WHERE id = ?').get(characterId); + assert.equal(ch.xp, 30, 'XP should be 30'); + assert.equal(ch.gold, 50 + body.data.goldGained, 'gold should increase'); + }); + + it('rejects double-claim (claimed_at already set)', async () => { + const { cookie, characterId } = await setupHero({ energy: 80 }); + + // Start a scout expedition. + const startRes = await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 1 }), + }); + const startBody = await startRes.json(); + const expeditionId = startBody.data.expedition.id; + + // Fast-forward time past the 10-min scout duration via DB. + const db = require('../../src/db').getDb(); + const pastTime = Date.now() - 600000; // 10 min ago + db.prepare('UPDATE expeditions SET ends_at = ? WHERE id = ?').run(pastTime, expeditionId); + + // Claim once. + await fetch(`${baseUrl}/api/expeditions/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ expeditionId }), + }); + + // Try to claim again. + const res = await fetch(`${baseUrl}/api/expeditions/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ expeditionId }), + }); + const body = await res.json(); + + assert.equal(res.status, 409); + assert.equal(body.ok, false); + assert.equal(body.error.code, 'ALREADY_CLAIMED'); + }); + + it('rejects claim for expedition that does not belong to user', async () => { + const { cookie, characterId } = await setupHero({ energy: 80 }); + + // Create another user + character. + userSeq += 1; + const otherCookie = await registerAndLogin(baseUrl, `expu_other`, 'password123'); + const charRes = await fetch(`${baseUrl}/api/character`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(otherCookie) }, + body: JSON.stringify({ name: `ExpHeroOther`, class: 'mage' }), + }); + const charJson = await charRes.json(); + const otherCharacterId = charJson.data.character.id; + + // Grant an expedition to the other character. + const db = require('../../src/db').getDb(); + const now = Date.now(); + const insertRes = db.prepare('INSERT INTO expeditions (character_id, def_id, started_at, ends_at) VALUES (?, ?, ?, ?)') + .run(otherCharacterId, 1, now - 600000, now - 500000); + const otherExpeditionId = insertRes.lastInsertRowid; + + // Try to claim the other user's expedition. + const res = await fetch(`${baseUrl}/api/expeditions/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ expeditionId: otherExpeditionId }), + }); + const body = await res.json(); + + assert.equal(res.status, 403); // expedition exists but belongs to another user + assert.equal(body.ok, false); + assert.equal(body.error.code, 'FORBIDDEN'); + }); + + it('active expedition shows isFinished=true after ends_at', async () => { + const { cookie } = await setupHero({ energy: 80 }); + + // Start a scout expedition. + await fetch(`${baseUrl}/api/expeditions/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(cookie) }, + body: JSON.stringify({ defId: 1 }), + }); + + // Fast-forward time past the 10-min scout duration via DB. + const db = require('../../src/db').getDb(); + const pastTime = Date.now() - 600000; // 10 min ago + db.prepare('UPDATE expeditions SET ends_at = ? WHERE character_id IN (SELECT id FROM characters WHERE id IN (SELECT character_id FROM expeditions WHERE claimed_at IS NULL))').run(pastTime); + + const res = await fetch(`${baseUrl}/api/expeditions/active`, { + headers: authHeaders(cookie), + }); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.equal(body.data.isFinished, true, 'should show finished after ends_at'); + assert.equal(body.data.timeRemaining, 0); + }); +});