step 7.2: done
This commit is contained in:
+24
-21
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user