2962 lines
128 KiB
JavaScript
2962 lines
128 KiB
JavaScript
// Master of Vega — the engine. Headless: no Phaser, importable by Node, so
|
||
// tools/verifyMasterOfVega.js can play whole games without a browser.
|
||
//
|
||
// Every function takes (rules, state, ...). `state` is plain JSON with the RNG
|
||
// cursor inside it, so serialising the state serialises the future too: replay
|
||
// a seed and you get the same galaxy, the same battles, the same winner.
|
||
//
|
||
// Economy model: MOO1's five allocation sliders, with MOO2 colony buildings
|
||
// acting as multipliers on the channel they name. The "Construction" channel
|
||
// funds the colony build queue (ships AND buildings); Industry builds factories;
|
||
// Ecology cleans industrial waste; Defence raises planetary defences; Research
|
||
// splits into the six tech fields.
|
||
|
||
import {
|
||
generateGalaxy, parsecs, mulberry32, PARSEC_PX,
|
||
} from './VegaGalaxyGen.js';
|
||
import { techCost, techCostFactor } from './VegaRules.js';
|
||
import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js';
|
||
// Space combat is resolved by the V2 per-ship engine (formerly a ?movsim-only
|
||
// prototype) — Brian's explicit call, after V2's momentum/collision-avoidance/
|
||
// formation work matured and its per-tick cost was brought down (see
|
||
// docs/mastervega-build-plan.md's "V2 becomes the official combat engine"
|
||
// entry). resolveInvasion (ground combat) is untouched — it has no
|
||
// battle-shaped dependency on either engine.
|
||
import { createBattle, runBattle } from './VegaCombatV2.js';
|
||
import { resolveInvasion } from './VegaCombat.js';
|
||
import { breakStalemate, declareWar, runGalaxyDiplomacyPass } from './VegaDiplomacy.js';
|
||
|
||
export const CHANNELS = ['ships', 'defense', 'industry', 'ecology', 'research'];
|
||
|
||
// --------------------------------------------------------------------------
|
||
// RNG — explicit state so it serialises with the game.
|
||
|
||
export function rand(state) {
|
||
let a = state.rngState | 0;
|
||
a = (a + 0x6d2b79f5) | 0;
|
||
state.rngState = a;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
}
|
||
export function randInt(state, n) { return Math.floor(rand(state) * n); }
|
||
|
||
export function attachRules(state, rules) { state.rules = rules; return state; }
|
||
|
||
// `seq` is a permanent, ever-increasing id independent of the event's
|
||
// position in `state.events` — VegaAI.js's updateWarMemory (per-empire
|
||
// combat-outcome tracking) needs a cursor that survives the trim below
|
||
// unscathed; an array-index or count-based cursor would silently go stale
|
||
// (or worse, skip real events) the moment a trim ran between two reads.
|
||
const pushEvent = (state, ev) => {
|
||
ev.seq = state.nextEventSeq += 1;
|
||
state.events.push(ev);
|
||
if (state.events.length > 600) state.events = state.events.slice(-300);
|
||
};
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Lookups
|
||
|
||
export const empireColonies = (state, e) => state.colonies.filter((c) => c.empireIdx === e);
|
||
export const empireFleets = (state, e) => state.fleets.filter((f) => f.empireIdx === e);
|
||
export const colonyAt = (state, starIdx) => state.colonies.find((c) => c.starIdx === starIdx);
|
||
export const coloniesAt = (state, starIdx) => state.colonies.filter((c) => c.starIdx === starIdx);
|
||
|
||
/**
|
||
* Colonies grouped by star for one empire, star groups ordered by that
|
||
* star's total population (highest first) — the shape the Colonies
|
||
* screen's spreadsheet (VegaColoniesScreen.js) needs. Colonies within a
|
||
* group are also ordered by population, highest first.
|
||
*/
|
||
export function empireColoniesByStar(state, e) {
|
||
const groups = new Map();
|
||
for (const colony of empireColonies(state, e)) {
|
||
let g = groups.get(colony.starIdx);
|
||
if (!g) { g = { starIdx: colony.starIdx, colonies: [], totalPop: 0 }; groups.set(colony.starIdx, g); }
|
||
g.colonies.push(colony);
|
||
g.totalPop += colony.pop;
|
||
}
|
||
const out = [...groups.values()];
|
||
out.forEach((g) => g.colonies.sort((a, b) => b.pop - a.pop));
|
||
out.sort((a, b) => b.totalPop - a.totalPop);
|
||
return out;
|
||
}
|
||
export const fleetsAt = (state, starIdx) => state.fleets.filter((f) => f.starIdx === starIdx);
|
||
export const starOf = (state, i) => state.galaxy.stars[i];
|
||
export const planetOf = (state, colony) => state.galaxy.stars[colony.starIdx].planets[colony.orbit];
|
||
|
||
// designFor() runs a knapsack per call. The AI, the upkeep pass and the fleet
|
||
// panel all ask for the same handful of designs many times per turn, so they
|
||
// are memoised per empire and invalidated whenever a tech lands.
|
||
export function empireDesign(rules, state, e, hullId, skills = null) {
|
||
const emp = state.empires[e];
|
||
if (skills) return designFor(rules, emp.known, hullId, rules.species[emp.speciesId].traits, skills);
|
||
if (!emp._designs || emp._designsAt !== emp.techsKnown) {
|
||
emp._designs = {};
|
||
emp._designsAt = emp.techsKnown;
|
||
}
|
||
if (!emp._designs[hullId]) {
|
||
emp._designs[hullId] = designFor(rules, emp.known, hullId, rules.species[emp.speciesId].traits);
|
||
}
|
||
return emp._designs[hullId];
|
||
}
|
||
|
||
export function empireComponents(rules, state, e) {
|
||
const emp = state.empires[e];
|
||
if (!emp._comps || emp._compsAt !== emp.techsKnown) {
|
||
emp._comps = bestComponents(rules, emp.known);
|
||
emp._compsAt = emp.techsKnown;
|
||
}
|
||
return emp._comps;
|
||
}
|
||
|
||
// Leader skill bags, merged. Admins bind to one colony; captains bind to a
|
||
// fleet. Anything unassigned still costs upkeep but does nothing, which is the
|
||
// player's problem.
|
||
export function colonyLeaderSkills(rules, state, colony) {
|
||
const emp = state.empires[colony.empireIdx];
|
||
const l = emp.leaders.find((x) => x.assignKind === 'colony' && x.assignId === colony.id);
|
||
return l ? rules.leaders[l.leaderId].skills : {};
|
||
}
|
||
export function fleetLeaderSkills(rules, state, fleet) {
|
||
const emp = state.empires[fleet.empireIdx];
|
||
const l = emp.leaders.find((x) => x.assignKind === 'fleet' && x.assignId === fleet.id);
|
||
return l ? rules.leaders[l.leaderId].skills : {};
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Colony derived numbers
|
||
|
||
export function buildingMult(rules, colony, channel) {
|
||
let m = 1;
|
||
for (const bid of colony.buildings) {
|
||
const b = rules.buildings[bid];
|
||
if (b && b.channel === channel) m *= b.mult;
|
||
}
|
||
return m;
|
||
}
|
||
function buildingEffect(rules, colony, key) {
|
||
let total = 0;
|
||
for (const bid of colony.buildings) {
|
||
const v = rules.buildings[bid]?.effects?.[key];
|
||
if (typeof v === 'number') total += v;
|
||
}
|
||
return total;
|
||
}
|
||
function buildingEffectMult(rules, colony, key) {
|
||
let m = 1;
|
||
for (const bid of colony.buildings) {
|
||
const v = rules.buildings[bid]?.effects?.[key];
|
||
if (typeof v === 'number') m *= v;
|
||
}
|
||
return m;
|
||
}
|
||
|
||
export function colonyMaxPop(rules, state, colony) {
|
||
const emp = state.empires[colony.empireIdx];
|
||
const spec = rules.species[emp.speciesId];
|
||
const planet = planetOf(state, colony);
|
||
const comps = empireComponents(rules, state, colony.empireIdx);
|
||
const skills = colonyLeaderSkills(rules, state, colony);
|
||
const base = planet.basePop * spec.traits.maxPopMult;
|
||
const bonus = comps.maxPopBonus + buildingEffect(rules, colony, 'maxPopBonus') + (skills.maxPopBonus ?? 0);
|
||
const raw = base + bonus;
|
||
// Uncleaned industrial waste poisons the biosphere; Lithox never generate any.
|
||
const penalty = Math.min(0.75, colony.waste / Math.max(1, planet.basePop * 2));
|
||
return Math.max(1, Math.round(raw * (1 - penalty)));
|
||
}
|
||
|
||
export function colonyFactoryCap(rules, state, colony) {
|
||
const emp = state.empires[colony.empireIdx];
|
||
const spec = rules.species[emp.speciesId];
|
||
return Math.round(colony.pop * spec.traits.factoriesPerPop);
|
||
}
|
||
|
||
// Population runs a colony's factories, so a colony that shrinks cannot work
|
||
// all of them. The surplus is mothballed rather than demolished — it neither
|
||
// produces nor pollutes, and comes back if the population recovers. Without
|
||
// this, a colony that loses population keeps generating waste from factories
|
||
// nobody is left to staff, which is a death spiral it can never escape.
|
||
export function effectiveFactories(rules, state, colony) {
|
||
return Math.min(colony.factories, colonyFactoryCap(rules, state, colony));
|
||
}
|
||
|
||
export function colonyProduction(rules, state, colony) {
|
||
const emp = state.empires[colony.empireIdx];
|
||
const spec = rules.species[emp.speciesId];
|
||
const planet = planetOf(state, colony);
|
||
const rich = rules.richness[planet.richId]?.industryMult ?? 1;
|
||
const skills = colonyLeaderSkills(rules, state, colony);
|
||
const eco = rules.economy;
|
||
const fromPop = colony.pop * eco.popOutput;
|
||
const fromFactories = effectiveFactories(rules, state, colony) * eco.factoryOutput * rich
|
||
* spec.traits.industryMult
|
||
* buildingMult(rules, colony, 'industry')
|
||
* (skills.industryMult ?? 1);
|
||
return fromPop + fromFactories;
|
||
}
|
||
|
||
export function colonyDefenseCap(rules, state, colony) {
|
||
const comps = empireComponents(rules, state, colony.empireIdx);
|
||
return Math.round((100 + comps.planetaryShield * 30) * buildingMult(rules, colony, 'defense'));
|
||
}
|
||
|
||
/**
|
||
* BC per turn actually reaching the build queue, used for the "N turns" figures
|
||
* the colony screen quotes. That is the `ships` share PLUS the two spillovers
|
||
* `processColony` folds in: industry whose factories are already capped, and
|
||
* defence whose batteries are already topped up.
|
||
*
|
||
* It has to live here rather than in a view because two different screens quote
|
||
* the same ETA and they must agree. It is an estimate by nature — mandatory
|
||
* ecology cleanup is taken pro-rata off every channel first, and how much that
|
||
* costs depends on waste generated during the turn — so it is deliberately
|
||
* quoted before cleanup rather than trying to predict it.
|
||
*/
|
||
export function colonyBuildRate(rules, state, colony) {
|
||
const prod = colonyProduction(rules, state, colony);
|
||
let rate = prod * (colony.sliders.ships ?? 0);
|
||
if (colony.factories >= colonyFactoryCap(rules, state, colony)) {
|
||
rate += prod * (colony.sliders.industry ?? 0);
|
||
}
|
||
if (colony.defenseHp >= colonyDefenseCap(rules, state, colony)) {
|
||
rate += prod * (colony.sliders.defense ?? 0);
|
||
}
|
||
return Math.max(0, rate);
|
||
}
|
||
|
||
/** Turns for a queue item to finish at the current rate, or Infinity if idle. */
|
||
export function queueItemEta(rules, state, colony, item, alreadyQueued = 0) {
|
||
const rate = colonyBuildRate(rules, state, colony);
|
||
if (rate <= 0) return Infinity;
|
||
const remaining = queueItemCost(rules, state, colony, item) - (item.progress ?? 0);
|
||
return Math.max(1, Math.ceil((alreadyQueued + remaining) / rate));
|
||
}
|
||
|
||
/**
|
||
* Turns until each queue entry finishes, CUMULATIVE down the queue — the engine
|
||
* spends on `queue[0]` alone, so an item's ETA includes everything ahead of it.
|
||
* Indexed to match `colony.queue`, so a collapsed run reads its figure off its
|
||
* own `lastIndex`. Both the colony screen and the system view quote these and
|
||
* they have to agree, which is why it is here rather than in either view.
|
||
*/
|
||
export function queueEtas(rules, state, colony) {
|
||
const rate = colonyBuildRate(rules, state, colony);
|
||
let acc = 0;
|
||
return colony.queue.map((item) => {
|
||
acc += Math.max(0, queueItemCost(rules, state, colony, item) - (item.progress ?? 0));
|
||
return rate > 0 ? Math.max(1, Math.ceil(acc / rate)) : Infinity;
|
||
});
|
||
}
|
||
|
||
export function colonyTrade(rules, state, colony) {
|
||
const emp = state.empires[colony.empireIdx];
|
||
const spec = rules.species[emp.speciesId];
|
||
const skills = colonyLeaderSkills(rules, state, colony);
|
||
const eco = rules.economy;
|
||
return colony.pop * eco.tradePerPop
|
||
* spec.traits.tradeMult
|
||
* buildingEffectMult(rules, colony, 'tradeMult')
|
||
* (skills.tradeMult ?? 1)
|
||
+ buildingEffect(rules, colony, 'tradeBonus');
|
||
}
|
||
|
||
export function colonyGroundDefense(rules, state, colony) {
|
||
const emp = state.empires[colony.empireIdx];
|
||
const spec = rules.species[emp.speciesId];
|
||
const comps = empireComponents(rules, state, colony.empireIdx);
|
||
const skills = colonyLeaderSkills(rules, state, colony);
|
||
const planet = planetOf(state, colony);
|
||
const grav = rules.gravity[planet.gravId];
|
||
// High gravity punishes attackers and defenders alike unless you were born
|
||
// on a heavy world.
|
||
const gravMod = spec.traits.highGravityOk ? 0 : (grav?.combatMod ?? 0);
|
||
return (spec.traits.groundDefense ?? 0)
|
||
+ (comps.groundDefense ?? 0)
|
||
+ buildingEffect(rules, colony, 'groundDefense')
|
||
+ (skills.groundDefense ?? 0)
|
||
+ gravMod;
|
||
}
|
||
|
||
// Can this empire settle this planet at all?
|
||
export function canColonize(rules, state, e, starIdx, orbit) {
|
||
if (colonyAt(state, starIdx) && coloniesAt(state, starIdx).some((c) => c.orbit === orbit)) return false;
|
||
return habitableForEmpire(rules, state, e, starIdx, orbit);
|
||
}
|
||
|
||
// Whether this planet is within an empire's current colonisation reach —
|
||
// type + hostility vs. current planetology tech — regardless of whether it is
|
||
// already settled. Split out of canColonize so the star map and tooltip's "N
|
||
// habitable" readout can match what the colony screen actually offers instead
|
||
// of just the planet type's static colonizable flag (which ignores tech and
|
||
// made "1 habitable" show even for worlds too hostile to settle yet).
|
||
export function habitableForEmpire(rules, state, e, starIdx, orbit) {
|
||
const planet = state.galaxy.stars[starIdx]?.planets?.[orbit];
|
||
if (!planet) return false;
|
||
const type = rules.planetTypes[planet.typeId];
|
||
if (!type.colonizable) return false;
|
||
const emp = state.empires[e];
|
||
const spec = rules.species[emp.speciesId];
|
||
if (spec.traits.colonizeAnything) return true;
|
||
const comps = empireComponents(rules, state, e);
|
||
return type.hostility <= comps.colonizeHostility;
|
||
}
|
||
|
||
// How many colonizable worlds this empire has actually SEEN (explored[])
|
||
// that nobody has claimed yet — the "Galactic Expansion" Colony Focus's
|
||
// build target, and what the advisor recommendations gauge "is the galaxy
|
||
// still open" against. Deliberately explored-gated rather than every
|
||
// canColonize world in the galaxy (which would recommend expansion into
|
||
// worlds the player has no idea exist yet), and deliberately NOT further
|
||
// gated by current fuel range like VegaAI.js's own targeting scan — a world
|
||
// just past today's range is still "available" once a colony ship reaches
|
||
// it, and fuel range only grows with tech.
|
||
export function discoveredOpenWorlds(rules, state, e) {
|
||
const emp = state.empires[e];
|
||
let count = 0;
|
||
for (let starIdx = 0; starIdx < state.galaxy.stars.length; starIdx += 1) {
|
||
if (!emp.explored[starIdx]) continue;
|
||
const star = state.galaxy.stars[starIdx];
|
||
for (let orbit = 0; orbit < star.planets.length; orbit += 1) {
|
||
if (canColonize(rules, state, e, starIdx, orbit)) count += 1;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
// Whether "Galactic Expansion" (Colony Focus) / "Expansion" (Allocation
|
||
// Focus) should be the advisors' top pick right now (Brian's ask,
|
||
// 2026-08-14): early game, before any rival has been met, land grabs are
|
||
// unambiguously the right call — but the instant either condition flips
|
||
// (first contact, or every known world already claimed) it drops out of the
|
||
// running entirely rather than fading gradually, so the player gets a clean
|
||
// "that phase is over" signal instead of a slowly-diminishing nudge.
|
||
function expansionFavored(rules, state, e) {
|
||
const hasMetAnyone = state.empires.some((o) => o.alive && o.idx !== e && state.empires[e].contacted[o.idx]);
|
||
const openTargets = discoveredOpenWorlds(rules, state, e);
|
||
return { favored: !hasMetAnyone && openTargets > 0, openTargets };
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Range — the star map's "range as light", and the engine's movement rule.
|
||
|
||
// Every star within fuel range of a colony or star base. Recomputed lazily and
|
||
// cached per turn; the AI asks for it constantly.
|
||
export function reachableStars(rules, state, e) {
|
||
const emp = state.empires[e];
|
||
if (emp._rangeAt === state.turn && emp._range) return emp._range;
|
||
const comps = empireComponents(rules, state, e);
|
||
let range = comps.fuelRange;
|
||
const sources = [];
|
||
for (const c of empireColonies(state, e)) {
|
||
sources.push(c.starIdx);
|
||
if (c.buildings.includes('starbase')) range = Math.max(range, comps.fuelRange + (rules.economy.starbaseRangeBonus ?? 3));
|
||
}
|
||
for (const f of empireFleets(state, e)) {
|
||
if (f.starIdx >= 0 && f.ships.some((s) => s.hullId === 'starbase')) sources.push(f.starIdx);
|
||
}
|
||
const out = {};
|
||
for (const src of sources) {
|
||
for (let i = 0; i < state.galaxy.stars.length; i += 1) {
|
||
if (out[i]) continue;
|
||
if (parsecs(state.galaxy, src, i) <= range + 1e-9) out[i] = true;
|
||
}
|
||
}
|
||
emp._range = out;
|
||
emp._rangeAt = state.turn;
|
||
return out;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Sensors — battlescanner and friends' scanRange tech effect (Brian's ask,
|
||
// 2026-08-14). Unlike reachableStars/fuelRange, this isn't cached per turn:
|
||
// it is only ever asked for on a fleet click, not in a hot AI loop, so the
|
||
// extra cache bookkeeping (and the extra invalidation sites that would need
|
||
// it) buys nothing here.
|
||
|
||
// A fleet's current position in star-map pixel coordinates: its own star's,
|
||
// if docked, or interpolated along its lane if in transit — the same lerp
|
||
// VegaStarMap.js's refreshFleets uses to DRAW an in-transit marker, just
|
||
// reused here (headless, no Phaser) to MEASURE distance to it instead.
|
||
function fleetPosition(state, fleet) {
|
||
if (fleet.starIdx >= 0) {
|
||
const star = state.galaxy.stars[fleet.starIdx];
|
||
return star ? { x: star.x, y: star.y } : null;
|
||
}
|
||
const a = state.galaxy.stars[fleet.fromStar];
|
||
const b = state.galaxy.stars[fleet.toStar];
|
||
if (!a || !b) return null;
|
||
const t = fleet.total > 0 ? Math.min(1, Math.max(0, fleet.progress / fleet.total)) : 0;
|
||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||
}
|
||
|
||
// Every point this empire could currently be detecting FROM: its colonies
|
||
// and its own fleets (docked or in transit), unlike reachableStars' sources
|
||
// (colonies + starbases only) — a sensor reach is about where your hulls
|
||
// actually ARE right now, not where you could send something from.
|
||
function scanSources(state, e) {
|
||
const sources = [];
|
||
for (const c of empireColonies(state, e)) {
|
||
const star = state.galaxy.stars[c.starIdx];
|
||
if (star) sources.push({ x: star.x, y: star.y });
|
||
}
|
||
for (const f of empireFleets(state, e)) {
|
||
const p = fleetPosition(state, f);
|
||
if (p) sources.push(p);
|
||
}
|
||
return sources;
|
||
}
|
||
|
||
/**
|
||
* Whether `viewerE` can currently detect `fleet` (any empire's, including
|
||
* its own — always true then via a zero-distance source) on sensors: within
|
||
* scanRange parsecs of one of the viewer's own colonies or fleets. Zero
|
||
* scanRange (no battlescanner-line tech yet) means nothing is ever
|
||
* detected — this is what turns "reveals enemy fleet composition" from
|
||
* flavor text into a real mechanic (see VegaTechEffects.js's scanRange
|
||
* formatter, and MasterOfVegaGame.js's onFleetClick / VegaSidePanel.js's
|
||
* buildFleet for what unlocks behind it).
|
||
*/
|
||
export function fleetInScanRange(rules, state, viewerE, fleet) {
|
||
const range = empireComponents(rules, state, viewerE).scanRange;
|
||
if (range <= 0) return false;
|
||
const pos = fleetPosition(state, fleet);
|
||
if (!pos) return false;
|
||
return scanSources(state, viewerE).some(
|
||
(s) => Math.hypot(s.x - pos.x, s.y - pos.y) / PARSEC_PX <= range + 1e-9,
|
||
);
|
||
}
|
||
|
||
export function fleetSpeed(rules, state, fleet) {
|
||
const emp = state.empires[fleet.empireIdx];
|
||
const spec = rules.species[emp.speciesId];
|
||
const skills = fleetLeaderSkills(rules, state, fleet);
|
||
// Immobile hulls (star bases) are SKIPPED, not disqualifying.
|
||
//
|
||
// Returning 0 for any fleet containing a star base looked reasonable and was
|
||
// catastrophic: a completed Star Base joins the fleet sitting over its own
|
||
// colony, that fleet is the empire's main battle fleet, and from that moment
|
||
// canSendFleet refused every order it was ever given. Measured over one game,
|
||
// 338 of 338 valid attacks — in range, strong enough, at war — were refused
|
||
// for this reason alone. No colony could ever be attacked, so no war could be
|
||
// won and conquest was unreachable. sendFleet() leaves the bases behind.
|
||
let speed = Infinity;
|
||
let mobile = 0;
|
||
for (const s of fleet.ships) {
|
||
if (s.count <= 0) continue;
|
||
const d = empireDesign(rules, state, fleet.empireIdx, s.hullId, Object.keys(skills).length ? skills : null);
|
||
if (d.immobile) continue;
|
||
mobile += 1;
|
||
speed = Math.min(speed, d.speed);
|
||
}
|
||
if (!mobile) return 0;
|
||
return Number.isFinite(speed) ? Math.max(1, speed) : 0;
|
||
}
|
||
|
||
export function fleetPower(rules, state, fleet) {
|
||
const emp = state.empires[fleet.empireIdx];
|
||
const spec = rules.species[emp.speciesId];
|
||
const skills = fleetLeaderSkills(rules, state, fleet);
|
||
let p = 0;
|
||
for (const s of fleet.ships) {
|
||
if (s.count <= 0) continue;
|
||
const d = empireDesign(rules, state, fleet.empireIdx, s.hullId, Object.keys(skills).length ? skills : null);
|
||
if (d.role !== 'warship' && d.role !== 'base') continue;
|
||
p += s.count * (d.hp + d.damage * 4);
|
||
}
|
||
return Math.round(p);
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Game creation
|
||
|
||
// MOO1's actual rule, corrected: racial skill (techAffinity, via
|
||
// techCostFactor) changes what a tech COSTS, never whether it exists in your
|
||
// tree. Availability is a flat roll per tech — 50% for every species, 75%
|
||
// for Cerebrai, the one species built around research breadth (uniformly
|
||
// >=1.2 techAffinity across every field, researchMult 2 — our closest
|
||
// analogue to Psilons). The one guarantee is that a rung of 2+ alternatives
|
||
// can never end up with zero available techs; a size-1 rung (most of the
|
||
// tree today) has nothing to fall back on, so its lone tech can still
|
||
// legitimately miss its roll and simply be skipped (rungCleared) — that is
|
||
// what makes tech trading matter for the majority of the tree, not just the
|
||
// branched rungs.
|
||
const PSILON_ANALOGUE = 'cerebrai';
|
||
|
||
export function rollTechAvailability(state, rules, emp, spec) {
|
||
const available = {};
|
||
const chance = spec.id === PSILON_ANALOGUE ? 0.75 : 0.5;
|
||
for (const t of rules.techList) {
|
||
available[t.id] = t.tier === 0 ? true : rand(state) < chance;
|
||
}
|
||
for (const field of Object.keys(rules.techFields)) {
|
||
for (const rung of rules.techRungsByField[field]) {
|
||
if (rung.techs.length <= 1) continue;
|
||
if (rung.techs.every((t) => !available[t.id])) {
|
||
available[rung.techs[randInt(state, rung.techs.length)].id] = true;
|
||
}
|
||
}
|
||
}
|
||
return available;
|
||
}
|
||
|
||
export function createGame(rules, opts) {
|
||
const {
|
||
sizeId = 'medium', shapeId = 'spiral', seed = 1, difficultyId = 'normal',
|
||
speciesIds = ['human', 'kkrix', 'rrashaa'], humanIndex = 0, homeColonyName = null,
|
||
} = opts;
|
||
|
||
const state = {
|
||
version: 1,
|
||
rules: null,
|
||
seed,
|
||
rngState: (seed * 2654435761) | 0,
|
||
sizeId, shapeId, difficultyId,
|
||
turn: 0,
|
||
current: 0,
|
||
humanIndex,
|
||
galaxy: null,
|
||
empires: [],
|
||
colonies: [],
|
||
fleets: [],
|
||
nextColonyId: 0,
|
||
nextFleetId: 0,
|
||
nextEventSeq: 0,
|
||
events: [],
|
||
council: {
|
||
nextTurn: rules.council.firstTurn, lastResult: null, history: [], pendingSession: false,
|
||
},
|
||
gnn: { history: [] },
|
||
over: false,
|
||
winnerIdx: -1,
|
||
victoryKind: null,
|
||
};
|
||
|
||
state.galaxy = generateGalaxy(rules, { sizeId, shapeId, seed, speciesIds });
|
||
const diff = rules.difficulties[difficultyId];
|
||
|
||
speciesIds.forEach((sid, e) => {
|
||
const spec = rules.species[sid];
|
||
const emp = {
|
||
idx: e,
|
||
speciesId: sid,
|
||
name: spec.name,
|
||
color: spec.color,
|
||
alive: true,
|
||
isHuman: e === humanIndex,
|
||
homeStar: state.galaxy.homeIdx[e],
|
||
known: {},
|
||
available: {},
|
||
techsKnown: 0,
|
||
knownInField: { computers: 0, construction: 0, forcefields: 0, planetology: 0, propulsion: 0, weapons: 0 },
|
||
researching: {},
|
||
alloc: {},
|
||
allocLocked: {},
|
||
beakers: {},
|
||
bc: rules.economy.startingBC,
|
||
leaders: [],
|
||
contacted: {},
|
||
treaties: {},
|
||
attitude: {},
|
||
pendingOffers: {},
|
||
tradeAgreements: {},
|
||
lastGiftTurn: {},
|
||
// colony.id -> the turn this empire last bombarded it. Caps bombard()
|
||
// at once per (attacker, colony) per turn — see bombard()'s own
|
||
// comment for why: unlike invade() it spends no resource of its own
|
||
// (warships aren't consumed), so without this cap the UI's Bombard
|
||
// button could be clicked any number of times in one turn.
|
||
lastBombardTurn: {},
|
||
fleetIntrusions: {},
|
||
// enemyIdx -> { losses }, a consecutive-attack-loss streak read by
|
||
// VegaAI.js's manageFleets to demand progressively more force before
|
||
// trying that enemy again rather than attacking at the same losing
|
||
// fleet size on repeat. warMemorySeq is the highest event `seq`
|
||
// (pushEvent, above) already scanned for 'combat' events (VegaAI.js's
|
||
// updateWarMemory) — plain fields, not underscore-prefixed caches,
|
||
// since unlike _comps/_range they aren't recomputable from current
|
||
// tech and have to survive save/load.
|
||
warMemory: {},
|
||
warMemorySeq: 0,
|
||
explored: {},
|
||
spyPoints: 0,
|
||
// otherIdx -> 'steal' | 'sabotage', missing entry means 'steal' (see
|
||
// runEspionage) — every empire behaves exactly as it always has until
|
||
// the human explicitly sets a mission on the Audience screen.
|
||
espionageMission: {},
|
||
totalPop: 0,
|
||
nameCursor: 0,
|
||
// A per-empire shuffle of its species' colonyNames indices, so
|
||
// peekColonyName() hands out that bank in a random (but replay-stable)
|
||
// order instead of always suggesting the same name first. Drawn from a
|
||
// seed derived from the game seed and empire index rather than
|
||
// state.rngState — this is cosmetic, not gameplay, and keeping it off
|
||
// the shared RNG stream means it can never nudge tech rolls, combat, or
|
||
// anything else a replay depends on.
|
||
nameOrder: shuffledIndexes(mulberry32((seed + (e + 1) * 7919) >>> 0), spec.colonyNames.length),
|
||
_comps: null, _compsAt: -1, _range: null, _rangeAt: -1, _designs: null, _designsAt: -1,
|
||
};
|
||
// Research starts spread evenly across the six fields.
|
||
const fields = Object.keys(rules.techFields);
|
||
for (const f of fields) { emp.alloc[f] = 1 / fields.length; emp.beakers[f] = 0; emp.researching[f] = null; }
|
||
emp.available = rollTechAvailability(state, rules, emp, spec);
|
||
state.empires.push(emp);
|
||
});
|
||
|
||
// Mutual ignorance to start; attitudes are neutral except where a species is
|
||
// simply disliked on sight.
|
||
for (const a of state.empires) {
|
||
for (const b of state.empires) {
|
||
if (a.idx === b.idx) continue;
|
||
a.treaties[b.idx] = 'none';
|
||
const spec = rules.species[a.speciesId];
|
||
a.attitude[b.idx] = Math.round((spec.traits.diplomacy ?? 0) / 4);
|
||
}
|
||
}
|
||
|
||
// Homeworlds.
|
||
speciesIds.forEach((sid, e) => {
|
||
const starIdx = state.galaxy.homeIdx[e];
|
||
const name = e === humanIndex ? homeColonyName : null;
|
||
const colony = foundColony(rules, state, e, starIdx, 0, rules.economy.startingPop, name);
|
||
colony.factories = rules.economy.startingFactories;
|
||
colony.capital = true;
|
||
state.empires[e].explored[starIdx] = true;
|
||
checkContactAt(rules, state, starIdx);
|
||
// Everything else starts unexplored — a scout has to physically arrive
|
||
// (see moveFleets' `explored[f.starIdx] = true`) before its details show.
|
||
// Starting fleet: a scout and a colony ship, plus difficulty handicap ships.
|
||
const bonus = e === humanIndex ? 0 : diff.aiStartBonus;
|
||
addFleet(rules, state, e, starIdx, [
|
||
{ hullId: 'scout', mark: 1, count: 1 + bonus },
|
||
{ hullId: 'colonyship', mark: 1, count: 1 },
|
||
...(bonus > 0 ? [{ hullId: 'frigate', mark: 1, count: bonus * 2 }] : []),
|
||
]);
|
||
});
|
||
|
||
recomputeTotals(rules, state);
|
||
return state;
|
||
}
|
||
|
||
function shuffledIndexes(rnd, len) {
|
||
const arr = Array.from({ length: len }, (_, i) => i);
|
||
for (let i = arr.length - 1; i > 0; i -= 1) {
|
||
const j = Math.floor(rnd() * (i + 1));
|
||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
// Read-only preview of the name a new colony for this empire would get,
|
||
// without consuming it from its species' pool — lets UI show a default
|
||
// before the player confirms. Walks the species' 50-name bank in this
|
||
// empire's own shuffled order (`nameOrder`, set once in createGame), so
|
||
// suggestions are randomized but a cursor that only ever moves forward still
|
||
// guarantees no name already in use by this empire is suggested again. A
|
||
// round past the end (which the bank is generous enough to make rare) appends
|
||
// a Roman-numeral suffix rather than repeating a bare name.
|
||
export function peekColonyName(rules, state, e) {
|
||
const emp = state.empires[e];
|
||
const pool = rules.species[emp.speciesId].colonyNames;
|
||
const cursor = emp.nameCursor ?? 0;
|
||
// Saves from before per-empire shuffling existed have no nameOrder (or one
|
||
// sized for an older, shorter bank) — fall back to bank order rather than
|
||
// crashing.
|
||
const order = (emp.nameOrder && emp.nameOrder.length === pool.length)
|
||
? emp.nameOrder : pool.map((_, i) => i);
|
||
const round = Math.floor(cursor / pool.length);
|
||
const name = pool[order[cursor % pool.length]];
|
||
return round > 0 ? `${name} ${'I'.repeat(round + 1)}` : name;
|
||
}
|
||
|
||
function advanceColonyNameCursor(state, e) {
|
||
state.empires[e].nameCursor = (state.empires[e].nameCursor ?? 0) + 1;
|
||
}
|
||
|
||
export function nextColonyName(rules, state, e) {
|
||
const name = peekColonyName(rules, state, e);
|
||
advanceColonyNameCursor(state, e);
|
||
return name;
|
||
}
|
||
|
||
export function foundColony(rules, state, e, starIdx, orbit, pop, name = null) {
|
||
const colonyName = (name && name.trim()) ? name.trim() : peekColonyName(rules, state, e);
|
||
advanceColonyNameCursor(state, e);
|
||
const colony = {
|
||
id: state.nextColonyId += 1,
|
||
empireIdx: e,
|
||
starIdx,
|
||
orbit,
|
||
pop,
|
||
name: colonyName,
|
||
factories: 0,
|
||
waste: 0,
|
||
defenseHp: 0,
|
||
buildings: [],
|
||
queue: [],
|
||
capital: false,
|
||
sliders: { ships: 0.2, defense: 0.1, industry: 0.4, ecology: 0.1, research: 0.2 },
|
||
locked: {},
|
||
focus: 'manual', // 'manual' | 'improvement' | 'research' | 'fleet' | 'growth' | 'trade' | 'defense'
|
||
// Advisor re-analysis tracking (checkAdvisorRecommendations). The Quiet*
|
||
// fields are null until the player first picks something in the
|
||
// corresponding flyout — advisors never nag a colony that has not been
|
||
// touched. allocKey is tracking-only: it does not lock the sliders.
|
||
advisor: {
|
||
focusQuietUntil: null, focusNotifiedValue: null,
|
||
allocQuietUntil: null, allocKey: null, allocNotifiedKey: null,
|
||
},
|
||
founded: state.turn,
|
||
};
|
||
state.colonies.push(colony);
|
||
state.empires[e].explored[starIdx] = true;
|
||
return colony;
|
||
}
|
||
|
||
export function addFleet(rules, state, e, starIdx, ships) {
|
||
// Merge into an existing fleet at the same star rather than littering the map
|
||
// with one-ship fleets.
|
||
const existing = state.fleets.find((f) => f.empireIdx === e && f.starIdx === starIdx && f.toStar < 0);
|
||
if (existing) {
|
||
for (const s of ships) {
|
||
const m = existing.ships.find((x) => x.hullId === s.hullId && x.mark === s.mark);
|
||
if (m) m.count += s.count;
|
||
else existing.ships.push({ ...s });
|
||
}
|
||
return existing;
|
||
}
|
||
const fleet = {
|
||
id: state.nextFleetId += 1,
|
||
empireIdx: e,
|
||
starIdx,
|
||
fromStar: -1,
|
||
toStar: -1,
|
||
progress: 0,
|
||
total: 0,
|
||
ships: ships.map((s) => ({ ...s })),
|
||
};
|
||
state.fleets.push(fleet);
|
||
return fleet;
|
||
}
|
||
|
||
function cleanFleets(state) {
|
||
for (const f of state.fleets) f.ships = f.ships.filter((s) => s.count > 0);
|
||
state.fleets = state.fleets.filter((f) => f.ships.length > 0);
|
||
}
|
||
|
||
// Merge every idle fleet an empire has sitting in the same system. Ships
|
||
// completing while the local fleet happens to be in transit each spawn a new
|
||
// fleet, and over a long game that compounds into hundreds of one-ship stacks —
|
||
// which is both unreadable on the star map and quadratic work for the AI.
|
||
function consolidateFleets(rules, state, e) {
|
||
const byStar = new Map();
|
||
for (const f of state.fleets) {
|
||
if (f.empireIdx !== e || f.starIdx < 0 || f.toStar >= 0) continue;
|
||
// Key on mobility as well as location, so a star-base garrison never gets
|
||
// folded back into the battle fleet it was just split out of.
|
||
const mobile = fleetSpeed(rules, state, f) > 0;
|
||
const key = `${f.starIdx}|${mobile ? 'm' : 'g'}`;
|
||
const head = byStar.get(key);
|
||
if (!head) { byStar.set(key, f); continue; }
|
||
for (const s of f.ships) {
|
||
const m = head.ships.find((x) => x.hullId === s.hullId && x.mark === s.mark);
|
||
if (m) m.count += s.count;
|
||
else head.ships.push({ ...s });
|
||
}
|
||
f.ships = [];
|
||
}
|
||
cleanFleets(state);
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Research
|
||
|
||
// A rung (tech.field + tech.tier) clears once ANY one of its techs is known
|
||
// — MOO1-style: pick one alternative to advance, the rest stay legitimate
|
||
// side-picks rather than being blocked or required. A rung with zero
|
||
// available techs (every option missed its species roll) is also cleared —
|
||
// there's nothing left to wait on, so research simply moves past it.
|
||
function rungCleared(emp, rung) {
|
||
return rung.techs.some((t) => emp.known[t.id]) || rung.techs.every((t) => !emp.available[t.id]);
|
||
}
|
||
|
||
export function canResearch(rules, state, e, tech) {
|
||
const emp = state.empires[e];
|
||
if (emp.known[tech.id] || !emp.available[tech.id]) return false;
|
||
// Every lower rung in the field must be cleared.
|
||
for (const rung of rules.techRungsByField[tech.field]) {
|
||
if (rung.tier >= tech.tier) break;
|
||
if (!rungCleared(emp, rung)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export function nextResearchTarget(rules, state, e, field) {
|
||
for (const t of rules.techsByField[field]) {
|
||
if (canResearch(rules, state, e, t)) return t.id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// The techs at a field's current frontier rung that are actually
|
||
// researchable right now. Length 0 = field exhausted; length 1 = the
|
||
// unambiguous pick nextResearchTarget already made; length 2+ = a live
|
||
// choice the player hasn't resolved — this is what VegaResearchChoiceScreen
|
||
// prompts for.
|
||
export function openResearchChoices(rules, state, e, field) {
|
||
const candidates = rules.techsByField[field].filter((t) => canResearch(rules, state, e, t));
|
||
if (!candidates.length) return [];
|
||
const minTier = Math.min(...candidates.map((t) => t.tier));
|
||
return candidates.filter((t) => t.tier === minTier);
|
||
}
|
||
|
||
// An original formula, not a MOO1 port — ×5 per known tier echoes the real
|
||
// MOO1 fact that its 10 sub-fields each span 5 levels; +1 per extra known
|
||
// tech below the frontier echoes the general shape (not the literals) of
|
||
// MOO1's own scoring. Pure function of emp.known/rules.techsByField.
|
||
export function fieldTechLevel(rules, state, e, field) {
|
||
const emp = state.empires[e];
|
||
const knownTiers = rules.techsByField[field]
|
||
.filter((t) => emp.known[t.id])
|
||
.map((t) => t.tier);
|
||
if (knownTiers.length === 0) return 0;
|
||
const highestKnownTier = Math.max(...knownTiers);
|
||
const extraKnownBelowFrontier = knownTiers.filter((t) => t < highestKnownTier).length;
|
||
return highestKnownTier * 5 + extraKnownBelowFrontier;
|
||
}
|
||
|
||
// Player (or a future AI override) picks a specific tech within its field's
|
||
// currently-open rung. Deliberately does not touch emp.beakers[field] — RP
|
||
// banked in a field is a shared pool (see processResearch below), so
|
||
// switching which tech it's funding never loses progress, matching MOO1.
|
||
export function setResearchTarget(rules, state, e, field, techId) {
|
||
const emp = state.empires[e];
|
||
const tech = rules.techs[techId];
|
||
if (!tech || tech.field !== field || !canResearch(rules, state, e, tech)) return false;
|
||
emp.researching[field] = techId;
|
||
return true;
|
||
}
|
||
|
||
export function grantTech(rules, state, e, techId, source = 'research') {
|
||
const emp = state.empires[e];
|
||
if (emp.known[techId]) return false;
|
||
const tech = rules.techs[techId];
|
||
emp.known[techId] = true;
|
||
emp.available[techId] = true;
|
||
emp.techsKnown += 1;
|
||
emp.knownInField[tech.field] += 1;
|
||
emp._comps = null;
|
||
emp._compsAt = -1;
|
||
emp._range = null;
|
||
emp._rangeAt = -1;
|
||
emp._designs = null;
|
||
emp._designsAt = -1;
|
||
pushEvent(state, { type: 'techDone', empire: e, techId, source, turn: state.turn });
|
||
return true;
|
||
}
|
||
|
||
function processResearch(rules, state, e, beakers) {
|
||
const emp = state.empires[e];
|
||
const spec = rules.species[emp.speciesId];
|
||
for (const field of Object.keys(rules.techFields)) {
|
||
if (!emp.researching[field]) emp.researching[field] = nextResearchTarget(rules, state, e, field);
|
||
const targetId = emp.researching[field];
|
||
if (!targetId) continue;
|
||
emp.beakers[field] += beakers * (emp.alloc[field] ?? 0);
|
||
const tech = rules.techs[targetId];
|
||
const cost = techCost(rules, tech, emp.knownInField[field], techCostFactor(spec, field));
|
||
if (emp.beakers[field] >= cost) {
|
||
emp.beakers[field] -= cost;
|
||
grantTech(rules, state, e, targetId);
|
||
emp.researching[field] = nextResearchTarget(rules, state, e, field);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Colony turn
|
||
|
||
// Allocation with MOO1 spillover: a channel that cannot use its share passes it
|
||
// on rather than burning it. Industry that has maxed its factories feeds the
|
||
// build queue; a clean colony's ecology money becomes research. Without this,
|
||
// a developed colony quietly wastes most of its output.
|
||
function processColony(rules, state, colony) {
|
||
const e = colony.empireIdx;
|
||
const emp = state.empires[e];
|
||
const spec = rules.species[emp.speciesId];
|
||
const comps = empireComponents(rules, state, e);
|
||
const skills = colonyLeaderSkills(rules, state, colony);
|
||
const eco = rules.economy;
|
||
const prod = colonyProduction(rules, state, colony);
|
||
|
||
const share = {};
|
||
for (const ch of CHANNELS) share[ch] = prod * (colony.sliders[ch] ?? 0);
|
||
|
||
// --- Ecology: clean this turn's waste plus any backlog.
|
||
//
|
||
// Cleanup is MANDATORY. If the ecology slider does not cover it, the
|
||
// shortfall is taken pro-rata out of the other four channels — exactly the
|
||
// way MOO1's eco slider snaps up to the minimum on its own. Letting a colony
|
||
// simply not pay is a death spiral with no exit: unpaid waste cuts maximum
|
||
// population, the smaller population mothballs factories and produces less,
|
||
// which leaves even less to spend on the waste that is still there.
|
||
const wasteGen = effectiveFactories(rules, state, colony) * eco.wastePerFactory * spec.traits.ecologyMult;
|
||
const needUnits = colony.waste + wasteGen;
|
||
const costPerUnit = eco.wasteCleanupCost * comps.wasteMult
|
||
* buildingMult(rules, colony, 'ecology') * (skills.ecologyMult ?? 1);
|
||
let ecoSpill = 0;
|
||
if (needUnits <= 0 || costPerUnit <= 0) {
|
||
colony.waste = 0;
|
||
ecoSpill = share.ecology;
|
||
} else {
|
||
const cleanupCost = needUnits * costPerUnit;
|
||
if (share.ecology >= cleanupCost) {
|
||
colony.waste = 0;
|
||
ecoSpill = share.ecology - cleanupCost;
|
||
} else {
|
||
let shortfall = cleanupCost - share.ecology;
|
||
const donors = ['industry', 'ships', 'defense', 'research'];
|
||
const pool = donors.reduce((t, ch) => t + share[ch], 0);
|
||
if (pool > 0) {
|
||
const take = Math.min(shortfall, pool);
|
||
for (const ch of donors) {
|
||
share[ch] -= take * (share[ch] / pool);
|
||
}
|
||
shortfall -= take;
|
||
}
|
||
const paid = cleanupCost - shortfall;
|
||
colony.waste = Math.max(0, needUnits - paid / costPerUnit);
|
||
colony.ecoForced = Math.round(paid - share.ecology > 0 ? paid - Math.min(paid, prod * (colony.sliders.ecology ?? 0)) : 0);
|
||
}
|
||
}
|
||
|
||
// --- Industry: factories, capped by population.
|
||
const factoryCost = eco.factoryCost * comps.factoryCostMult;
|
||
const capF = colonyFactoryCap(rules, state, colony);
|
||
let indSpill = 0;
|
||
if (colony.factories >= capF) {
|
||
indSpill = share.industry;
|
||
} else {
|
||
const built = Math.min(capF - colony.factories, share.industry / factoryCost);
|
||
colony.factories += built;
|
||
indSpill = Math.max(0, share.industry - built * factoryCost);
|
||
}
|
||
|
||
// --- Defence: planetary batteries, capped by tech.
|
||
const capD = colonyDefenseCap(rules, state, colony);
|
||
let defSpill = 0;
|
||
if (colony.defenseHp >= capD) {
|
||
defSpill = share.defense;
|
||
} else {
|
||
const added = Math.min(capD - colony.defenseHp, share.defense);
|
||
colony.defenseHp += added;
|
||
defSpill = Math.max(0, share.defense - added);
|
||
}
|
||
|
||
// --- Construction: the build queue (ships and buildings).
|
||
let build = share.ships + indSpill + defSpill;
|
||
let buildSpill = 0;
|
||
let guard = 0;
|
||
while (build > 0 && colony.queue.length > 0 && guard < 20) {
|
||
guard += 1;
|
||
const item = colony.queue[0];
|
||
const cost = queueItemCost(rules, state, colony, item);
|
||
const need = cost - item.progress;
|
||
if (build >= need) {
|
||
build -= need;
|
||
completeQueueItem(rules, state, colony, item);
|
||
colony.queue.shift();
|
||
} else {
|
||
item.progress += build;
|
||
build = 0;
|
||
}
|
||
}
|
||
if (colony.queue.length === 0) buildSpill = build;
|
||
|
||
// --- Research absorbs everything left over.
|
||
const research = (share.research + ecoSpill + buildSpill) * (skills.researchMult ?? 1);
|
||
|
||
// --- Population.
|
||
const maxPop = colonyMaxPop(rules, state, colony);
|
||
const growth = colony.pop * eco.growthRateBase
|
||
* (1 - colony.pop / Math.max(1, maxPop))
|
||
* spec.traits.growthMult
|
||
* buildingEffectMult(rules, colony, 'growthMult')
|
||
* (skills.growthMult ?? 1);
|
||
colony.pop = Math.max(0.5, Math.min(maxPop, colony.pop + growth));
|
||
|
||
return { research, trade: colonyTrade(rules, state, colony) };
|
||
}
|
||
|
||
export function queueItemCost(rules, state, colony, item) {
|
||
const emp = state.empires[colony.empireIdx];
|
||
const spec = rules.species[emp.speciesId];
|
||
if (item.kind === 'building') return rules.buildings[item.id].cost;
|
||
return empireDesign(rules, state, colony.empireIdx, item.id).cost;
|
||
}
|
||
|
||
function completeQueueItem(rules, state, colony, item) {
|
||
const e = colony.empireIdx;
|
||
if (item.kind === 'building') {
|
||
if (!colony.buildings.includes(item.id)) colony.buildings.push(item.id);
|
||
pushEvent(state, { type: 'buildingDone', empire: e, colonyId: colony.id, buildingId: item.id, starIdx: colony.starIdx, turn: state.turn });
|
||
return;
|
||
}
|
||
const emp = state.empires[e];
|
||
const mark = markFor(rules, emp.known);
|
||
if (item.id === 'starbase') {
|
||
if (!colony.buildings.includes('starbase')) colony.buildings.push('starbase');
|
||
emp._range = null; emp._rangeAt = -1;
|
||
}
|
||
addFleet(rules, state, e, colony.starIdx, [{ hullId: item.id, mark, count: 1 }]);
|
||
pushEvent(state, { type: 'shipDone', empire: e, colonyId: colony.id, hullId: item.id, starIdx: colony.starIdx, turn: state.turn });
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Empire turn
|
||
|
||
export function beginEmpireTurn(rules, state, e) {
|
||
const emp = state.empires[e];
|
||
if (!emp.alive) return;
|
||
const spec = rules.species[emp.speciesId];
|
||
const diff = rules.difficulties[state.difficultyId];
|
||
const comps = empireComponents(rules, state, e);
|
||
|
||
let research = 0;
|
||
let trade = 0;
|
||
let upkeep = 0;
|
||
for (const colony of empireColonies(state, e)) {
|
||
const r = processColony(rules, state, colony);
|
||
research += r.research;
|
||
trade += r.trade;
|
||
for (const bid of colony.buildings) upkeep += rules.buildings[bid]?.upkeep ?? 0;
|
||
}
|
||
// Fleets cost a fraction of their build cost every turn.
|
||
for (const f of empireFleets(state, e)) {
|
||
for (const s of f.ships) {
|
||
upkeep += empireDesign(rules, state, e, s.hullId).cost
|
||
* (rules.economy.shipUpkeepFraction ?? 0.02) * s.count;
|
||
}
|
||
}
|
||
for (const l of emp.leaders) upkeep += rules.leaders[l.leaderId].upkeep;
|
||
|
||
// Active trade agreements add a modest ongoing income, scaled to the
|
||
// smaller of the two economies so a deal with a tiny neighbor is never a
|
||
// windfall.
|
||
let tradeAgreementIncome = 0;
|
||
for (const otherIdx of Object.keys(emp.tradeAgreements)) {
|
||
const other = state.empires[otherIdx];
|
||
if (!other?.alive) continue;
|
||
tradeAgreementIncome += rules.diplomacy.tradeAgreement.bcRatePerPop
|
||
* Math.min(emp.totalPop, other.totalPop);
|
||
}
|
||
|
||
emp.bc = Math.max(0, emp.bc + trade + tradeAgreementIncome - upkeep);
|
||
emp.lastIncome = trade + tradeAgreementIncome - upkeep;
|
||
|
||
const researchMult = spec.traits.researchMult
|
||
* comps.researchMult
|
||
* (emp.isHuman ? diff.humanResearchMult : diff.aiResearchMult);
|
||
processResearch(rules, state, e, research * researchMult);
|
||
|
||
consolidateFleets(rules, state, e);
|
||
autoRefit(rules, state, e);
|
||
runEspionage(rules, state, e);
|
||
recomputeTotals(rules, state);
|
||
}
|
||
|
||
// Ships sitting over a friendly colony are brought up to the current Mark, paid
|
||
// for out of the reserve. This is the whole reason preset hulls still feel
|
||
// connected to the tech tree: research a better gun and your existing fleet
|
||
// visibly improves, with a report line to say so.
|
||
function autoRefit(rules, state, e) {
|
||
const emp = state.empires[e];
|
||
const spec = rules.species[emp.speciesId];
|
||
const mark = markFor(rules, emp.known);
|
||
for (const f of empireFleets(state, e)) {
|
||
if (f.starIdx < 0 || f.toStar >= 0) continue;
|
||
const colony = state.colonies.find((c) => c.starIdx === f.starIdx && c.empireIdx === e);
|
||
if (!colony) continue;
|
||
for (const s of f.ships) {
|
||
if (s.mark >= mark) continue;
|
||
const cost = refitCost(rules, emp.known, s.hullId, s.mark, spec.traits) * s.count;
|
||
if (emp.bc < cost) continue;
|
||
emp.bc -= cost;
|
||
const from = s.mark;
|
||
s.mark = mark;
|
||
pushEvent(state, {
|
||
type: 'refit', empire: e, starIdx: f.starIdx, hullId: s.hullId,
|
||
count: s.count, fromMark: from, toMark: mark, cost: Math.round(cost), turn: state.turn,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Espionage is passive: a species with spies gets periodic attempts against
|
||
// empires it has met, and either steals a tech it could not research itself
|
||
// or sabotages a colony — whichever mission the attacker has standing (set
|
||
// from the Audience screen's Intelligence toggle, default 'steal').
|
||
function runEspionage(rules, state, e) {
|
||
const emp = state.empires[e];
|
||
const spec = rules.species[emp.speciesId];
|
||
const comps = empireComponents(rules, state, e);
|
||
let power = (spec.traits.espionage ?? 0) + comps.espionage;
|
||
for (const c of empireColonies(state, e)) {
|
||
power += buildingEffect(rules, c, 'espionage') + (colonyLeaderSkills(rules, state, c).espionage ?? 0);
|
||
}
|
||
if (power <= 0) return;
|
||
|
||
// Only the surplus over a baseline counts. A Battle-Scanner-and-nothing-else
|
||
// empire is not a spy agency: crediting its raw score let EVERY empire steal
|
||
// its way to the full tech tree over a long game, which made the per-species
|
||
// availability roll — the whole reason tech trading exists — meaningless.
|
||
const net = power - 25;
|
||
if (net <= 0) return;
|
||
emp.spyPoints += net / 300;
|
||
if (emp.spyPoints < 1) return;
|
||
emp.spyPoints -= 1;
|
||
|
||
// 'off' (set from the Audience screen's Intelligence toggle) pulls that
|
||
// empire out of the random-target pool entirely — the only way, since
|
||
// targeting is otherwise a single shared roll across everyone contacted,
|
||
// to actually stop spying on someone rather than just changing what a hit
|
||
// against them does.
|
||
const targets = state.empires.filter((o) => o.alive && o.idx !== e
|
||
&& emp.contacted[o.idx] && emp.treaties[o.idx] !== 'alliance'
|
||
&& (emp.espionageMission[o.idx] ?? 'steal') !== 'off');
|
||
if (!targets.length) return;
|
||
const target = targets[randInt(state, targets.length)];
|
||
const mission = emp.espionageMission[target.idx] ?? 'steal';
|
||
const tspec = rules.species[target.speciesId];
|
||
let defence = (tspec.traits.counterEspionage ?? 0) + empireComponents(rules, state, target.idx).counterEspionage;
|
||
for (const c of empireColonies(state, target.idx)) {
|
||
defence += buildingEffect(rules, c, 'espionage') + (colonyLeaderSkills(rules, state, c).counterEspionage ?? 0);
|
||
}
|
||
|
||
const odds = Math.max(0.05, Math.min(0.8, 0.4 + (power - defence) / 200));
|
||
if (rand(state) > odds) {
|
||
pushEvent(state, { type: 'spyCaught', empire: e, target: target.idx, mission, turn: state.turn });
|
||
target.attitude[e] = Math.max(-100, Math.min(100, (target.attitude[e] ?? 0) - 12));
|
||
return;
|
||
}
|
||
|
||
// A SUCCESSFUL mission carries no attitude penalty (Brian's ask) — nobody
|
||
// publicly knows who did it (see VegaGnn.js's `attributed` flag), so there
|
||
// is nobody for the victim to be angry AT. Getting CAUGHT above is the one
|
||
// espionage outcome that still costs relations, since that one names names.
|
||
if (mission === 'sabotage') {
|
||
const targetColonies = empireColonies(state, target.idx);
|
||
if (!targetColonies.length) return;
|
||
const colony = targetColonies[randInt(state, targetColonies.length)];
|
||
const cfg = rules.diplomacy.espionage;
|
||
const factoriesLost = Math.round(colony.factories * cfg.sabotageFactoriesFraction);
|
||
const defenseLost = Math.round(colony.defenseHp * cfg.sabotageDefenseFraction);
|
||
colony.factories = Math.max(0, colony.factories - factoriesLost);
|
||
colony.defenseHp = Math.max(0, colony.defenseHp - defenseLost);
|
||
pushEvent(state, {
|
||
type: 'sabotage', empire: e, target: target.idx, starIdx: colony.starIdx,
|
||
factoriesLost, defenseLost, turn: state.turn,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const stealable = rules.techList.filter((t) => target.known[t.id] && !emp.known[t.id]);
|
||
if (!stealable.length) return;
|
||
const tech = stealable[randInt(state, stealable.length)];
|
||
grantTech(rules, state, e, tech.id, 'espionage');
|
||
pushEvent(state, { type: 'techStolen', empire: e, target: target.idx, techId: tech.id, turn: state.turn });
|
||
}
|
||
|
||
function recomputeTotals(rules, state) {
|
||
for (const emp of state.empires) emp.totalPop = 0;
|
||
for (const c of state.colonies) state.empires[c.empireIdx].totalPop += c.pop;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Movement, arrival, combat
|
||
|
||
export function canSendFleet(rules, state, fleet, toStar) {
|
||
// A DOCKED fleet just needs a real destination different from where it
|
||
// sits. An IN-FLIGHT fleet additionally needs Hyperspace Communications'
|
||
// redirectInFlight effect — without it, an order already under way is
|
||
// locked in, same as always — and a destination other than the one it is
|
||
// already headed for (retargeting to that is a no-op, same as ordering a
|
||
// docked fleet to stay where it is).
|
||
if (fleet.toStar >= 0) {
|
||
if (!empireComponents(rules, state, fleet.empireIdx).redirectInFlight) return false;
|
||
if (toStar === fleet.toStar) return false;
|
||
} else if (fleet.starIdx === toStar) {
|
||
return false;
|
||
}
|
||
if (fleetSpeed(rules, state, fleet) <= 0) return false;
|
||
const reach = reachableStars(rules, state, fleet.empireIdx);
|
||
return !!reach[toStar];
|
||
}
|
||
|
||
export function sendFleet(rules, state, fleet, toStar) {
|
||
if (!canSendFleet(rules, state, fleet, toStar)) return false;
|
||
if (fleet.toStar >= 0) {
|
||
// Redirecting a fleet already under way (Hyperspace Communications,
|
||
// gated in canSendFleet above): no garrison to split off — an immobile
|
||
// hull can never have left port in the first place — so this is just a
|
||
// fresh leg from its ORIGINAL destination, the one real star anchor
|
||
// still available mid-flight. Matches the same fromStar-becomes-toStar
|
||
// convention etaTo() already uses to estimate an in-flight fleet's ETA
|
||
// to some OTHER star, so a quoted order's preview (VegaSidePanel.js's
|
||
// buildOrder, which calls etaTo()) lines up with what actually happens
|
||
// here on Accept.
|
||
fleet.fromStar = fleet.toStar;
|
||
fleet.toStar = toStar;
|
||
fleet.total = parsecs(state.galaxy, fleet.fromStar, toStar);
|
||
fleet.progress = 0;
|
||
return true;
|
||
}
|
||
// Star bases defend the system they were built in and do not sail with the
|
||
// fleet; split them into a garrison that stays put.
|
||
const garrison = [];
|
||
fleet.ships = fleet.ships.filter((s) => {
|
||
const d = empireDesign(rules, state, fleet.empireIdx, s.hullId);
|
||
if (!d.immobile) return true;
|
||
garrison.push(s);
|
||
return false;
|
||
});
|
||
if (garrison.length) {
|
||
state.fleets.push({
|
||
id: state.nextFleetId += 1,
|
||
empireIdx: fleet.empireIdx,
|
||
starIdx: fleet.starIdx,
|
||
fromStar: -1, toStar: -1, progress: 0, total: 0,
|
||
ships: garrison,
|
||
});
|
||
}
|
||
fleet.fromStar = fleet.starIdx;
|
||
fleet.toStar = toStar;
|
||
fleet.total = parsecs(state.galaxy, fleet.starIdx, toStar);
|
||
fleet.progress = 0;
|
||
fleet.starIdx = -1;
|
||
return true;
|
||
}
|
||
|
||
export function fleetEta(rules, state, fleet) {
|
||
if (fleet.toStar < 0) return 0;
|
||
const speed = fleetSpeed(rules, state, fleet);
|
||
if (speed <= 0) return Infinity;
|
||
return Math.max(1, Math.ceil((fleet.total - fleet.progress) / speed));
|
||
}
|
||
|
||
// How long an order WOULD take, asked before it is given — the number the star
|
||
// map's order panel shows next to Accept. `ships` scopes it to a detachment the
|
||
// player has selected, whose speed is the slowest hull in the selection rather
|
||
// than the slowest in the whole fleet: leaving the colony ships at home is how
|
||
// you make a raid arrive this decade, so the ETA has to react to the selection.
|
||
export function etaTo(rules, state, fleet, toStar, ships = null) {
|
||
const from = fleet.starIdx >= 0 ? fleet.starIdx : fleet.toStar;
|
||
if (from < 0 || from === toStar) return 0;
|
||
const speed = fleetSpeed(rules, state, ships ? { ...fleet, ships } : fleet);
|
||
if (speed <= 0) return Infinity;
|
||
return Math.max(1, Math.ceil(parsecs(state.galaxy, from, toStar) / speed));
|
||
}
|
||
|
||
// Fold a [{hullId, mark, count}] request into one entry per stack, so asking
|
||
// for the same stack twice cannot slip past the "do you have this many?" check.
|
||
function normaliseTake(take) {
|
||
const out = new Map();
|
||
for (const t of take ?? []) {
|
||
const n = Math.floor(t.count ?? 0);
|
||
if (n <= 0) continue;
|
||
const key = `${t.hullId}|${t.mark}`;
|
||
const prev = out.get(key);
|
||
if (prev) prev.count += n;
|
||
else out.set(key, { hullId: t.hullId, mark: t.mark, count: n });
|
||
}
|
||
return [...out.values()];
|
||
}
|
||
|
||
const takeTotal = (take) => take.reduce((t, s) => t + s.count, 0);
|
||
const fleetTotal = (fleet) => fleet.ships.reduce((t, s) => t + Math.max(0, s.count), 0);
|
||
|
||
/**
|
||
* Detach part of an idle fleet into a fleet of its own, parked at the same star.
|
||
* Returns the new fleet, or null if the request was not satisfiable.
|
||
*
|
||
* Two idle fleets in one system are merged again by consolidateFleets at the
|
||
* start of the owner's next turn, which is MOO1's rule and deliberate — so the
|
||
* only splitting the UI offers is "send some of these ships somewhere"
|
||
* (sendDetachment below), where the detachment leaves the same instant.
|
||
*
|
||
* The detachment gets a fresh fleet id and therefore does NOT inherit the
|
||
* parent's fleet leader; the leader stays with the fleet they were posted to.
|
||
*/
|
||
export function splitFleet(rules, state, fleet, take) {
|
||
if (!fleet || fleet.starIdx < 0 || fleet.toStar >= 0) return null;
|
||
const wanted = normaliseTake(take);
|
||
const taken = takeTotal(wanted);
|
||
if (taken <= 0 || taken >= fleetTotal(fleet)) return null;
|
||
|
||
// Verify the whole request before mutating anything, or a request that is
|
||
// half-satisfiable leaves the fleet carved up and the order refused.
|
||
const pairs = [];
|
||
for (const t of wanted) {
|
||
const src = fleet.ships.find((s) => s.hullId === t.hullId && s.mark === t.mark);
|
||
if (!src || src.count < t.count) return null;
|
||
pairs.push([src, t.count]);
|
||
}
|
||
|
||
const ships = [];
|
||
for (const [src, n] of pairs) {
|
||
src.count -= n;
|
||
ships.push({ ...src, count: n });
|
||
}
|
||
fleet.ships = fleet.ships.filter((s) => s.count > 0);
|
||
|
||
const detachment = {
|
||
id: state.nextFleetId += 1,
|
||
empireIdx: fleet.empireIdx,
|
||
starIdx: fleet.starIdx,
|
||
fromStar: -1, toStar: -1, progress: 0, total: 0,
|
||
ships,
|
||
};
|
||
state.fleets.push(detachment);
|
||
return detachment;
|
||
}
|
||
|
||
/**
|
||
* Send some of a fleet's ships to a star: the selection departs, the rest stay
|
||
* behind. Selecting everything is just sendFleet. Returns the fleet that is now
|
||
* under way, or null if the order was refused.
|
||
*/
|
||
export function sendDetachment(rules, state, fleet, toStar, take) {
|
||
if (!fleet) return null;
|
||
const wanted = normaliseTake(take);
|
||
const taken = takeTotal(wanted);
|
||
if (taken <= 0) return null;
|
||
// Check the request against the actual stacks BEFORE comparing totals, or
|
||
// asking for nine of a stack of three reads as "all of them" and quietly
|
||
// sends the whole fleet instead of refusing.
|
||
for (const t of wanted) {
|
||
const src = fleet.ships.find((s) => s.hullId === t.hullId && s.mark === t.mark);
|
||
if (!src || src.count < t.count) return null;
|
||
}
|
||
if (taken === fleetTotal(fleet)) {
|
||
return sendFleet(rules, state, fleet, toStar) ? fleet : null;
|
||
}
|
||
|
||
// Ask whether the DETACHMENT could fly before splitting it out, so a refused
|
||
// order never leaves the fleet in pieces.
|
||
const probe = { ...fleet, ships: wanted.map((t) => ({ ...t })) };
|
||
if (!canSendFleet(rules, state, probe, toStar)) return null;
|
||
|
||
const detachment = splitFleet(rules, state, fleet, wanted);
|
||
if (!detachment) return null;
|
||
if (!sendFleet(rules, state, detachment, toStar)) {
|
||
// Unreachable given the probe, but a half-split fleet would be a silent
|
||
// loss of ships, so put them back rather than trust the reasoning.
|
||
for (const s of detachment.ships) {
|
||
const m = fleet.ships.find((x) => x.hullId === s.hullId && x.mark === s.mark);
|
||
if (m) m.count += s.count;
|
||
else fleet.ships.push({ ...s });
|
||
}
|
||
state.fleets = state.fleets.filter((f) => f !== detachment);
|
||
return null;
|
||
}
|
||
return detachment;
|
||
}
|
||
|
||
function moveFleets(rules, state, e) {
|
||
for (const f of empireFleets(state, e)) {
|
||
if (f.toStar < 0) continue;
|
||
f.progress += fleetSpeed(rules, state, f);
|
||
if (f.progress >= f.total - 1e-9) {
|
||
f.starIdx = f.toStar;
|
||
f.toStar = -1;
|
||
f.fromStar = -1;
|
||
f.progress = 0;
|
||
f.total = 0;
|
||
const wasExplored = !!state.empires[e].explored[f.starIdx];
|
||
state.empires[e].explored[f.starIdx] = true;
|
||
pushEvent(state, { type: 'arrive', empire: e, starIdx: f.starIdx, fleetId: f.id, turn: state.turn });
|
||
if (!wasExplored) {
|
||
pushEvent(state, { type: 'discovered', empire: e, starIdx: f.starIdx, turn: state.turn });
|
||
}
|
||
checkContactAt(rules, state, f.starIdx);
|
||
deliverPopulation(rules, state, f);
|
||
}
|
||
}
|
||
cleanFleets(state);
|
||
}
|
||
|
||
// Empires meet by sharing a system — a ship or colony of one sitting where a
|
||
// ship or colony of another already is, for the first time. Contact used to
|
||
// be proximity-based (any two empires' colonies within N parsecs), which met
|
||
// everyone quietly and gave the human no sense that first contact was an
|
||
// event. Star-scoped and all-pairs so it is agnostic to *why* two empires
|
||
// ended up sharing a star (a fleet arriving, a colony founded onto an
|
||
// already-occupied system) — every caller just says "check this star."
|
||
export function checkContactAt(rules, state, starIdx) {
|
||
const present = new Set();
|
||
for (const c of coloniesAt(state, starIdx)) present.add(c.empireIdx);
|
||
for (const f of fleetsAt(state, starIdx)) present.add(f.empireIdx);
|
||
const list = [...present];
|
||
for (let i = 0; i < list.length; i += 1) {
|
||
for (let j = i + 1; j < list.length; j += 1) {
|
||
const a = list[i];
|
||
const b = list[j];
|
||
if (state.empires[a].contacted[b]) continue;
|
||
state.empires[a].contacted[b] = true;
|
||
state.empires[b].contacted[a] = true;
|
||
pushEvent(state, { type: 'contact', empire: a, other: b, starIdx, turn: state.turn });
|
||
}
|
||
}
|
||
}
|
||
|
||
export function atWar(state, a, b) {
|
||
return state.empires[a]?.treaties?.[b] === 'war';
|
||
}
|
||
|
||
// Resolve every star where hostile forces now share orbit. Called once per
|
||
// empire turn after movement, so a fleet that arrives is engaged immediately.
|
||
//
|
||
// `excludeEmpire`, if given, skips any pairing that empire is a party to
|
||
// (attacker, defender, OR undefended-colony owner) — left for the caller to
|
||
// resolve some other way instead (MasterOfVegaGame.js's playPlayerBattles,
|
||
// so the human always gets the interactive tactical view rather than a
|
||
// battle they're in being silently auto-resolved during someone else's
|
||
// turn). Defaults to null so every existing headless call site (tests, the
|
||
// AI self-play soak) is completely unaffected — this is opt-in, not a
|
||
// behavior change to the engine's default. Safe to leave the OTHER pairs at
|
||
// the same star to resolve normally in the same pass: ship stacks are kept
|
||
// per (starIdx, empireIdx), so a resolved fight elsewhere in this loop can
|
||
// never touch the excluded empire's own ships.
|
||
export function resolveCombats(rules, state, { excludeEmpire = null } = {}) {
|
||
const results = [];
|
||
const byStar = new Map();
|
||
for (const f of state.fleets) {
|
||
if (f.starIdx < 0) continue;
|
||
if (!byStar.has(f.starIdx)) byStar.set(f.starIdx, []);
|
||
byStar.get(f.starIdx).push(f);
|
||
}
|
||
for (const [starIdx, fleets] of byStar) {
|
||
const empires = [...new Set(fleets.map((f) => f.empireIdx))];
|
||
const colony = colonyAt(state, starIdx);
|
||
const defenderIdx = colony ? colony.empireIdx : null;
|
||
|
||
for (const a of empires) {
|
||
for (const b of empires) {
|
||
if (a >= b) continue;
|
||
if (!atWar(state, a, b)) continue;
|
||
if (a === excludeEmpire || b === excludeEmpire) continue;
|
||
const result = fightAt(rules, state, starIdx, a, b);
|
||
if (result) results.push(result);
|
||
}
|
||
}
|
||
// A hostile fleet in orbit of a defended colony must also fight the planet.
|
||
if (colony) {
|
||
for (const a of empires) {
|
||
if (a === defenderIdx) continue;
|
||
if (!atWar(state, a, defenderIdx)) continue;
|
||
if (a === excludeEmpire || defenderIdx === excludeEmpire) continue;
|
||
if (state.fleets.some((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx && f.ships.length)) continue;
|
||
if (colony.defenseHp <= 0) continue;
|
||
const result = fightAt(rules, state, starIdx, a, defenderIdx);
|
||
if (result) results.push(result);
|
||
}
|
||
}
|
||
}
|
||
cleanFleets(state);
|
||
return results;
|
||
}
|
||
|
||
function collectShips(rules, state, starIdx, e) {
|
||
const ships = [];
|
||
for (const f of state.fleets) {
|
||
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
|
||
for (const s of f.ships) {
|
||
const m = ships.find((x) => x.hullId === s.hullId && x.mark === s.mark);
|
||
if (m) m.count += s.count;
|
||
else ships.push({ hullId: s.hullId, mark: s.mark, count: s.count });
|
||
}
|
||
}
|
||
return ships;
|
||
}
|
||
|
||
function applyBattleLosses(rules, state, starIdx, e, survivors) {
|
||
const want = new Map();
|
||
for (const s of survivors) want.set(`${s.hullId}|${s.mark}`, s.count);
|
||
for (const f of state.fleets) {
|
||
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
|
||
for (const s of f.ships) {
|
||
const key = `${s.hullId}|${s.mark}`;
|
||
const left = want.get(key) ?? 0;
|
||
const keep = Math.min(s.count, left);
|
||
want.set(key, left - keep);
|
||
s.count = keep;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build the battle object for a pair at a system, WITHOUT resolving it.
|
||
// Split out of fightAt so the player can drive a battle tick by tick through
|
||
// VegaCombatViewV2 and then hand the outcome back — the interactive battle
|
||
// and auto-resolve therefore run the same engine and cannot diverge.
|
||
// `humanFormation` (VegaFormations.js id) is stamped onto whichever side is
|
||
// state.humanIndex, so a human-participated battle can carry the tactic the
|
||
// player actually chose (MasterOfVegaGame.js's playPlayerBattles, via its
|
||
// pre-battle formation picker) instead of createBattle's own silent-random
|
||
// fallback for an unset side. AI-vs-AI battles (fightAt, below) never pass
|
||
// this and are unaffected — both sides stay silently random as before.
|
||
export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation = null } = {}) {
|
||
const colony = colonyAt(state, starIdx);
|
||
const defenderIdx = colony && (colony.empireIdx === a || colony.empireIdx === b) ? colony.empireIdx : b;
|
||
const attackerIdx = defenderIdx === a ? b : a;
|
||
|
||
const mkSide = (idx) => {
|
||
const emp = state.empires[idx];
|
||
return {
|
||
empireIdx: idx,
|
||
name: emp.name,
|
||
empire: { known: emp.known, traits: rules.species[emp.speciesId].traits },
|
||
ships: collectShips(rules, state, starIdx, idx),
|
||
};
|
||
};
|
||
const attacker = mkSide(attackerIdx);
|
||
const defender = mkSide(defenderIdx);
|
||
if (humanFormation) {
|
||
if (attackerIdx === state.humanIndex) attacker.formationStrategy = humanFormation;
|
||
else if (defenderIdx === state.humanIndex) defender.formationStrategy = humanFormation;
|
||
}
|
||
const defColony = colony && colony.empireIdx === defenderIdx ? colony : null;
|
||
if (!attacker.ships.length) return null;
|
||
if (!defender.ships.length && !(defColony && defColony.defenseHp > 0)) return null;
|
||
|
||
// The planet's typeId rides along purely for the tactical view's art (the
|
||
// real planets spritesheet instead of a generic icon) — the combat engine
|
||
// itself never reads it. shieldBonus was hard-coded to 0 here until now,
|
||
// which meant the Planetary Shield building's own effect (rules.buildings
|
||
// .planetaryshield's shieldBonus:5) was silently never applied to a real
|
||
// battle, only ever exercised through the standalone ?movsim simulator's
|
||
// manual stepper — found while wiring the shield ring into the view.
|
||
const battle = createBattle(rules, {
|
||
attacker,
|
||
defender,
|
||
colony: defColony ? {
|
||
defenseHp: defColony.defenseHp,
|
||
shieldBonus: buildingEffect(rules, defColony, 'shieldBonus'),
|
||
typeId: state.galaxy.stars[starIdx]?.planets[defColony.orbit]?.typeId,
|
||
} : null,
|
||
starIdx,
|
||
rnd: () => rand(state),
|
||
});
|
||
return { battle, starIdx, attackerIdx, defenderIdx, colony: defColony };
|
||
}
|
||
|
||
// Write a finished battle's result back into the game state.
|
||
export function applyBattleOutcome(rules, state, prepared, result) {
|
||
const { starIdx, attackerIdx, defenderIdx, colony } = prepared;
|
||
applyBattleLosses(rules, state, starIdx, attackerIdx, result.attackerSurvivors);
|
||
applyBattleLosses(rules, state, starIdx, defenderIdx, result.defenderSurvivors);
|
||
if (colony) colony.defenseHp = result.planetDefenseLeft;
|
||
|
||
const loser = result.winner === 'attacker' ? defenderIdx : attackerIdx;
|
||
if (result.winner !== 'draw') retreatFrom(rules, state, starIdx, loser);
|
||
|
||
pushEvent(state, {
|
||
type: 'combat', starIdx, attacker: attackerIdx, defender: defenderIdx,
|
||
winner: result.winner, rounds: result.rounds,
|
||
attackerLosses: result.attackerLosses, defenderLosses: result.defenderLosses,
|
||
turn: state.turn,
|
||
});
|
||
cleanFleets(state);
|
||
return result;
|
||
}
|
||
|
||
// Systems where `e` is about to fight this turn. The scene calls this after
|
||
// movement so it can hand the player's own battles to the tactical view —
|
||
// and, since resolveCombats can now be asked to defer `e`'s battles instead
|
||
// of auto-resolving them (its `excludeEmpire` option), this is also what
|
||
// finds those deferred fights again afterward so they still get shown.
|
||
export function pendingBattlesFor(rules, state, e) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
const addStar = (starIdx) => {
|
||
if (seen.has(starIdx)) return;
|
||
seen.add(starIdx);
|
||
const foes = new Set();
|
||
for (const g of state.fleets) {
|
||
if (g.starIdx === starIdx && g.empireIdx !== e && atWar(state, e, g.empireIdx)) foes.add(g.empireIdx);
|
||
}
|
||
const colony = colonyAt(state, starIdx);
|
||
if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx) && colony.defenseHp > 0) {
|
||
foes.add(colony.empireIdx);
|
||
}
|
||
for (const other of foes) out.push({ starIdx, other });
|
||
};
|
||
for (const f of state.fleets) {
|
||
if (f.starIdx < 0 || f.empireIdx !== e) continue;
|
||
addStar(f.starIdx);
|
||
}
|
||
// A colony of e's own with no fleet garrison at all is still a valid
|
||
// defender (planetary batteries alone) — resolveCombats' own colony-
|
||
// defense pass already covers this case; the loop above, scoped to e's
|
||
// FLEETS, previously didn't, so an undefended colony under siege during
|
||
// someone else's turn would never surface here at all.
|
||
for (const c of empireColonies(state, e)) {
|
||
addStar(c.starIdx);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function fightAt(rules, state, starIdx, a, b) {
|
||
const prepared = prepareBattleAt(rules, state, starIdx, a, b);
|
||
if (!prepared) return null;
|
||
return applyBattleOutcome(rules, state, prepared, runBattle(prepared.battle));
|
||
}
|
||
|
||
function retreatFrom(rules, state, starIdx, e) {
|
||
const own = empireColonies(state, e);
|
||
if (!own.length) {
|
||
for (const f of state.fleets) {
|
||
if (f.starIdx === starIdx && f.empireIdx === e) f.ships = [];
|
||
}
|
||
return;
|
||
}
|
||
let best = own[0].starIdx;
|
||
let bestD = Infinity;
|
||
for (const c of own) {
|
||
const d = parsecs(state.galaxy, starIdx, c.starIdx);
|
||
if (d < bestD) { bestD = d; best = c.starIdx; }
|
||
}
|
||
for (const f of state.fleets) {
|
||
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
|
||
if (best === starIdx) continue;
|
||
f.fromStar = starIdx;
|
||
f.toStar = best;
|
||
f.total = bestD;
|
||
f.progress = 0;
|
||
f.starIdx = -1;
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Colonisation and invasion
|
||
|
||
export function colonize(rules, state, e, starIdx, orbit, name = null) {
|
||
if (!canColonize(rules, state, e, starIdx, orbit)) return false;
|
||
const fleet = state.fleets.find((f) => f.starIdx === starIdx && f.empireIdx === e
|
||
&& f.ships.some((s) => s.hullId === 'colonyship' && s.count > 0));
|
||
if (!fleet) return false;
|
||
const stack = fleet.ships.find((s) => s.hullId === 'colonyship' && s.count > 0);
|
||
stack.count -= 1;
|
||
cleanFleets(state);
|
||
const colony = foundColony(rules, state, e, starIdx, orbit, 5, name);
|
||
checkContactAt(rules, state, starIdx);
|
||
pushEvent(state, { type: 'colonised', empire: e, starIdx, orbit, colonyId: colony.id, turn: state.turn });
|
||
state.empires[e]._range = null;
|
||
state.empires[e]._rangeAt = -1;
|
||
return true;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Population transport — MOO1's "send N population from one of your colonies
|
||
// to another." Unlike colonize()'s colony ship, this never sits in a build
|
||
// queue: the 'poptransport' hull (data/mastervega-rules.json) is dispatched
|
||
// directly, in transit already, the instant the order is given, carrying
|
||
// whatever amount was chosen as a payload on its own ship stack rather than a
|
||
// fixed per-hull capacity. It rides the same movement/fuel-range/combat
|
||
// machinery as any other fleet, so an unescorted transport crossing hostile
|
||
// space is exactly as vulnerable as a colony ship would be.
|
||
|
||
// A colony can never be fully emptied this way — same floor colony growth
|
||
// itself never goes below (see the `Math.max(0.5, ...)` in processColony).
|
||
const MIN_POP_LEFT_BEHIND = 0.5;
|
||
|
||
export function maxSendablePopulation(colony) {
|
||
return Math.max(0, colony.pop - MIN_POP_LEFT_BEHIND);
|
||
}
|
||
|
||
export function sendPopulation(rules, state, e, fromColonyId, toColonyId, amount) {
|
||
if (fromColonyId === toColonyId) return false;
|
||
const colony = state.colonies.find((c) => c.id === fromColonyId);
|
||
if (!colony || colony.empireIdx !== e) return false;
|
||
const dest = state.colonies.find((c) => c.id === toColonyId);
|
||
if (!dest || dest.empireIdx !== e) return false;
|
||
const send = Math.min(amount, maxSendablePopulation(colony));
|
||
if (send <= 0) return false;
|
||
|
||
// Built directly in transit — see the module comment above for why this
|
||
// does not go through addFleet()/sendFleet() (which would first have to
|
||
// land in an idle fleet at the source, risking a merge with whatever is
|
||
// garrisoned there before it could be split back out). It also can't route
|
||
// through canSendFleet(): that gate rejects a destination equal to the
|
||
// fleet's own starIdx as a no-op order, which would wrongly forbid moving
|
||
// population between two colonies that share one system, so reachability
|
||
// is checked by hand here instead — same rules, minus that restriction.
|
||
const probe = { empireIdx: e, starIdx: colony.starIdx, toStar: -1, ships: [{ hullId: 'poptransport', mark: 1, count: 1 }] };
|
||
if (fleetSpeed(rules, state, probe) <= 0) return false;
|
||
if (!reachableStars(rules, state, e)[dest.starIdx]) return false;
|
||
|
||
state.fleets.push({
|
||
id: state.nextFleetId += 1,
|
||
empireIdx: e,
|
||
starIdx: -1,
|
||
fromStar: colony.starIdx,
|
||
toStar: dest.starIdx,
|
||
// Which colony to credit on arrival — starIdx alone can't disambiguate
|
||
// two colonies sharing a system (see deliverPopulation).
|
||
destColonyId: dest.id,
|
||
progress: 0,
|
||
total: parsecs(state.galaxy, colony.starIdx, dest.starIdx),
|
||
ships: [{ hullId: 'poptransport', mark: 1, count: 1, popPayload: send }],
|
||
});
|
||
colony.pop -= send;
|
||
pushEvent(state, {
|
||
type: 'populationSent', empire: e, starIdx: colony.starIdx, toStarIdx: dest.starIdx, amount: send, turn: state.turn,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// Called from moveFleets the instant a poptransport fleet arrives. Delivers
|
||
// to a still-friendly colony there and consumes the stack; if the star no
|
||
// longer has a colony of ours (captured, or the fleet was mis-sent), the
|
||
// payload just sits inert in orbit rather than being silently discarded, in
|
||
// case the player wants to redirect it by hand later.
|
||
function deliverPopulation(rules, state, f) {
|
||
const stack = f.ships.find((s) => s.hullId === 'poptransport' && s.popPayload > 0);
|
||
if (!stack) return;
|
||
// starIdx alone can't tell two same-system colonies apart, so prefer the
|
||
// colony id recorded when the transport was dispatched (see sendPopulation).
|
||
const colony = (f.destColonyId != null && state.colonies.find((c) => c.id === f.destColonyId))
|
||
|| colonyAt(state, f.starIdx);
|
||
if (!colony || colony.empireIdx !== f.empireIdx) return;
|
||
const maxPop = colonyMaxPop(rules, state, colony);
|
||
colony.pop = Math.min(maxPop, colony.pop + stack.popPayload);
|
||
pushEvent(state, {
|
||
type: 'populationDelivered', empire: f.empireIdx, starIdx: f.starIdx,
|
||
colonyId: colony.id, amount: stack.popPayload, turn: state.turn,
|
||
});
|
||
stack.count = 0;
|
||
stack.popPayload = 0;
|
||
}
|
||
|
||
// Resolves which colony at this star a bombard/invade action actually
|
||
// targets. A star can host more than one colony (one per orbit), owned by
|
||
// different empires — colonyAt() only ever returns the first one found in
|
||
// state.colonies, regardless of orbit. That silently broke Bombard/Invade
|
||
// whenever an allied or neutral colony shared a system with the actual
|
||
// (hostile) target: colonyAt picked the ally, the atWar check correctly
|
||
// refused, and the caller had no way to tell the refusal apart from a real
|
||
// "no orbital superiority" failure. `orbit`, when supplied, pins the exact
|
||
// colony the caller means — VegaSystemView.js always knows which one is on
|
||
// screen. Without it (VegaAI.js's callers, which only know a starIdx), the
|
||
// fallback targets whichever colony here the attacker is actually at war
|
||
// with, rather than just whichever happens to be first in the array.
|
||
function targetColonyAt(state, e, starIdx, orbit) {
|
||
const here = coloniesAt(state, starIdx);
|
||
if (orbit != null) return here.find((c) => c.orbit === orbit) ?? null;
|
||
return here.find((c) => atWar(state, e, c.empireIdx)) ?? here[0] ?? null;
|
||
}
|
||
|
||
// Orbital superiority at a system: our combat power there exceeds theirs.
|
||
// Bombardment and invasion both require it.
|
||
export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
|
||
const defPower = state.fleets
|
||
.filter((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx)
|
||
.reduce((t, f) => t + fleetPower(rules, state, f), 0);
|
||
const attPower = state.fleets
|
||
.filter((f) => f.starIdx === starIdx && f.empireIdx === e)
|
||
.reduce((t, f) => t + fleetPower(rules, state, f), 0);
|
||
if (attPower <= 0) return false;
|
||
return defPower <= 0 || attPower > defPower;
|
||
}
|
||
|
||
// Whether `e` has already bombarded this exact colony this turn — exported
|
||
// so the UI can grey out/hide the Bombard button proactively instead of
|
||
// letting the player click it into a silent no-op (bombard() itself returns
|
||
// null either way, same as every other precondition failure).
|
||
export function bombardedThisTurn(state, e, colony) {
|
||
return state.empires[e].lastBombardTurn[colony.id] === state.turn;
|
||
}
|
||
|
||
// Bombard a colony from orbit, killing population.
|
||
//
|
||
// This is MOO1's answer to a cornered empire, and without it conquest is
|
||
// literally unreachable: an empire reduced to one fortified homeworld survives
|
||
// forever, because its beaten fleet always retreats back to that same world and
|
||
// denies the attacker the clean orbit an invasion needs. Soaked to 2500 turns,
|
||
// not one empire in eight games was ever eliminated. Bombing also thins the
|
||
// defenders for a subsequent landing, so the two mechanics work together.
|
||
//
|
||
// Capped at once per (attacker, colony) per turn (lastBombardTurn, keyed by
|
||
// colony.id on the attacking empire — not global, so two different empires
|
||
// bombarding the same contested world in the same turn don't block each
|
||
// other). Unlike invade(), which naturally self-limits by consuming the
|
||
// troop transports that carried it out, bombard() spends no resource of its
|
||
// own — warships aren't expended — so without this cap VegaSystemView.js's
|
||
// Bombard button could be clicked any number of times in one turn and wipe
|
||
// a colony's population instantly regardless of fleet size. This also
|
||
// quietly fixes the same bug on the AI side: VegaAI.js calls bombard() once
|
||
// per fleet it has at a hostile star, and bombard() has always summed EVERY
|
||
// attacker fleet at that star regardless of which one was passed in, so an
|
||
// AI with two fleets at one siege was double-charging the same pooled
|
||
// damage before this cap existed.
|
||
export function bombard(rules, state, e, starIdx, orbit = null) {
|
||
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||
if (!colony || colony.empireIdx === e) return null;
|
||
if (!atWar(state, e, colony.empireIdx)) return null;
|
||
if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null;
|
||
|
||
const emp = state.empires[e];
|
||
if (bombardedThisTurn(state, e, colony)) return null;
|
||
const spec = rules.species[emp.speciesId];
|
||
let damage = 0;
|
||
let cracker = false;
|
||
for (const f of state.fleets) {
|
||
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
|
||
for (const st of f.ships) {
|
||
const d = empireDesign(rules, state, e, st.hullId);
|
||
if (d.role !== 'warship') continue;
|
||
damage += d.damage * st.count;
|
||
if (d.planetCracker) cracker = true;
|
||
}
|
||
}
|
||
if (damage <= 0) return null;
|
||
emp.lastBombardTurn[colony.id] = state.turn;
|
||
|
||
const comps = empireComponents(rules, state, e);
|
||
const shield = colony.buildings.includes('planetaryshield')
|
||
? comps.planetaryShield + 5 : comps.planetaryShield;
|
||
const kill = (damage * (rules.combat.bombardPopKill ?? 0.5)) / (1 + shield * 0.15);
|
||
const before = colony.pop;
|
||
colony.pop = Math.max(0, colony.pop - kill);
|
||
// A Stellar Converter cracks the crust; nothing is left to invade.
|
||
if (cracker) colony.pop = Math.max(0, colony.pop - kill);
|
||
|
||
pushEvent(state, {
|
||
type: 'bombard', empire: e, target: colony.empireIdx, starIdx,
|
||
killed: Math.round(before - colony.pop), cracker, turn: state.turn,
|
||
});
|
||
|
||
if (colony.pop < 1) {
|
||
const owner = colony.empireIdx;
|
||
state.colonies = state.colonies.filter((c) => c !== colony);
|
||
pushEvent(state, { type: 'colonyDestroyed', empire: e, target: owner, starIdx, turn: state.turn });
|
||
recomputeTotals(rules, state);
|
||
checkLastColony(state, owner, e);
|
||
checkElimination(rules, state, owner, e);
|
||
return { destroyed: true, killed: Math.round(before) };
|
||
}
|
||
recomputeTotals(rules, state);
|
||
return { destroyed: false, killed: Math.round(before - colony.pop) };
|
||
}
|
||
|
||
// What a landing at this system would look like right now. Exported because
|
||
// both the AI and the human's invade button need the same forecast — the AI
|
||
// was committing every transport it had to hopeless landings (1971 failures
|
||
// against 130 successes) purely because nothing told it the odds.
|
||
export function invasionForecast(rules, state, e, starIdx, orbit = null) {
|
||
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||
if (!colony || colony.empireIdx === e) return null;
|
||
const invadingFleets = state.fleets.filter((f) => f.starIdx === starIdx && f.empireIdx === e);
|
||
const troops = invadingFleets
|
||
.reduce((t, f) => t + f.ships
|
||
.filter((s) => s.hullId === 'transport')
|
||
.reduce((n, s) => n + s.count, 0), 0) * (rules.hulls.transport.troops ?? 4);
|
||
if (troops <= 0) return { troops: 0, defenders: 0, odds: 0, favourable: false };
|
||
|
||
const spec = rules.species[state.empires[e].speciesId];
|
||
const comps = empireComponents(rules, state, e);
|
||
const leaderBonus = invadingFleets.reduce((t, f) => t + (fleetLeaderSkills(rules, state, f).groundAttack ?? 0), 0);
|
||
const attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0) + leaderBonus;
|
||
const defenceBonus = colonyGroundDefense(rules, state, colony) + colony.defenseHp / 45;
|
||
const defenders = Math.max(1, Math.round(colony.pop / 8)) + Math.round(defenceBonus / 10);
|
||
const odds = Math.max(0.1, Math.min(0.9,
|
||
0.5 + (attackBonus - defenceBonus) * (rules.combat.groundOddsScale ?? 0.01)));
|
||
// Each round is one duel; the attacker needs `defenders` wins before it takes
|
||
// `troops` losses. Expected exchange favours the landing when this holds.
|
||
const favourable = troops * odds > defenders * (1 - odds) * 1.25;
|
||
return { troops, defenders, odds, favourable };
|
||
}
|
||
|
||
export function invade(rules, state, e, starIdx, orbit = null) {
|
||
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||
if (!colony || colony.empireIdx === e) return null;
|
||
if (!atWar(state, e, colony.empireIdx)) return null;
|
||
// Holding orbit means orbital SUPERIORITY, not an empty sky.
|
||
//
|
||
// Two versions of this check killed conquest outright. Requiring defenceHp to
|
||
// be zero never opened a window at all — combat resolves on the attacker's
|
||
// turn, and the defender's own turn rebuilds the batteries before the
|
||
// attacker acts again. Requiring literally no enemy hull present failed the
|
||
// same way for the same reason: a besieged colony finishes a ship every few
|
||
// turns, and that one fresh hull blocked the landing indefinitely. Measured
|
||
// over six games there were 1268 colony-turns under hostile orbit and exactly
|
||
// two invasion attempts.
|
||
const defPower = state.fleets
|
||
.filter((f) => f.starIdx === starIdx && f.empireIdx === colony.empireIdx)
|
||
.reduce((t, f) => t + fleetPower(rules, state, f), 0);
|
||
const attPower = state.fleets
|
||
.filter((f) => f.starIdx === starIdx && f.empireIdx === e)
|
||
.reduce((t, f) => t + fleetPower(rules, state, f), 0);
|
||
if (defPower > 0 && attPower <= defPower) return null;
|
||
|
||
const fleet = state.fleets.find((f) => f.starIdx === starIdx && f.empireIdx === e
|
||
&& f.ships.some((s) => s.hullId === 'transport' && s.count > 0));
|
||
if (!fleet) return null;
|
||
const stack = fleet.ships.find((s) => s.hullId === 'transport' && s.count > 0);
|
||
|
||
const emp = state.empires[e];
|
||
const spec = rules.species[emp.speciesId];
|
||
const comps = empireComponents(rules, state, e);
|
||
const troops = stack.count * (rules.hulls.transport.troops ?? 4);
|
||
const attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0)
|
||
+ (fleetLeaderSkills(rules, state, fleet).groundAttack ?? 0);
|
||
// Intact orbital batteries shell the landing zones, but only as a modifier —
|
||
// at a twelfth of their hit points they alone drove the attacker's odds to
|
||
// the 10% floor and only 21 of 268 landings succeeded.
|
||
const defenceBonus = colonyGroundDefense(rules, state, colony) + colony.defenseHp / 45;
|
||
|
||
const result = resolveInvasion(rules, () => rand(state), troops, attackBonus,
|
||
{ groundDefense: defenceBonus }, defenceBonus, colony.pop);
|
||
|
||
stack.count = 0;
|
||
cleanFleets(state);
|
||
|
||
if (result.captured) {
|
||
const from = colony.empireIdx;
|
||
colony.empireIdx = e;
|
||
colony.capital = false;
|
||
colony.pop = Math.max(1, colony.pop * 0.5);
|
||
colony.queue = [];
|
||
colony.defenseHp = 0;
|
||
pushEvent(state, { type: 'captured', empire: e, from, starIdx, colonyId: colony.id, turn: state.turn });
|
||
state.empires[from].attitude[e] = Math.max(-100, Math.min(100, (state.empires[from].attitude[e] ?? 0) - 40));
|
||
state.empires[e]._range = null; state.empires[e]._rangeAt = -1;
|
||
checkLastColony(state, from, e);
|
||
checkElimination(rules, state, from, e);
|
||
} else {
|
||
pushEvent(state, { type: 'invasionFailed', empire: e, starIdx, turn: state.turn });
|
||
}
|
||
recomputeTotals(rules, state);
|
||
return result;
|
||
}
|
||
|
||
// Pushed the instant an attack (capture or bombardment) leaves the victim
|
||
// with exactly one colony — the beat GNN's "reduced to their last world"
|
||
// story reads off. Checked only from the two attack call sites that already
|
||
// know the attacker (bombard/invade); the generic end-of-turn elimination
|
||
// sweep below has no attacker in scope by construction and is not a
|
||
// colony-loss path itself, so it never needs this check.
|
||
function checkLastColony(state, victim, attacker) {
|
||
const emp = state.empires[victim];
|
||
if (!emp.alive) return;
|
||
if (empireColonies(state, victim).length === 1) {
|
||
pushEvent(state, { type: 'lastColony', empire: victim, attacker, turn: state.turn });
|
||
}
|
||
}
|
||
|
||
// `attacker` defaults to -1 ("unknown/none") for the generic end-of-turn
|
||
// sweep call site, which isn't tied to any specific attack.
|
||
function checkElimination(rules, state, e, attacker = -1) {
|
||
const emp = state.empires[e];
|
||
if (!emp.alive) return;
|
||
if (empireColonies(state, e).length > 0) return;
|
||
emp.alive = false;
|
||
// An empire with no colonies has nothing to resupply from, so its ships are
|
||
// scuttled. Leaving them on the map produced fleets belonging to a dead
|
||
// empire that nothing would ever clean up.
|
||
state.fleets = state.fleets.filter((f) => f.empireIdx !== e);
|
||
pushEvent(state, { type: 'eliminated', empire: e, attacker, turn: state.turn });
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Galactic Council
|
||
|
||
// Share of the SETTLEABLE galaxy that is settled. Measuring against every star
|
||
// made the Council unreachable: a third of systems hold nothing but gas giants
|
||
// and asteroid belts, so "half the galaxy colonised" could never be true no
|
||
// matter how completely the map was carved up.
|
||
export function colonizedFraction(state) {
|
||
const rules = state.rules;
|
||
let habitable = 0;
|
||
for (const star of state.galaxy.stars) {
|
||
const ok = star.planets.some((p) => (rules
|
||
? rules.planetTypes[p.typeId].colonizable
|
||
: true));
|
||
if (ok) habitable += 1;
|
||
}
|
||
const owned = new Set(state.colonies.map((c) => c.starIdx));
|
||
return owned.size / Math.max(1, habitable);
|
||
}
|
||
|
||
export function runCouncil(rules, state) {
|
||
const alive = state.empires.filter((e) => e.alive);
|
||
if (alive.length < 2) return null;
|
||
const totalPop = alive.reduce((t, e) => t + e.totalPop, 0);
|
||
if (totalPop <= 0) return null;
|
||
|
||
// MOO1's rule: the two largest empires stand for High Guardian and everyone
|
||
// else votes between them. Letting every empire stand meant every empire
|
||
// simply voted for itself, so the Council convened forever and elected nobody
|
||
// — thirty sessions in an 800-turn game, all of them null.
|
||
const candidates = alive.slice().sort((a, b) => b.totalPop - a.totalPop || a.idx - b.idx).slice(0, 2);
|
||
const votes = {};
|
||
for (const c of candidates) votes[c.idx] = 0;
|
||
let abstained = 0;
|
||
// Per-voter breakdown, purely additive to the aggregate `votes` above —
|
||
// exists so VegaCouncilSession.js can replay the session one delegate at a
|
||
// time (smallest population first) instead of only ever showing the
|
||
// final tally. `choice` is a candidate idx or null (abstained).
|
||
const voters = [];
|
||
|
||
for (const voter of alive) {
|
||
const weight = voter.totalPop;
|
||
const own = candidates.find((c) => c.idx === voter.idx);
|
||
if (own) { votes[own.idx] += weight; voters.push({ idx: voter.idx, weight, choice: own.idx }); continue; }
|
||
let best = null;
|
||
let bestScore = -Infinity;
|
||
for (const cand of candidates) {
|
||
// Being at war with a candidate is disqualifying on its own.
|
||
const score = (voter.attitude[cand.idx] ?? 0) - (atWar(state, voter.idx, cand.idx) ? 60 : 0);
|
||
if (score > bestScore) { bestScore = score; best = cand; }
|
||
}
|
||
if (!best || bestScore < (rules.council.abstainAttitude ?? -20)) {
|
||
abstained += weight;
|
||
voters.push({ idx: voter.idx, weight, choice: null });
|
||
continue;
|
||
}
|
||
votes[best.idx] += weight;
|
||
voters.push({ idx: voter.idx, weight, choice: best.idx });
|
||
}
|
||
|
||
let winner = -1;
|
||
for (const c of candidates) {
|
||
if (votes[c.idx] / totalPop >= rules.council.winFraction) winner = c.idx;
|
||
}
|
||
|
||
// MOO1's rule: the defeated candidate may REFUSE TO SUBMIT. An empire that is
|
||
// at war with the winner, or simply hates them, walks out of the chamber and
|
||
// the election is void — and everyone who refused is now at war with the
|
||
// presumptive High Guardian.
|
||
//
|
||
// This is what keeps conquest reachable. Without it a dominant empire always
|
||
// won its own council vote a hundred turns before it could finish a war, and
|
||
// no game in a 24-game soak ever ended by conquest.
|
||
let refused = false;
|
||
if (winner >= 0) {
|
||
const loser = candidates.find((c) => c.idx !== winner);
|
||
const bitter = loser && (atWar(state, loser.idx, winner)
|
||
|| (loser.attitude[winner] ?? 0) < (rules.council.submitAttitude ?? -25));
|
||
if (bitter) {
|
||
refused = true;
|
||
pushEvent(state, { type: 'councilRefused', empire: loser.idx, winner, turn: state.turn });
|
||
if (!atWar(state, loser.idx, winner)) declareWar(rules, state, loser.idx, winner);
|
||
// Everyone who withheld their vote resents the near-coronation.
|
||
for (const o of alive) {
|
||
if (o.idx === winner) continue;
|
||
o.attitude[winner] = Math.max(-100, (o.attitude[winner] ?? 0) - 15);
|
||
}
|
||
winner = -1;
|
||
}
|
||
}
|
||
|
||
const result = {
|
||
turn: state.turn, votes, totalPop, abstained, winner, refused,
|
||
candidates: candidates.map((c) => c.idx), voters,
|
||
};
|
||
state.council.lastResult = result;
|
||
state.council.history.push(result);
|
||
state.council.nextTurn = state.turn + rules.council.interval;
|
||
// Consumed by MasterOfVegaGame.js's runToHumanTurn() the moment control
|
||
// returns to the human, which opens VegaCouncilSession.js's ceremony
|
||
// BEFORE the state.over victory check — so a decisive session still gets
|
||
// its ceremony instead of jumping straight to the victory overlay.
|
||
state.council.pendingSession = true;
|
||
pushEvent(state, { type: 'council', ...result });
|
||
if (winner >= 0) {
|
||
state.over = true;
|
||
state.winnerIdx = winner;
|
||
state.victoryKind = 'council';
|
||
pushEvent(state, { type: 'victory', empire: winner, kind: 'council', turn: state.turn });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function checkVictory(rules, state) {
|
||
if (state.over) return;
|
||
const alive = state.empires.filter((e) => e.alive);
|
||
if (alive.length <= 1) {
|
||
state.over = true;
|
||
state.winnerIdx = alive.length ? alive[0].idx : -1;
|
||
state.victoryKind = 'conquest';
|
||
pushEvent(state, { type: 'victory', empire: state.winnerIdx, kind: 'conquest', turn: state.turn });
|
||
return;
|
||
}
|
||
if (state.turn >= (rules.victory.turnCap ?? 800)) {
|
||
// Out of time: the largest empire is declared dominant so a game always
|
||
// terminates. The soak relies on this.
|
||
const best = alive.reduce((x, y) => (y.totalPop > x.totalPop ? y : x), alive[0]);
|
||
state.over = true;
|
||
state.winnerIdx = best.idx;
|
||
state.victoryKind = 'timeout';
|
||
pushEvent(state, { type: 'victory', empire: best.idx, kind: 'timeout', turn: state.turn });
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Turn sequencing
|
||
|
||
export function moveFleetsFor(rules, state, e) { moveFleets(rules, state, e); }
|
||
|
||
// `deferBattlesFor` threads straight through to resolveCombats'
|
||
// `excludeEmpire` — see that function's own comment. Defaults to null
|
||
// (resolve everything, exactly as before) so no existing caller is affected;
|
||
// MasterOfVegaGame.js is the only caller that passes it, for every
|
||
// endEmpireTurn call in the human's turn — its own AND every AI empire's —
|
||
// so a battle the human is in NEVER auto-resolves, only ever through the
|
||
// interactive tactical view (Brian's ask, 2026-08-14).
|
||
export function endEmpireTurn(rules, state, e, { skipMove = false, deferBattlesFor = null } = {}) {
|
||
if (state.empires[e].alive) {
|
||
if (!skipMove) moveFleets(rules, state, e);
|
||
resolveCombats(rules, state, { excludeEmpire: deferBattlesFor });
|
||
for (const emp of state.empires) checkElimination(rules, state, emp.idx);
|
||
}
|
||
|
||
let next = e;
|
||
for (let i = 0; i < state.empires.length; i += 1) {
|
||
next = (next + 1) % state.empires.length;
|
||
if (next === 0) {
|
||
state.turn += 1;
|
||
state.rules = rules;
|
||
// Border/fleet proximity tension, fleet-parked-at-your-colony
|
||
// complaints, and enemy-of-my-enemy triangulation — whole-galaxy
|
||
// passes, run once per calendar turn rather than once per empire, and
|
||
// before the Council so its vote reads this turn's fresh attitude.
|
||
runGalaxyDiplomacyPass(rules, state);
|
||
if (rules.council.council !== false
|
||
&& state.turn >= state.council.nextTurn
|
||
&& colonizedFraction(state) >= rules.council.minColonizedFraction) {
|
||
runCouncil(rules, state);
|
||
}
|
||
// A galaxy where nobody ever fights and no Council vote carries will run
|
||
// to the turn cap and be decided by a tiebreak, which is the least
|
||
// interesting outcome available. Past a point, force the issue.
|
||
const nudge = rules.victory.stalemateTurn ?? 150;
|
||
if (!state.over && state.turn > nudge && state.turn % 40 === 0) breakStalemate(rules, state);
|
||
checkVictory(rules, state);
|
||
}
|
||
if (state.empires[next].alive) break;
|
||
}
|
||
state.current = next;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Player actions
|
||
|
||
export function setSlider(rules, state, colony, channel, value) {
|
||
const v = Math.max(0, Math.min(1, value));
|
||
const others = CHANNELS.filter((c) => c !== channel && !colony.locked[c]);
|
||
const lockedSum = CHANNELS.filter((c) => c !== channel && colony.locked[c])
|
||
.reduce((t, c) => t + colony.sliders[c], 0);
|
||
const room = Math.max(0, 1 - lockedSum);
|
||
const target = Math.min(v, room);
|
||
colony.sliders[channel] = target;
|
||
const rest = room - target;
|
||
const otherSum = others.reduce((t, c) => t + colony.sliders[c], 0);
|
||
if (others.length === 0) return;
|
||
if (otherSum <= 0) {
|
||
for (const c of others) colony.sliders[c] = rest / others.length;
|
||
} else {
|
||
for (const c of others) colony.sliders[c] = (colony.sliders[c] / otherSum) * rest;
|
||
}
|
||
}
|
||
|
||
// A locked field holds its share while the rest renormalise — same contract
|
||
// as setSlider/colony.locked, transposed onto emp.alloc/emp.allocLocked.
|
||
export function setResearchAlloc(rules, state, e, field, value) {
|
||
const emp = state.empires[e];
|
||
emp.allocLocked ??= {};
|
||
if (emp.allocLocked[field]) return;
|
||
const fields = Object.keys(rules.techFields);
|
||
const v = Math.max(0, Math.min(1, value));
|
||
const others = fields.filter((f) => f !== field && !emp.allocLocked[f]);
|
||
const lockedSum = fields.filter((f) => f !== field && emp.allocLocked[f])
|
||
.reduce((t, f) => t + emp.alloc[f], 0);
|
||
const room = Math.max(0, 1 - lockedSum);
|
||
const target = Math.min(v, room);
|
||
emp.alloc[field] = target;
|
||
const rest = room - target;
|
||
if (others.length === 0) return;
|
||
const sum = others.reduce((t, f) => t + emp.alloc[f], 0);
|
||
if (sum <= 0) for (const f of others) emp.alloc[f] = rest / others.length;
|
||
else for (const f of others) emp.alloc[f] = (emp.alloc[f] / sum) * rest;
|
||
}
|
||
|
||
export function enqueue(rules, state, colony, kind, id) {
|
||
if (kind === 'building') {
|
||
if (colony.buildings.includes(id)) return false;
|
||
if (colony.queue.some((q) => q.kind === 'building' && q.id === id)) return false;
|
||
const b = rules.buildings[id];
|
||
const emp = state.empires[colony.empireIdx];
|
||
if (b.prereq && !emp.known[b.prereq]) return false;
|
||
}
|
||
colony.queue.push({ kind, id, progress: 0 });
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Queue `n` copies of the same thing — the repeat-count button on the colony
|
||
* screen. They are stored as n SEPARATE entries rather than one entry with a
|
||
* count, so `processColony` and the save format need no knowledge of repeats at
|
||
* all; the view is what collapses a run of identical ships into a "x N" row.
|
||
* Buildings are unique per colony, so `enqueue` refuses every copy after the
|
||
* first and this returns 1. Returns how many were actually added.
|
||
*/
|
||
export function enqueueMany(rules, state, colony, kind, id, n) {
|
||
let added = 0;
|
||
for (let i = 0; i < Math.max(0, Math.floor(n)); i += 1) {
|
||
if (!enqueue(rules, state, colony, kind, id)) break;
|
||
added += 1;
|
||
}
|
||
return added;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Colony Focus — the colony screen's per-colony autopilot (VegaColonyView.js).
|
||
// Runs once per human turn, right after that empire's own beginEmpireTurn
|
||
// (MasterOfVegaGame.js's runToHumanTurn), and mirrors the shape of VegaAI.js's
|
||
// manageColony building ladder / preferredWarship. Deliberately reimplemented
|
||
// rather than imported: VegaAI.js already imports FROM this file, so pulling
|
||
// AI helpers in here would cycle, and duplicating a dozen lines of scoring
|
||
// logic is cheaper than restructuring either file around a shared surface.
|
||
|
||
/**
|
||
* First candidate in `ids` that clears enqueue's own guards (already-built,
|
||
* already-queued, prereq) plus an affordability guard mirroring VegaAI.js's
|
||
* `if (b.cost > prod * 25) continue;` — so a small colony is never saddled
|
||
* with upkeep it cannot carry. Pure — used both to decide what to actually
|
||
* queue (enqueueFirstAffordableBuilding) and, read-only, by the advisor
|
||
* recommendation heuristics (recommendColonyFocus/recommendAllocationFocus)
|
||
* to check "would something here actually get built."
|
||
*/
|
||
function firstEligibleBuildingId(rules, state, colony, ids) {
|
||
const prod = colonyProduction(rules, state, colony);
|
||
const emp = state.empires[colony.empireIdx];
|
||
for (const id of ids) {
|
||
const b = rules.buildings[id];
|
||
if (!b) continue;
|
||
if (colony.buildings.includes(id)) continue;
|
||
if (colony.queue.some((q) => q.kind === 'building' && q.id === id)) continue;
|
||
if (b.prereq && !emp.known[b.prereq]) continue;
|
||
if (b.cost > prod * 25) continue;
|
||
return id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function enqueueFirstAffordableBuilding(rules, state, colony, ids) {
|
||
const id = firstEligibleBuildingId(rules, state, colony, ids);
|
||
return id ? enqueue(rules, state, colony, 'building', id) : false;
|
||
}
|
||
|
||
const byCostAsc = (a, b) => a.cost - b.cost;
|
||
|
||
// Candidate id lists, shared between the FOCUS_PICKERS below (which actually
|
||
// enqueue) and the advisor recommendation heuristics further down (which only
|
||
// need to know whether something in the category is eligible).
|
||
const industryBuildingIds = (rules) => rules.buildingList
|
||
.filter((b) => b.channel === 'industry').sort(byCostAsc).map((b) => b.id);
|
||
const otherBuildingIds = (rules) => rules.buildingList
|
||
.filter((b) => b.channel !== 'industry').sort(byCostAsc).map((b) => b.id);
|
||
const researchBuildingIds = (rules) => rules.buildingList
|
||
.filter((b) => b.channel === 'research').sort(byCostAsc).map((b) => b.id);
|
||
const growthBuildingIds = (rules) => rules.buildingList
|
||
.filter((b) => b.effects?.maxPopBonus || b.effects?.growthMult).sort(byCostAsc).map((b) => b.id);
|
||
const tradeBuildingIds = (rules) => rules.buildingList
|
||
.filter((b) => b.effects?.tradeBonus || b.effects?.tradeMult).sort(byCostAsc).map((b) => b.id);
|
||
const defenseBuildingIds = (rules) => rules.buildingList
|
||
.filter((b) => b.channel === 'defense').sort(byCostAsc).map((b) => b.id);
|
||
const anyBuildingIds = (rules) => rules.buildingList.slice().sort(byCostAsc).map((b) => b.id);
|
||
|
||
function pickImprovement(rules, state, colony) {
|
||
enqueueFirstAffordableBuilding(rules, state, colony, [...industryBuildingIds(rules), ...otherBuildingIds(rules)]);
|
||
}
|
||
|
||
// No fallback if nothing qualifies — an empty queue is correct here, since
|
||
// processColony already spills unspent construction BC into research.
|
||
function pickResearchOnly(rules, state, colony) {
|
||
enqueueFirstAffordableBuilding(rules, state, colony, researchBuildingIds(rules));
|
||
}
|
||
|
||
function pickGrowth(rules, state, colony) {
|
||
enqueueFirstAffordableBuilding(rules, state, colony, growthBuildingIds(rules));
|
||
}
|
||
|
||
function pickTrade(rules, state, colony) {
|
||
enqueueFirstAffordableBuilding(rules, state, colony, tradeBuildingIds(rules));
|
||
}
|
||
|
||
function pickDefense(rules, state, colony) {
|
||
enqueueFirstAffordableBuilding(rules, state, colony, defenseBuildingIds(rules));
|
||
}
|
||
|
||
// Brian's target fleet mix for Colony Focus: Fleet Production — 4 frigates :
|
||
// 3 destroyers : 2 cruisers : 1 battleship. Any warship hull missing from
|
||
// this table (there shouldn't be one — frigate/destroyer/cruiser/battleship
|
||
// are the only role:'warship' hulls in mastervega-rules.json) falls back to
|
||
// weight 1.
|
||
const FLEET_MIX_WEIGHT = { frigate: 4, destroyer: 3, cruiser: 2, battleship: 1 };
|
||
|
||
// Independently-implemented twin of VegaAI.js's preferredWarship (same
|
||
// (hp + damage*4) / cost scoring, same ~15-turns-of-budget affordability
|
||
// cutoff — see the header note above for why this is duplicated rather than
|
||
// shared), plus a weighted diversity pass: rather than always maxing out the
|
||
// single best-value hull, it counts what's already docked at this colony's
|
||
// star and favours whichever warship role is furthest below its target share
|
||
// of FLEET_MIX_WEIGHT (count/weight, lowest wins — the standard weighted
|
||
// round-robin comparison), ties broken by score. Starting from an empty
|
||
// fleet this converges to the 4:3:2:1 ratio after one full 10-ship cycle and
|
||
// holds it indefinitely, rather than the old equal-count-per-hull split.
|
||
function pickFleet(rules, state, colony) {
|
||
const e = colony.empireIdx;
|
||
const budget = colonyBuildRate(rules, state, colony);
|
||
const hullIds = rules.hullList.filter((h) => h.role === 'warship').map((h) => h.id);
|
||
const present = {};
|
||
for (const f of fleetsAt(state, colony.starIdx)) {
|
||
if (f.empireIdx !== e) continue;
|
||
for (const s of f.ships) {
|
||
if (hullIds.includes(s.hullId)) present[s.hullId] = (present[s.hullId] ?? 0) + s.count;
|
||
}
|
||
}
|
||
let best = null;
|
||
let bestRatio = Infinity;
|
||
let bestScore = -Infinity;
|
||
for (const hullId of hullIds) {
|
||
const d = empireDesign(rules, state, e, hullId);
|
||
if (d.damage <= 0) continue;
|
||
if (d.cost > budget * 15) continue;
|
||
const count = present[hullId] ?? 0;
|
||
const weight = FLEET_MIX_WEIGHT[hullId] ?? 1;
|
||
const ratio = count / weight;
|
||
const score = (d.hp + d.damage * 4) / d.cost;
|
||
if (ratio < bestRatio || (ratio === bestRatio && score > bestScore)) {
|
||
best = hullId; bestRatio = ratio; bestScore = score;
|
||
}
|
||
}
|
||
// Before any weapon tech is known every hull scores damage=0 and `best`
|
||
// stays null — VegaAI.js's preferredWarship falls back to a bare frigate
|
||
// in that same situation rather than building nothing, and Fleet
|
||
// Production mirrors it: an empty queue every turn until the first weapon
|
||
// is researched would read as the focus doing nothing at all.
|
||
enqueue(rules, state, colony, 'ship', best ?? 'frigate');
|
||
}
|
||
|
||
// How many of `hullId` this empire already has committed, empire-wide: built
|
||
// and sitting in a fleet, PLUS still under construction in any colony's
|
||
// queue. Counting the queue too keeps every expansion-focused colony reading
|
||
// the same live total instead of each one independently topping up toward
|
||
// the same target the instant its own queue goes empty — pickExpansion calls
|
||
// this every time it is asked for the next item, so double-committing would
|
||
// otherwise be the default outcome, not an edge case.
|
||
function empireHullCommitment(state, e, hullId) {
|
||
let n = 0;
|
||
for (const f of empireFleets(state, e)) {
|
||
for (const s of f.ships) if (s.hullId === hullId) n += s.count;
|
||
}
|
||
for (const colony of empireColonies(state, e)) {
|
||
for (const item of colony.queue) if (item.kind === 'ship' && item.id === hullId) n += 1;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
// Colony Focus: Galactic Expansion (Brian's ask, 2026-08-14). One colony
|
||
// ship for every discovered-but-unclaimed world (discoveredOpenWorlds), then
|
||
// — once that demand is covered — a standing 2-frigates-per-colony-ship
|
||
// escort ratio empire-wide. This is a RATIO, not a literal per-ship escort
|
||
// assignment (mastervega has no ship-to-ship escort tagging); "two frigates
|
||
// exist for every colony ship in the fleet" is what satisfies it. No
|
||
// fallback once both targets are met, same as pickResearchOnly — an empty
|
||
// queue is correct and processColony spills the unspent BC into research.
|
||
function pickExpansion(rules, state, colony) {
|
||
const e = colony.empireIdx;
|
||
const openTargets = discoveredOpenWorlds(rules, state, e);
|
||
if (empireHullCommitment(state, e, 'colonyship') < openTargets) {
|
||
enqueue(rules, state, colony, 'ship', 'colonyship');
|
||
return;
|
||
}
|
||
let colonyShipsBuilt = 0;
|
||
for (const f of empireFleets(state, e)) {
|
||
for (const s of f.ships) if (s.hullId === 'colonyship') colonyShipsBuilt += s.count;
|
||
}
|
||
if (empireHullCommitment(state, e, 'frigate') < colonyShipsBuilt * 2) {
|
||
enqueue(rules, state, colony, 'ship', 'frigate');
|
||
}
|
||
}
|
||
|
||
const FOCUS_PICKERS = {
|
||
improvement: pickImprovement,
|
||
research: pickResearchOnly,
|
||
fleet: pickFleet,
|
||
expansion: pickExpansion,
|
||
growth: pickGrowth,
|
||
trade: pickTrade,
|
||
defense: pickDefense,
|
||
};
|
||
|
||
/**
|
||
* For every colony of empire `e` with a non-manual focus and an empty queue,
|
||
* enqueue at most one thing. Called once per human turn — see
|
||
* MasterOfVegaGame.js's runToHumanTurn for the exact hook point.
|
||
*/
|
||
export function autoQueueColonies(rules, state, e) {
|
||
for (const colony of empireColonies(state, e)) {
|
||
if (!colony.focus || colony.focus === 'manual') continue;
|
||
if (colony.queue.length > 0) continue;
|
||
FOCUS_PICKERS[colony.focus]?.(rules, state, colony);
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Advisor recommendations — what the Colony Focus / Allocation Focus flyouts
|
||
// (VegaColonyView.js) point at, and what checkAdvisorRecommendations compares
|
||
// the current setting against once a turn. Both functions are pure (never
|
||
// mutate, never enqueue) so they are cheap to call on every flyout render.
|
||
|
||
/**
|
||
* Empire-wide fleet strength — the same computation VegaAI.js's
|
||
* computeStrategy uses for its own `myFleet` (VegaAI.js:71), reused here so
|
||
* "fleet condition of the species" means what the AI already judges itself
|
||
* by, rather than a second invented metric. Exported (renamed from the old
|
||
* human-only `myFleetPower`) so GNN's Military ranking page can compute the
|
||
* same number for every empire, not just the human's.
|
||
*/
|
||
export function empireFleetPower(rules, state, e) {
|
||
return empireFleets(state, e).reduce((t, f) => t + fleetPower(rules, state, f), 0);
|
||
}
|
||
|
||
// VegaAI.js's own peacetime fleet-adequacy threshold (VegaAI.js:180),
|
||
// reused rather than inventing a separate number.
|
||
const fleetAdequacyThreshold = (state) => 400 + state.turn * 4;
|
||
|
||
// How urgently the advisors push "Fleet Production" (recommendColonyFocus's
|
||
// fleet rung) — a multiplier on fleetAdequacyThreshold rather than a flat
|
||
// yes/no, so it fires on a sliding bar instead of ever getting stuck fully
|
||
// on or off. Before first contact there is nobody to threaten the empire or
|
||
// be threatened by, so the rung is throttled hard rather than firing off the
|
||
// raw baseline alone (Brian's ask). Once contact exists, the COLDEST
|
||
// contacted relationship sets the tone rather than the average — one
|
||
// species turning hostile matters more than three others staying friendly —
|
||
// so a fleet the player hasn't needed yet can suddenly read as advisable
|
||
// again, and eases back off once every contact is warm. Mirrors
|
||
// VegaDiplomacy.js's moodOf tiers (-60/-20/20/60) without importing it:
|
||
// VegaDiplomacy.js imports FROM this file, so the reverse import would be
|
||
// circular.
|
||
export function fleetUrgency(state, e) {
|
||
const others = state.empires.filter((o) => o.alive && o.idx !== e && state.empires[e].contacted[o.idx]);
|
||
if (!others.length) return { multiplier: 0.25, threat: null };
|
||
let coldest = others[0];
|
||
for (const o of others) {
|
||
if ((o.attitude[e] ?? 0) < (coldest.attitude[e] ?? 0)) coldest = o;
|
||
}
|
||
const att = coldest.attitude[e] ?? 0;
|
||
if (att <= -60) return { multiplier: 1.5, threat: { empire: coldest, moodWord: 'hostile' } };
|
||
if (att <= -20) return { multiplier: 1.15, threat: { empire: coldest, moodWord: 'cold' } };
|
||
if (att < 20) return { multiplier: 0.85, threat: null };
|
||
return { multiplier: 0.5, threat: null };
|
||
}
|
||
|
||
/**
|
||
* First applicable rung of a priority ladder — deliberately mirrors
|
||
* FOCUS_PICKERS' order, but reports the first CATEGORY that applies rather
|
||
* than the first one enqueue would accept, and always resolves to something
|
||
* (falls through to 'manual') so the colony screen always has a row to point
|
||
* at.
|
||
*/
|
||
export function recommendColonyFocus(rules, state, colony) {
|
||
const star = state.galaxy.stars[colony.starIdx];
|
||
const planet = star.planets[colony.orbit];
|
||
const type = rules.planetTypes[planet.typeId];
|
||
const maxPop = colonyMaxPop(rules, state, colony);
|
||
const e = colony.empireIdx;
|
||
|
||
const expansion = expansionFavored(rules, state, e);
|
||
if (expansion.favored) {
|
||
return {
|
||
value: 'expansion', label: 'Galactic Expansion',
|
||
reason: `${expansion.openTargets} known world${expansion.openTargets === 1 ? '' : 's'} `
|
||
+ `${expansion.openTargets === 1 ? 'is' : 'are'} still unclaimed and no rival has been met yet — `
|
||
+ 'grab territory now while the galaxy is still open.',
|
||
};
|
||
}
|
||
|
||
const growthId = firstEligibleBuildingId(rules, state, colony, growthBuildingIds(rules));
|
||
if (colony.pop < maxPop * 0.7 && growthId) {
|
||
const pct = Math.round((colony.pop / maxPop) * 100);
|
||
return {
|
||
value: 'growth', label: 'Population Growth',
|
||
reason: `This ${type.name.toLowerCase()} world is at ${pct}% of its population ceiling — `
|
||
+ `a ${rules.buildings[growthId].name} would raise it before this colony turns to military objectives.`,
|
||
};
|
||
}
|
||
|
||
// Two separate reasons to recommend Colony Improvement here, both ahead of
|
||
// Fleet Production in this ladder: factories deep in the red (< 70% of cap
|
||
// — Brian's ask, 2026-08-14, same "red" line recommendAllocationFocus's
|
||
// own Industrial Buildout rung already used) mean this colony hasn't built
|
||
// out its own infrastructure yet and shouldn't be steered toward warships
|
||
// before it has; factories nearly AT cap (>= 90%) is the opposite
|
||
// situation — capacity is about to run out and another building keeps
|
||
// growth going. Both point at the same fix, so they share one rung.
|
||
const industryId = firstEligibleBuildingId(rules, state, colony, industryBuildingIds(rules));
|
||
const factoryCap = colonyFactoryCap(rules, state, colony);
|
||
const effF = effectiveFactories(rules, state, colony);
|
||
const factoriesBehind = effF < factoryCap * 0.7;
|
||
const factoriesNearCap = effF >= factoryCap * 0.9;
|
||
if ((factoriesBehind || factoriesNearCap) && industryId) {
|
||
return {
|
||
value: 'improvement', label: 'Colony Improvement',
|
||
reason: factoriesBehind
|
||
? `Factories are well below your cap (${Math.floor(effF)}/${factoryCap}) — build up this colony's `
|
||
+ `infrastructure with a ${rules.buildings[industryId].name} before turning toward military objectives.`
|
||
: `Factories are running at ${Math.floor(effF)}/${factoryCap} — `
|
||
+ `a ${rules.buildings[industryId].name} would raise your production ceiling.`,
|
||
};
|
||
}
|
||
|
||
const { multiplier: fleetMultiplier, threat } = fleetUrgency(state, e);
|
||
if (empireFleetPower(rules, state, e) < fleetAdequacyThreshold(state) * fleetMultiplier) {
|
||
return {
|
||
value: 'fleet', label: 'Fleet Production',
|
||
reason: threat
|
||
? `Empire-wide fleet strength is below what's typical this far into the game, and relations `
|
||
+ `with ${threat.empire.name} have turned ${threat.moodWord} — additional warships would help, `
|
||
+ 'built toward a 4:3:2:1 frigate/destroyer/cruiser/battleship mix.'
|
||
: "Empire-wide fleet strength is below what's typical this far into the game — "
|
||
+ 'additional warships would help, built toward a 4:3:2:1 frigate/destroyer/cruiser/battleship mix.',
|
||
};
|
||
}
|
||
|
||
const tradeIds = tradeBuildingIds(rules);
|
||
const hasTrade = colony.buildings.some((b) => tradeIds.includes(b));
|
||
const tradeId = firstEligibleBuildingId(rules, state, colony, tradeIds);
|
||
if (!hasTrade && colony.pop >= 3 && tradeId) {
|
||
return {
|
||
value: 'trade', label: 'Trade & Commerce',
|
||
reason: `This colony has no trade infrastructure yet — a ${rules.buildings[tradeId].name} `
|
||
+ 'would raise its BC income.',
|
||
};
|
||
}
|
||
|
||
const atWarWithAnyone = state.empires.some((o) => o.alive && o.idx !== e && atWar(state, e, o.idx));
|
||
const defenseId = firstEligibleBuildingId(rules, state, colony, defenseBuildingIds(rules));
|
||
const defenseCap = colonyDefenseCap(rules, state, colony);
|
||
if (atWarWithAnyone && colony.defenseHp < defenseCap * 0.5 && defenseId) {
|
||
return {
|
||
value: 'defense', label: 'Homeworld Defense',
|
||
reason: `Your empire is at war and this colony's defences are under-built `
|
||
+ `(${Math.round(colony.defenseHp)}/${defenseCap}) — a ${rules.buildings[defenseId].name} would help repel raids.`,
|
||
};
|
||
}
|
||
|
||
const researchId = firstEligibleBuildingId(rules, state, colony, researchBuildingIds(rules));
|
||
if (colony.pop >= 3 && researchId) {
|
||
return {
|
||
value: 'research', label: 'Research Focus',
|
||
reason: `A ${rules.buildings[researchId].name} would raise this colony's contribution to empire research.`,
|
||
};
|
||
}
|
||
|
||
const anyId = firstEligibleBuildingId(rules, state, colony, anyBuildingIds(rules));
|
||
if (anyId) {
|
||
return {
|
||
value: 'improvement', label: 'Colony Improvement',
|
||
reason: `There's still room to grow this colony's infrastructure — a ${rules.buildings[anyId].name} is available.`,
|
||
};
|
||
}
|
||
|
||
return {
|
||
value: 'manual', label: 'Manual',
|
||
reason: 'This colony is fully built out and the fleet is in good shape — steer it yourself as opportunities come up.',
|
||
};
|
||
}
|
||
|
||
/** Same signal order as recommendColonyFocus, mapped to slider presets instead of buildings. */
|
||
export function recommendAllocationFocus(rules, state, colony) {
|
||
const maxPop = colonyMaxPop(rules, state, colony);
|
||
const factoryCap = colonyFactoryCap(rules, state, colony);
|
||
const effF = effectiveFactories(rules, state, colony);
|
||
const e = colony.empireIdx;
|
||
|
||
const expansion = expansionFavored(rules, state, e);
|
||
if (expansion.favored) {
|
||
return {
|
||
key: 'expansion', label: 'Expansion',
|
||
reason: `${expansion.openTargets} known world${expansion.openTargets === 1 ? '' : 's'} `
|
||
+ `${expansion.openTargets === 1 ? 'is' : 'are'} still unclaimed and no rival has been met yet — `
|
||
+ 'fund colony ships and their escorts over everything else.',
|
||
};
|
||
}
|
||
|
||
if (colony.pop < maxPop * 0.7 || colony.waste > 3) {
|
||
return {
|
||
key: 'growth', label: 'Population Growth',
|
||
reason: colony.waste > 3
|
||
? `Uncleaned waste (${colony.waste.toFixed(1)}) is capping this colony's population — `
|
||
+ 'more Ecology funding clears it fastest.'
|
||
: `Population is at ${Math.round((colony.pop / maxPop) * 100)}% of its ceiling — `
|
||
+ 'more Ecology funding clears the way for growth.',
|
||
};
|
||
}
|
||
|
||
if (effF < factoryCap * 0.7) {
|
||
return {
|
||
key: 'production', label: 'Industrial Buildout',
|
||
reason: `Factories are still well below your cap (${Math.floor(effF)}/${factoryCap}) — `
|
||
+ 'more Industry funding builds them out faster.',
|
||
};
|
||
}
|
||
|
||
if (empireFleetPower(rules, state, e) < fleetAdequacyThreshold(state)) {
|
||
return {
|
||
key: 'military', label: 'Military Buildup',
|
||
reason: "Empire-wide fleet strength is below what's typical this far into the game.",
|
||
};
|
||
}
|
||
|
||
// Deliberately NOT `colony.sliders.research < someThreshold`: the Default
|
||
// and Research Focus presets sit at 0.20 and 0.50 respectively, so reading
|
||
// the colony's own current slider back would just report which preset was
|
||
// picked last — recommend Default while on Research (0.50 isn't low) and
|
||
// recommend Research while on Default (0.20 is low), flipping every time
|
||
// the player follows the advice. A research building still worth putting
|
||
// up is a fact about the colony, not about whichever preset is live, so it
|
||
// can't oscillate the same way — same signal recommendColonyFocus's own
|
||
// research rung uses.
|
||
const researchId = firstEligibleBuildingId(rules, state, colony, researchBuildingIds(rules));
|
||
if (colony.pop >= 3 && researchId) {
|
||
return {
|
||
key: 'research', label: 'Research Focus',
|
||
reason: `Nothing more urgent elsewhere — a ${rules.buildings[researchId].name} would raise this `
|
||
+ "colony's contribution to empire research.",
|
||
};
|
||
}
|
||
|
||
return {
|
||
key: 'default', label: 'Default',
|
||
reason: 'A balanced split fits this colony well right now.',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Set a colony's Colony Focus autopilot and arm the advisor's quiet timer —
|
||
* shared by VegaColonyView.js's flyout and VegaColoniesScreen.js's inline
|
||
* pill so both apply exactly the same side effect rather than two copies of
|
||
* it drifting apart.
|
||
*/
|
||
export function applyColonyFocus(state, colony, focusValue) {
|
||
colony.focus = focusValue;
|
||
colony.advisor.focusQuietUntil = state.turn + 15;
|
||
}
|
||
|
||
/**
|
||
* Overwrite a colony's allocation sliders with a preset and arm the
|
||
* advisor's quiet timer. A deliberate bulk overwrite that BYPASSES
|
||
* colony.locked (unlike setSlider) — `opt` is one of VegaColonyView.js's
|
||
* ALLOCATION_FOCUS_OPTIONS, `{ key, sliders }`.
|
||
*/
|
||
export function applyAllocationFocus(state, colony, opt) {
|
||
colony.sliders = { ...opt.sliders };
|
||
colony.advisor.allocKey = opt.key;
|
||
colony.advisor.allocQuietUntil = state.turn + 15;
|
||
}
|
||
|
||
/**
|
||
* The Colonies screen's (VegaColoniesScreen.js) advisor commentary for one
|
||
* colony: the same recommended Colony Focus / Allocation Focus judgment the
|
||
* single-colony flyouts already make, plus explicit deficiency call-outs,
|
||
* as an ordered array of short lines for the terminal panel to type out.
|
||
* Pure — never mutates.
|
||
*/
|
||
export function advisorColonyReport(rules, state, colony) {
|
||
const maxPop = colonyMaxPop(rules, state, colony);
|
||
const factoryCap = colonyFactoryCap(rules, state, colony);
|
||
const effF = effectiveFactories(rules, state, colony);
|
||
const defenseCap = colonyDefenseCap(rules, state, colony);
|
||
|
||
const lines = [];
|
||
const focus = recommendColonyFocus(rules, state, colony);
|
||
lines.push(`COLONY FOCUS: ${focus.label}`);
|
||
lines.push(focus.reason);
|
||
|
||
const alloc = recommendAllocationFocus(rules, state, colony);
|
||
lines.push(`ALLOCATION FOCUS: ${alloc.label}`);
|
||
lines.push(alloc.reason);
|
||
|
||
const deficiencies = [];
|
||
const popPct = Math.round((colony.pop / Math.max(1, maxPop)) * 100);
|
||
if (popPct < 70) {
|
||
deficiencies.push(`Population is at ${popPct}% of what this world can support.`);
|
||
}
|
||
if (effF < factoryCap * 0.7) {
|
||
deficiencies.push(`Factories are under-built: ${Math.floor(effF)} of ${factoryCap}.`);
|
||
}
|
||
if (colony.factories > effF + 0.5) {
|
||
deficiencies.push(`${Math.floor(colony.factories - effF)} factories sit mothballed — too few people to staff them.`);
|
||
}
|
||
if (colony.waste > 3) {
|
||
deficiencies.push(`Uncleaned waste (${colony.waste.toFixed(1)}) is capping population growth.`);
|
||
}
|
||
if (colony.defenseHp < defenseCap * 0.5) {
|
||
deficiencies.push(`Defences are thin: ${Math.round(colony.defenseHp)} of ${defenseCap}.`);
|
||
}
|
||
if (deficiencies.length) {
|
||
lines.push('DEFICIENCIES:');
|
||
lines.push(...deficiencies);
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
/**
|
||
* Once-a-turn re-analysis. Only touches a colony whose advisor tracking has
|
||
* been armed (a Quiet* field is non-null, meaning the player picked
|
||
* something at least once in that flyout) and whose quiet period has
|
||
* elapsed. Notifies at most once per still-outstanding recommendation — the
|
||
* Notified* markers dedupe so a persisting mismatch doesn't repeat every
|
||
* turn, and clear themselves the moment the setting matches the current
|
||
* recommendation again so a later divergence is reported fresh.
|
||
*/
|
||
export function checkAdvisorRecommendations(rules, state, e) {
|
||
for (const colony of empireColonies(state, e)) {
|
||
const adv = colony.advisor;
|
||
if (!adv) continue;
|
||
const domains = [];
|
||
|
||
if (adv.focusQuietUntil != null && state.turn >= adv.focusQuietUntil) {
|
||
const rec = recommendColonyFocus(rules, state, colony);
|
||
if (rec.value === colony.focus) {
|
||
adv.focusNotifiedValue = null;
|
||
} else if (rec.value !== adv.focusNotifiedValue) {
|
||
adv.focusNotifiedValue = rec.value;
|
||
domains.push('focus');
|
||
}
|
||
}
|
||
|
||
if (adv.allocQuietUntil != null && state.turn >= adv.allocQuietUntil) {
|
||
const rec = recommendAllocationFocus(rules, state, colony);
|
||
if (rec.key === adv.allocKey) {
|
||
adv.allocNotifiedKey = null;
|
||
} else if (rec.key !== adv.allocNotifiedKey) {
|
||
adv.allocNotifiedKey = rec.key;
|
||
domains.push('alloc');
|
||
}
|
||
}
|
||
|
||
if (domains.length) {
|
||
pushEvent(state, {
|
||
type: 'advisorRecommendation', empire: e, colonyId: colony.id, starIdx: colony.starIdx,
|
||
domains, turn: state.turn,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
export function dequeue(rules, state, colony, index) {
|
||
if (index < 0 || index >= colony.queue.length) return false;
|
||
colony.queue.splice(index, 1);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Reorder the queue. `progress` is a property of the item object, so it travels
|
||
* with the move for free — demoting the half-built head item banks its BC
|
||
* rather than losing them, and promoting something else does not hand it the
|
||
* head's progress. `processColony` only ever looks at `queue[0]`, so a splice
|
||
* between turns is safe.
|
||
*/
|
||
export function moveQueueItem(rules, state, colony, from, to) {
|
||
const n = colony.queue.length;
|
||
if (from < 0 || from >= n) return false;
|
||
const dest = Math.max(0, Math.min(n - 1, to));
|
||
if (dest === from) return true;
|
||
const [item] = colony.queue.splice(from, 1);
|
||
colony.queue.splice(dest, 0, item);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Collapse the queue into display runs: consecutive identical ships become one
|
||
* `{ index, lastIndex, item, count }` row. `enqueueMany` stores a repeat as N
|
||
* separate entries so the turn processor and the save format never learn about
|
||
* repeats, and this is what folds them back up for a UI.
|
||
*
|
||
* A part-built entry never joins a run — its progress has to stay individually
|
||
* legible, and it is always at the head anyway, since slot 0 is the only one
|
||
* the engine ever spends on.
|
||
*/
|
||
export function collapseQueue(colony) {
|
||
const rows = [];
|
||
colony.queue.forEach((item, index) => {
|
||
const last = rows[rows.length - 1];
|
||
const runnable = item.kind === 'ship' && (item.progress ?? 0) === 0;
|
||
if (last && runnable && last.item.kind === 'ship' && last.item.id === item.id
|
||
&& (last.item.progress ?? 0) === 0) {
|
||
last.count += 1;
|
||
last.lastIndex = index;
|
||
return;
|
||
}
|
||
rows.push({ index, lastIndex: index, item, count: 1 });
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
/**
|
||
* Move a whole collapsed run one row up (dir -1) or down (dir +1), where `rows`
|
||
* came from `collapseQueue`. A "x 5" row is five queue entries, so this is five
|
||
* splices — and the two directions do NOT use the same indices, because every
|
||
* splice shifts what comes after it:
|
||
*
|
||
* up — the copies still waiting behind keep their original slots, so both
|
||
* ends of the move advance together (`+ k`).
|
||
* down — pulling the head copy out slides the rest of the run down into the
|
||
* slot it just vacated, so the SAME pair of indices moves every copy.
|
||
*
|
||
* Getting this wrong does not throw; it silently interleaves the run with its
|
||
* neighbour, which is why it is here in the headless tier and checked in the
|
||
* verifier rather than left in the view.
|
||
*/
|
||
export function moveQueueRun(rules, state, colony, rows, ri, dir) {
|
||
const run = rows[ri];
|
||
const neighbour = rows[ri + dir];
|
||
if (!run || !neighbour) return false;
|
||
for (let k = 0; k < run.count; k += 1) {
|
||
if (dir < 0) moveQueueItem(rules, state, colony, run.index + k, neighbour.index + k);
|
||
else moveQueueItem(rules, state, colony, run.index, neighbour.lastIndex);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// A leader's list price (rules.leaders[id].hireCost) is fixed; what an
|
||
// empire actually pays climbs with how many leaders it already has on
|
||
// staff, so stockpiling BC and clearing the shared pool cheaply stops being
|
||
// the dominant strategy once a roster gets going. The 1st hire is always
|
||
// list price.
|
||
export function leaderHireCost(rules, state, e, leaderId) {
|
||
const leader = rules.leaders[leaderId];
|
||
if (!leader) return Infinity;
|
||
const owned = state.empires[e].leaders.length;
|
||
return Math.round(leader.hireCost * (1 + owned * rules.leaderHiring.costScalePerOwned));
|
||
}
|
||
|
||
export function hireLeader(rules, state, e, leaderId) {
|
||
const emp = state.empires[e];
|
||
const leader = rules.leaders[leaderId];
|
||
if (!leader || emp.leaders.some((l) => l.leaderId === leaderId)) return false;
|
||
const cost = leaderHireCost(rules, state, e, leaderId);
|
||
if (emp.bc < cost) return false;
|
||
emp.bc -= cost;
|
||
emp.leaders.push({ leaderId, assignKind: null, assignId: -1 });
|
||
pushEvent(state, {
|
||
type: 'leaderHired', empire: e, leaderId, cost, turn: state.turn,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
export function assignLeader(rules, state, e, leaderId, kind, id) {
|
||
const emp = state.empires[e];
|
||
const l = emp.leaders.find((x) => x.leaderId === leaderId);
|
||
if (!l) return false;
|
||
const leader = rules.leaders[leaderId];
|
||
if (leader.kind === 'admin' && kind !== 'colony') return false;
|
||
if (leader.kind === 'captain' && kind !== 'fleet') return false;
|
||
// One leader per posting.
|
||
for (const other of emp.leaders) {
|
||
if (other !== l && other.assignKind === kind && other.assignId === id) other.assignId = -1, other.assignKind = null;
|
||
}
|
||
l.assignKind = kind;
|
||
l.assignId = id;
|
||
return true;
|
||
}
|
||
|
||
export function unassignLeader(rules, state, e, leaderId) {
|
||
const emp = state.empires[e];
|
||
const l = emp.leaders.find((x) => x.leaderId === leaderId);
|
||
if (!l || l.assignKind === null) return false;
|
||
l.assignKind = null;
|
||
l.assignId = -1;
|
||
return true;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Serialisation
|
||
|
||
export function serialize(state) {
|
||
const { rules, ...rest } = state;
|
||
// Every underscore-prefixed field is a derived memo cache (component bags,
|
||
// range sets and the turn stamps that invalidate them). Dropping the values
|
||
// but keeping the stamps would make a reloaded game serialise differently
|
||
// from the one it was saved from, so the whole prefix goes.
|
||
return JSON.stringify(rest, (key, value) => (key.startsWith('_') ? undefined : value));
|
||
}
|
||
|
||
export function deserialize(json) {
|
||
const state = JSON.parse(json);
|
||
if (state.version !== 1) return null;
|
||
// A save from before pushEvent stamped events with `seq` has none on any
|
||
// existing event — nextEventSeq starts at 0 either way (pushEvent uses
|
||
// pre-increment), so the first NEW event in a reloaded old save gets seq 1,
|
||
// safely past every un-seq'd historical event's implicit `undefined`
|
||
// (always fails `ev.seq > cursor`, so old events are just never
|
||
// war-memory-countable, same cold-start tradeoff as every other
|
||
// back-filled field here).
|
||
state.nextEventSeq ??= 0;
|
||
for (const emp of state.empires) {
|
||
emp._comps = null; emp._compsAt = -1; emp._range = null; emp._rangeAt = -1;
|
||
emp._designs = null; emp._designsAt = -1;
|
||
// A save from before the research-lock padlock existed has no allocLocked
|
||
// at all — back-fill it the same way a fresh empire gets one, rather than
|
||
// bumping the save version over one new always-empty-by-default field.
|
||
emp.allocLocked ??= {};
|
||
// Same convention for the diplomacy-expansion fields: trade agreements,
|
||
// gift cooldown tracking, and foreign-fleet dwell tracking.
|
||
emp.tradeAgreements ??= {};
|
||
emp.lastGiftTurn ??= {};
|
||
emp.lastBombardTurn ??= {};
|
||
emp.fleetIntrusions ??= {};
|
||
emp.espionageMission ??= {};
|
||
emp.warMemory ??= {};
|
||
emp.warMemorySeq ??= 0;
|
||
}
|
||
// Same convention for a save from before GNN existed — but a bare
|
||
// `gnn ??= {history:[]}` is not enough here: state.events is never cleared
|
||
// (only capped/trimmed), so a save with retained war/peace/tech events
|
||
// would otherwise have every one of them treated as a brand-new unreported
|
||
// GNN story the instant the player next ends a turn, flooding them with
|
||
// retroactive news. Marking every existing event gnnAnnounced up front
|
||
// prevents that burst — GNN only ever reports what happens from here on.
|
||
if (!state.gnn) {
|
||
state.gnn = { history: [] };
|
||
for (const ev of state.events) ev.gnnAnnounced = true;
|
||
}
|
||
// Same convention for a save from before the Council Session ceremony
|
||
// existed — an old save can never be mid-session (the feature didn't
|
||
// exist to leave one pending), so false is always correct here.
|
||
state.council.pendingSession ??= false;
|
||
// Same convention for a save from before Colony Focus existed.
|
||
for (const c of state.colonies) {
|
||
c.focus ??= 'manual';
|
||
c.advisor ??= {
|
||
focusQuietUntil: null, focusNotifiedValue: null,
|
||
allocQuietUntil: null, allocKey: null, allocNotifiedKey: null,
|
||
};
|
||
}
|
||
return state;
|
||
}
|
||
|
||
export function hashState(state) {
|
||
const str = serialize(state);
|
||
let h = 2166136261;
|
||
for (let i = 0; i < str.length; i += 1) {
|
||
h ^= str.charCodeAt(i);
|
||
h = Math.imul(h, 16777619);
|
||
}
|
||
return (h >>> 0).toString(16);
|
||
}
|