// 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 = ?' ), // List all expedition definitions. listDefs: db.prepare( 'SELECT * FROM expedition_defs ORDER BY min_level, 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((cid, ne, eat, defid, sat, endAt) => { s.updateEnergy.run(ne, eat, cid); s.insertExpedition.run(cid, defid, sat, endAt); }); doStart(characterId, newEnergy, updatedAtMs, defId, startedAt, endsAt); // 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, }, }; } /** * List all expedition definitions (public, no auth needed but called behind requireAuth). * @returns {Array} expedition definitions ordered by min_level, id. */ function listDefinitions() { const db = getDb(); const s = getStmts(db); const rows = s.listDefs.all(); return rows.map((row) => ({ id: row.id, slug: row.slug, name: row.name, description: row.description, minLevel: row.min_level, durationSec: row.duration_sec, energyCost: row.energy_cost, xpReward: row.xp_reward, goldMin: row.gold_min, goldMax: row.gold_max, })); } module.exports = { startExpedition, getActive, claimExpedition, listDefinitions };