step 7.2: done

This commit is contained in:
bepis
2026-10-21 06:59:52 +11:00
parent 7f074e4dec
commit 233f1e0491
7 changed files with 724 additions and 25 deletions
+258
View File
@@ -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 };