241 lines
9.3 KiB
JavaScript
241 lines
9.3 KiB
JavaScript
/**
|
||
* Galaxy Strike Online — Mission System (GDD §8.3, §15)
|
||
* Fleet Missions: Attack, Mine, Patrol, Transport
|
||
* Reward formula: base_reward = difficulty × 100 × (1 + level × 0.02)
|
||
*/
|
||
|
||
const MISSION_TYPES = {
|
||
patrol: {
|
||
label: 'Patrol', icon: '🛡',
|
||
desc: 'Patrol a sector — passive XP and chance of intercepting pirates.',
|
||
baseDurationMin: 3, baseDurationMax: 5, // minutes
|
||
rewards: (level) => ({ xp: Math.floor(50 * (1 + level * 0.02)), credits: Math.floor(30 * (1 + level * 0.02)) }),
|
||
riskFactor: 0.05,
|
||
},
|
||
mine: {
|
||
label: 'Mining Run', icon: '⛏',
|
||
desc: 'Send fleet to mine asteroids in a sector. Returns Metal and Gas.',
|
||
baseDurationMin: 5, baseDurationMax: 10,
|
||
rewards: (level) => ({
|
||
xp: Math.floor(30 * (1 + level * 0.02)),
|
||
metal: Math.floor(200 * (1 + level * 0.05)),
|
||
gas: Math.floor(80 * (1 + level * 0.05)),
|
||
}),
|
||
riskFactor: 0.02,
|
||
},
|
||
transport: {
|
||
label: 'Trade Run', icon: '🚚',
|
||
desc: 'Transport cargo between sectors for credits.',
|
||
baseDurationMin: 8, baseDurationMax: 15,
|
||
rewards: (level) => ({ xp: Math.floor(40 * (1 + level * 0.02)), credits: Math.floor(150 * (1 + level * 0.03)) }),
|
||
riskFactor: 0.01,
|
||
},
|
||
attack: {
|
||
label: 'Raid', icon: '⚔',
|
||
desc: 'Raid a pirate outpost. High rewards, high risk.',
|
||
baseDurationMin: 10, baseDurationMax: 20,
|
||
rewards: (level) => ({
|
||
xp: Math.floor(120 * (1 + level * 0.02)),
|
||
credits: Math.floor(200 * (1 + level * 0.03)),
|
||
loot: true,
|
||
}),
|
||
riskFactor: 0.25,
|
||
},
|
||
explore: {
|
||
label: 'Explore', icon: '🔭',
|
||
desc: 'Explore an unknown sector for intel and resources.',
|
||
baseDurationMin: 6, baseDurationMax: 12,
|
||
rewards: (level) => ({ xp: Math.floor(80 * (1 + level * 0.02)), darkMatter: Math.floor(2 + Math.floor(level / 5)) }),
|
||
riskFactor: 0.10,
|
||
},
|
||
};
|
||
|
||
// Random loot table for raid missions
|
||
const RAID_LOOT = ['repair_kit','warp_token','shield_charge','metal_cache','crystal_shard'];
|
||
|
||
class MissionSystem {
|
||
constructor() {
|
||
this.MISSION_TYPES = MISSION_TYPES;
|
||
}
|
||
|
||
/** Start a fleet mission */
|
||
startMission(playerData, { missionType, fleetShipIds = [] }) {
|
||
if (!MISSION_TYPES[missionType]) throw new Error('Unknown mission type: ' + missionType);
|
||
const type = MISSION_TYPES[missionType];
|
||
const level = playerData.stats?.level || 1;
|
||
|
||
// Need at least one ship
|
||
const ships = (playerData.inventory || []).filter(i => i.type === 'ship' && fleetShipIds.includes(i.id));
|
||
if (ships.length === 0) throw new Error('Select at least one ship for the mission.');
|
||
|
||
// Ships can't be on multiple missions
|
||
const activeMissions = playerData.fleetMissions || [];
|
||
const busyShips = new Set(activeMissions.flatMap(m => m.shipIds || []));
|
||
const conflicting = fleetShipIds.filter(id => busyShips.has(id));
|
||
if (conflicting.length > 0) throw new Error('Some ships are already on a mission.');
|
||
|
||
// Apply travelSpeed research bonus — reduces mission duration (GDD §8 travel formula)
|
||
const travelBonus = Math.min(0.60, (playerData.research?.effects?.travelSpeed || 0) / 100);
|
||
const baseDurationMin = type.baseDurationMin + Math.random() * (type.baseDurationMax - type.baseDurationMin);
|
||
const durationMin = baseDurationMin * (1 - travelBonus);
|
||
const durationMs = Math.round(durationMin * 60 * 1000);
|
||
const now = Date.now();
|
||
|
||
const mission = {
|
||
id: 'mission_' + now + '_' + Math.random().toString(36).slice(2, 7),
|
||
type: missionType,
|
||
label: type.label,
|
||
icon: type.icon,
|
||
shipIds: fleetShipIds,
|
||
startedAt: now,
|
||
completesAt: now + durationMs,
|
||
level,
|
||
status: 'active',
|
||
};
|
||
|
||
playerData.fleetMissions = [...activeMissions, mission];
|
||
return mission;
|
||
}
|
||
|
||
/** Collect completed missions, apply rewards, return results */
|
||
collectMissions(playerData, resourceSystem) {
|
||
const now = Date.now();
|
||
const active = playerData.fleetMissions || [];
|
||
const done = active.filter(m => m.completesAt <= now && m.status === 'active');
|
||
const remaining = active.filter(m => m.completesAt > now || m.status !== 'active');
|
||
playerData.fleetMissions = remaining;
|
||
|
||
const results = [];
|
||
for (const mission of done) {
|
||
const type = MISSION_TYPES[mission.type];
|
||
if (!type) continue;
|
||
const rewards = type.rewards(mission.level || 1);
|
||
|
||
// Risk roll — small chance mission fails
|
||
const failed = Math.random() < type.riskFactor;
|
||
if (failed) {
|
||
results.push({ mission, success: false, message: `Mission failed! Fleet was intercepted.` });
|
||
continue;
|
||
}
|
||
|
||
// Apply XP + credits
|
||
if (rewards.xp) playerData.stats.experience = (playerData.stats.experience || 0) + rewards.xp;
|
||
if (rewards.credits) playerData.stats.credits = (playerData.stats.credits || 0) + rewards.credits;
|
||
|
||
// Apply resource rewards
|
||
const resGains = {};
|
||
if (rewards.metal) resGains.metal = rewards.metal;
|
||
if (rewards.gas) resGains.gas = rewards.gas;
|
||
if (rewards.crystal) resGains.crystal = rewards.crystal;
|
||
if (rewards.darkMatter) resGains.darkMatter = rewards.darkMatter;
|
||
if (Object.keys(resGains).length > 0 && resourceSystem) {
|
||
resourceSystem.initResources(playerData);
|
||
resourceSystem.add(playerData, resGains);
|
||
}
|
||
|
||
// Random loot for raids
|
||
const lootItem = rewards.loot ? RAID_LOOT[Math.floor(Math.random() * RAID_LOOT.length)] : null;
|
||
if (lootItem) {
|
||
if (!playerData.inventory) playerData.inventory = [];
|
||
playerData.inventory.push({ id: lootItem + '_' + Date.now(), type: 'consumable', name: lootItem.replace('_', ' '), icon: '📦', description: 'Mission loot' });
|
||
}
|
||
|
||
results.push({ mission, success: true, rewards, lootItem, message: `${type.label} completed successfully!` });
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/** Recall a mission early (no rewards, ships return instantly) */
|
||
recallMission(playerData, missionId) {
|
||
const idx = (playerData.fleetMissions || []).findIndex(m => m.id === missionId);
|
||
if (idx < 0) throw new Error('Mission not found');
|
||
playerData.fleetMissions.splice(idx, 1);
|
||
}
|
||
|
||
getMissionTypes() { return MISSION_TYPES; }
|
||
|
||
getMissionsForPlayer(playerData) {
|
||
return {
|
||
active: (playerData.fleetMissions || []).filter(m => m.status === 'active'),
|
||
types: MISSION_TYPES,
|
||
};
|
||
}
|
||
}
|
||
|
||
module.exports = MissionSystem;
|
||
|
||
// ── Faction NPC Missions (GDD §15.1) ─────────────────────────────────────
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
let FACTION_MISSIONS = [];
|
||
try {
|
||
const p = path.join(__dirname, '../data/gso/missions/faction_missions.json');
|
||
FACTION_MISSIONS = JSON.parse(fs.readFileSync(p, 'utf8')).missions;
|
||
} catch(e) { /* data not loaded */ }
|
||
|
||
MissionSystem.prototype.getAvailableFactionMissions = function(playerData) {
|
||
const level = playerData.stats?.level || 1;
|
||
const rep = playerData.reputation || {};
|
||
return FACTION_MISSIONS.filter(m => {
|
||
if (m.minLevel > level) return false;
|
||
if (m.requires_rep !== undefined) {
|
||
const fRep = rep[m.faction] || 0;
|
||
if (fRep < m.requires_rep) return false;
|
||
}
|
||
return true;
|
||
});
|
||
};
|
||
|
||
MissionSystem.prototype.startFactionMission = function(playerData, missionId) {
|
||
const mission = FACTION_MISSIONS.find(m => m.id === missionId);
|
||
if (!mission) throw new Error('Faction mission not found: ' + missionId);
|
||
const level = playerData.stats?.level || 1;
|
||
if (mission.minLevel > level) throw new Error(`Requires Commander Level ${mission.minLevel}`);
|
||
|
||
const now = Date.now();
|
||
const job = {
|
||
id: 'fmission_' + now,
|
||
type: 'faction',
|
||
factionMissionId: mission.id,
|
||
label: mission.name,
|
||
icon: '📋',
|
||
faction: mission.faction,
|
||
startedAt: now,
|
||
completesAt: now + mission.duration * 1000,
|
||
status: 'active',
|
||
rewards: mission.rewards,
|
||
};
|
||
playerData.fleetMissions = [...(playerData.fleetMissions||[]), job];
|
||
return job;
|
||
};
|
||
|
||
MissionSystem.prototype.collectFactionMissions = function(playerData, resourceSystem, reputationSystem) {
|
||
const now = Date.now();
|
||
const done = (playerData.fleetMissions||[]).filter(m => m.type==='faction' && m.completesAt <= now && m.status==='active');
|
||
playerData.fleetMissions = (playerData.fleetMissions||[]).filter(m => !(m.type==='faction' && m.completesAt <= now && m.status==='active'));
|
||
|
||
const results = [];
|
||
for (const job of done) {
|
||
const r = job.rewards || {};
|
||
if (r.credits) playerData.stats.credits = (playerData.stats.credits||0) + r.credits;
|
||
if (r.xp) playerData.addExperience?.(r.xp);
|
||
if (r.metal || r.gas || r.crystal || r.darkMatter) {
|
||
resourceSystem?.initResources(playerData);
|
||
const gains = {};
|
||
if (r.metal) gains.metal = r.metal;
|
||
if (r.gas) gains.gas = r.gas;
|
||
if (r.crystal) gains.crystal = r.crystal;
|
||
if (r.darkMatter) gains.darkMatter = r.darkMatter;
|
||
resourceSystem?.add(playerData, gains);
|
||
}
|
||
if (r.reputation && reputationSystem) {
|
||
for (const [factionId, delta] of Object.entries(r.reputation)) {
|
||
reputationSystem.adjustReputation(playerData, factionId, delta);
|
||
}
|
||
}
|
||
results.push({ mission: job, success: true, rewards: r });
|
||
}
|
||
return results;
|
||
};
|