268 lines
12 KiB
JavaScript
268 lines
12 KiB
JavaScript
/**
|
||
* RaidSystem — GDD Phase 3 Launch Prep
|
||
*
|
||
* Weekly guild raids (5-player co-op dungeons) and monthly world boss events.
|
||
* Raid-exclusive loot tables with rare drops not available elsewhere.
|
||
*
|
||
* Raid types:
|
||
* weekly — resets every Monday 00:00 UTC, requires alliance, max 5 players
|
||
* boss — resets 1st of each month 00:00 UTC, any players, single-phase boss
|
||
*/
|
||
|
||
class RaidSystem {
|
||
constructor(contentLoader) {
|
||
this.loader = contentLoader;
|
||
this.io = null;
|
||
this.activeRaids = new Map(); // raidId -> raid instance
|
||
this.raidQueue = new Map(); // allianceId -> pending party
|
||
}
|
||
|
||
setIO(io) { this.io = io; }
|
||
|
||
// ── Raid Definitions ─────────────────────────────────────────────
|
||
getRaidDefinitions() {
|
||
return {
|
||
weekly_nebula_assault: {
|
||
id: 'weekly_nebula_assault',
|
||
name: 'Nebula Assault',
|
||
type: 'weekly',
|
||
description: 'Alliance operation to clear a heavily fortified nebula sector.',
|
||
minPlayers: 2,
|
||
maxPlayers: 5,
|
||
minLevel: 5,
|
||
phases: 3,
|
||
energyCost: 30,
|
||
cooldownHours: 168, // 1 week
|
||
lootTable: [
|
||
{ itemId: 'raid_nebula_core', weight: 5, qtyMin: 1, qtyMax: 1 },
|
||
{ itemId: 'voidsteel_ingot', weight: 20, qtyMin: 2, qtyMax: 5 },
|
||
{ itemId: 'flux_crystal', weight: 25, qtyMin: 3, qtyMax: 8 },
|
||
{ itemId: 'energy_boost_large', weight: 30, qtyMin: 1, qtyMax: 2 },
|
||
{ itemId: 'credit_multiplier', weight: 20, qtyMin: 1, qtyMax: 1 },
|
||
],
|
||
rewards: { creditsMin: 2000, creditsMax: 5000, xpMin: 800, xpMax: 2000, gems: 5 },
|
||
ui: { icon: 'fa-radiation', color: '#8e44ad' }
|
||
},
|
||
weekly_pirate_fortress: {
|
||
id: 'weekly_pirate_fortress',
|
||
name: 'Pirate Fortress Siege',
|
||
type: 'weekly',
|
||
description: 'Assault a fortified pirate stronghold for rare weapons and credits.',
|
||
minPlayers: 2,
|
||
maxPlayers: 5,
|
||
minLevel: 3,
|
||
phases: 4,
|
||
energyCost: 25,
|
||
cooldownHours: 168,
|
||
lootTable: [
|
||
{ itemId: 'raid_pirate_flag', weight: 5, qtyMin: 1, qtyMax: 1 },
|
||
{ itemId: 'assault_rifle_rare', weight: 15, qtyMin: 1, qtyMax: 1 },
|
||
{ itemId: 'iron_ore', weight: 30, qtyMin: 5, qtyMax: 15 },
|
||
{ itemId: 'bandage', weight: 25, qtyMin: 2, qtyMax: 5 },
|
||
{ itemId: 'health_kit_large', weight: 25, qtyMin: 1, qtyMax: 3 },
|
||
],
|
||
rewards: { creditsMin: 3000, creditsMax: 8000, xpMin: 600, xpMax: 1500, gems: 3 },
|
||
ui: { icon: 'fa-skull-crossbones', color: '#e67e22' }
|
||
},
|
||
monthly_void_titan: {
|
||
id: 'monthly_void_titan',
|
||
name: 'Void Titan — World Boss',
|
||
type: 'monthly',
|
||
description: 'A colossal entity tears through the sector. All pilots must unite to bring it down.',
|
||
minPlayers: 1,
|
||
maxPlayers: 20,
|
||
minLevel: 8,
|
||
phases: 1,
|
||
energyCost: 50,
|
||
cooldownHours: 720, // 30 days
|
||
bossId: 'void_titan',
|
||
bossStats: { health: 500000, attack: 80, defense: 40, phase_thresholds: [0.75, 0.5, 0.25] },
|
||
lootTable: [
|
||
{ itemId: 'raid_titan_shard', weight: 10, qtyMin: 1, qtyMax: 2 },
|
||
{ itemId: 'void_crystal_rare', weight: 15, qtyMin: 1, qtyMax: 3 },
|
||
{ itemId: 'helmet_tactical_epic', weight: 8, qtyMin: 1, qtyMax: 1 },
|
||
{ itemId: 'body_exosuit_epic', weight: 8, qtyMin: 1, qtyMax: 1 },
|
||
{ itemId: 'xp_booster', weight: 35, qtyMin: 2, qtyMax: 4 },
|
||
{ itemId: 'credit_multiplier', weight: 24, qtyMin: 1, qtyMax: 2 },
|
||
],
|
||
rewards: { creditsMin: 10000, creditsMax: 25000, xpMin: 3000, xpMax: 8000, gems: 15 },
|
||
ui: { icon: 'fa-dragon', color: '#ff4488' }
|
||
}
|
||
};
|
||
}
|
||
|
||
getRaid(id) { return this.getRaidDefinitions()[id] || null; }
|
||
getAllRaids() { return Object.values(this.getRaidDefinitions()); }
|
||
getWeeklyRaids() { return this.getAllRaids().filter(r => r.type === 'weekly'); }
|
||
getMonthlyRaids() { return this.getAllRaids().filter(r => r.type === 'monthly'); }
|
||
|
||
// ── Reset Windows ─────────────────────────────────────────────────
|
||
getWeeklyResetTime() {
|
||
const now = new Date();
|
||
const day = now.getUTCDay(); // 0=Sun,1=Mon
|
||
const daysUntilMonday = day === 0 ? 1 : (8 - day) % 7 || 7;
|
||
const reset = new Date(now);
|
||
reset.setUTCDate(now.getUTCDate() + daysUntilMonday);
|
||
reset.setUTCHours(0, 0, 0, 0);
|
||
return reset;
|
||
}
|
||
|
||
getMonthlyResetTime() {
|
||
const now = new Date();
|
||
const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
||
return reset;
|
||
}
|
||
|
||
// ── Party Queue ───────────────────────────────────────────────────
|
||
queueForRaid(raidId, userId, username, allianceId, playerData) {
|
||
const def = this.getRaid(raidId);
|
||
if (!def) return { success: false, error: 'Unknown raid' };
|
||
|
||
if (def.type === 'weekly' && !allianceId) {
|
||
return { success: false, error: 'Must be in an alliance for weekly raids' };
|
||
}
|
||
if ((playerData.stats?.level || 1) < def.minLevel) {
|
||
return { success: false, error: `Requires level ${def.minLevel}` };
|
||
}
|
||
if ((playerData.resources?.energyCells || 0) < def.energyCost) {
|
||
return { success: false, error: `Requires ${def.energyCost} Energy Cells` };
|
||
}
|
||
|
||
const queueKey = def.type === 'weekly' ? allianceId : 'global';
|
||
if (!this.raidQueue.has(queueKey)) this.raidQueue.set(queueKey, {});
|
||
const q = this.raidQueue.get(queueKey);
|
||
if (!q[raidId]) q[raidId] = [];
|
||
|
||
// Prevent duplicate queue
|
||
if (q[raidId].find(p => p.userId === userId)) {
|
||
return { success: false, error: 'Already queued for this raid' };
|
||
}
|
||
|
||
q[raidId].push({ userId, username, allianceId, joinedAt: Date.now() });
|
||
|
||
const party = q[raidId];
|
||
if (party.length >= def.minPlayers) {
|
||
const launched = this._launchRaid(raidId, def, party.slice(0, def.maxPlayers), queueKey);
|
||
q[raidId] = party.slice(def.maxPlayers); // leftover back in queue
|
||
return { success: true, launched: true, raidId: launched.raidId, party: launched.party };
|
||
}
|
||
|
||
return { success: true, launched: false, queued: true, partySize: party.length, waiting: def.minPlayers - party.length };
|
||
}
|
||
|
||
_launchRaid(defId, def, party, queueKey) {
|
||
const raidId = `raid_${defId}_${Date.now()}`;
|
||
const instance = {
|
||
raidId,
|
||
defId,
|
||
type: def.type,
|
||
name: def.name,
|
||
party: party.map(p => ({ ...p, alive: true, damage: 0 })),
|
||
phase: 1,
|
||
totalPhases: def.phases,
|
||
bossHp: def.bossStats?.health || (def.phases * 1000),
|
||
maxBossHp: def.bossStats?.health || (def.phases * 1000),
|
||
startedAt: Date.now(),
|
||
completed: false,
|
||
failed: false,
|
||
lootTable: def.lootTable,
|
||
rewards: def.rewards,
|
||
log: []
|
||
};
|
||
this.activeRaids.set(raidId, instance);
|
||
|
||
// Notify all party members
|
||
if (this.io) {
|
||
party.forEach(p => {
|
||
const sockets = [...this.io.sockets.sockets.values()].filter(s => s.userId === p.userId);
|
||
sockets.forEach(s => s.emit('raid_started', {
|
||
raidId, name: def.name, phase: 1, totalPhases: def.phases,
|
||
party: party.map(m => m.username), bossHp: instance.bossHp
|
||
}));
|
||
});
|
||
}
|
||
return instance;
|
||
}
|
||
|
||
// ── Encounter Processing ──────────────────────────────────────────
|
||
processRaidAction(raidId, userId, action = 'attack') {
|
||
const raid = this.activeRaids.get(raidId);
|
||
if (!raid) return { success: false, error: 'Raid not found' };
|
||
if (raid.completed || raid.failed) return { success: false, error: 'Raid already ended' };
|
||
|
||
const member = raid.party.find(p => p.userId === userId);
|
||
if (!member) return { success: false, error: 'Not in this raid party' };
|
||
if (!member.alive) return { success: false, error: 'You have been defeated' };
|
||
|
||
// Simple authoritative tick: each action = one attack round for this player
|
||
const baseDmg = 50 + Math.floor(Math.random() * 50); // 50–100 per action
|
||
const partyMult = 1 + (raid.party.filter(p => p.alive).length - 1) * 0.15; // +15% per extra live member
|
||
const dealt = Math.round(baseDmg * partyMult);
|
||
|
||
member.damage += dealt;
|
||
raid.bossHp = Math.max(0, raid.bossHp - dealt);
|
||
|
||
raid.log.push({ userId, username: member.username, action, dealt, bossHp: raid.bossHp, at: Date.now() });
|
||
|
||
// Check phase advance
|
||
const phasePct = 1 - (raid.bossHp / raid.maxBossHp);
|
||
const nextPhase = Math.min(raid.totalPhases, Math.floor(phasePct * raid.totalPhases) + 1);
|
||
if (nextPhase > raid.phase) {
|
||
raid.phase = nextPhase;
|
||
if (this.io) {
|
||
raid.party.forEach(p => {
|
||
const sockets = [...this.io.sockets.sockets.values()].filter(s => s.userId === p.userId);
|
||
sockets.forEach(s => s.emit('raid_phase_change', { raidId, phase: raid.phase, bossHp: raid.bossHp }));
|
||
});
|
||
}
|
||
}
|
||
|
||
// Boss defeated
|
||
if (raid.bossHp <= 0) {
|
||
raid.completed = true;
|
||
const loot = this._rollLoot(raid.lootTable);
|
||
return { success: true, bossDefeated: true, raidId, loot, rewards: raid.rewards, damage: member.damage };
|
||
}
|
||
|
||
return { success: true, bossDefeated: false, dealt, bossHp: raid.bossHp, bossMaxHp: raid.maxBossHp, phase: raid.phase };
|
||
}
|
||
|
||
_rollLoot(table) {
|
||
const totalWeight = table.reduce((s, e) => s + e.weight, 0);
|
||
const results = [];
|
||
// Each party member gets ~2 loot rolls
|
||
for (let i = 0; i < 2; i++) {
|
||
let roll = Math.random() * totalWeight;
|
||
for (const entry of table) {
|
||
roll -= entry.weight;
|
||
if (roll <= 0) {
|
||
const qty = entry.qtyMin + Math.floor(Math.random() * (entry.qtyMax - entry.qtyMin + 1));
|
||
results.push({ itemId: entry.itemId, qty });
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
getRaidInstance(raidId) { return this.activeRaids.get(raidId) || null; }
|
||
getPlayerRaid(userId) {
|
||
for (const [, raid] of this.activeRaids) {
|
||
if (!raid.completed && !raid.failed && raid.party.find(p => p.userId === userId)) return raid;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
cleanup() {
|
||
// Remove completed/failed raids older than 2h
|
||
const cutoff = Date.now() - 2 * 60 * 60 * 1000;
|
||
for (const [id, raid] of this.activeRaids) {
|
||
if ((raid.completed || raid.failed) && raid.startedAt < cutoff) {
|
||
this.activeRaids.delete(id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = RaidSystem;
|