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