feat(combat): freeze colony defense regen and defensive builds while under siege

Add siegeFreezesDefense mechanic (default true) that stops a colony's
passive defenseHp trickle and blocks defensive building completion at the
front of the build queue while a hostile warship fleet is present.

This fixes the recurring "Under Attack" notification loop reported by Brian
where defenseHp would regenerate each turn, re-arming the pendingBattlesFor
gate and triggering fresh popup notices for an already-beaten colony.

Also adds an AI dispatch throttle in manageFleets: idle fleets no longer
open redundant attack orders against colonies the empire already holds orbit
over with defenseHp beaten to 0. Excluded fleets fall through to rally logic
and naturally join the existing siege or find a fresh front.

New helpers: isDefensiveBuildingId, isHostileWarshipFleet, hasHostileWarshipFleet
— shared across processColony, pendingBattlesFor, and VegaAI to ensure a
single definition of "under active siege".

Includes tests (sections 5d, 5e) covering regen freeze, build queue freeze,
siegeFreezesDefense=false toggle, and AI throttle.
This commit is contained in:
Brian Fertig 2026-08-15 08:00:40 -06:00
parent 869f041b39
commit d920d8e476
4 changed files with 230 additions and 8 deletions

View File

@ -498,7 +498,7 @@
}, },
"combat": { "combat": {
"_readme": "Tactical grid battle. The grid is small on purpose — MOO1 battles are decided by fleet composition, not manoeuvre.", "_readme": "Tactical grid battle. The grid is small on purpose — MOO1 battles are decided by fleet composition, not manoeuvre. siegeFreezesDefense (default true) stops a colony's passive defenseHp trickle (and any defensive building at the front of its build queue) while a hostile warship fleet is present — set false to restore the old always-regen behaviour.",
"gridCols": 12, "gridCols": 12,
"gridRows": 8, "gridRows": 8,
"maxRounds": 60, "maxRounds": 60,
@ -518,7 +518,8 @@
"bombardDefensiveBuildingDestroyMult": 0.25, "bombardDefensiveBuildingDestroyMult": 0.25,
"groundOddsScale": 0.01, "groundOddsScale": 0.01,
"cloakEvasion": 0.02, "cloakEvasion": 0.02,
"singularityShieldPierce": 0.5 "singularityShieldPierce": 0.5,
"siegeFreezesDefense": true
}, },
"_combatV2Readme": "Constants for the VegaCombatV2 per-ship prototype only (behind ?movsim's Live/V2 toggle) — completely separate from 'combat' above, which the live engine still reads unmodified. World space is continuous 2D, not a lane, and combat runs in continuous simulated TIME, not discrete rounds: every ship has its own firing cooldown (turnSeconds, seconds between shots — armed the moment it first comes into weapon range of its target, matching real-time engagement rather than a synchronized lockstep round) and moves/turns continuously every tick rather than snapping once per round. moveUnitsPerSpeed is world units per SECOND now (was per old discrete round, at 100) — deliberately slowed down, not just time-converted, per Brian's explicit ask for a slower, more deliberate pace. turnRateScale converts each hull's turnRateBase (still degrees, still hand-tuned per hull, still living in the hulls block) into a per-second max angular velocity; spinUpSeconds is how long a ship takes to spin up to that max rate from a standing start, which is what gives turning real momentum instead of an instant snap. separationUnit/separationWeight tune the collision-avoidance steering (VegaCombatV2.js's computeSeparation) — separationUnit is the 'personal space' radius per point of a hull's sizeScale, so bigger ships keep proportionally more distance. Ships also carry real LINEAR momentum (vx/vy, independent of facing) — each hull's brakeSeconds (hulls block) sets how hard it can actually decelerate; a hull whose stopping distance at full speed exceeds beamRange physically cannot stop before reaching its target and blows through for another pass instead (see computeShipMove's comment). avoidAccel is a flat, hull-independent acceleration budget for collision avoidance ONLY, deliberately NOT drawn from a hull's own (possibly weak) linearAccel — several ships converging on the same weighted-random target approach nearly in formation, and a frigate's deliberately poor brakes must not also mean it can't swerve around a teammate on that same course. Kept deliberately gentle (not a strong repulsion) per Brian's explicit ask — ships should attempt to avoid each other, not bounce, and overlapping when their actual objectives require it (e.g. a strafing pass, or several ships converging on one target) is fine. beamRange/missileRange are unchanged by any of this (spatial, not temporal) — every other combat constant (hit-chance coefficients, cloakEvasion, singularityShieldPierce) is shared by reading rules.combat directly, since none of it is range/position/time-scaled; retreatAfterRound/disengageRound are reinterpreted as seconds (×turnSeconds) rather than duplicated here. damageMultiplier scales each weapon's raw output (before shield mitigation) in VegaCombatV2.js's fireMounts() ONLY — the live engine's weapon damage (read from the same shared VegaShips.js designs) is completely untouched, so this shortens V2 battle length without changing live-game balance at all. Brian's explicit ask, after watching V2 play out live and finding fights took too many exchanges to resolve. centeringAccel is a flat, hull-independent acceleration budget (same shape as avoidAccel) that gently pulls a ship back toward roughly where the battle STARTED once it has wandered meaningfully beyond that starting footprint — VegaCombatV2.js's computeCenteringForce/createBattle for the full mechanism; Brian's ask after noticing battles look great early on and then drift off the fixed camera framing by the end. Zero force within the battle's own actual starting spread (formation-and-fleet-size-aware, not a guessed world fraction), ramping in only beyond it. maxDurationSeconds is V2's OWN total time budget (VegaCombatV2.js's maxDurationSec(b) helper), deliberately decoupled from rules.combat.maxRounds (shared with the live engine's round cap) — measuring actual battle outcomes found the disengage timer was firing in essentially every simulated battle before combat concluded naturally regardless of damage level, so raising the ceiling (120s -> 240s) and disengageFraction (0.85 -> 0.9, now 216s before the weaker side is forced to flee, was 102s) gives combat meaningfully more room to resolve on its own merits. IMPORTANT interaction: raising the duration ceiling alone made battles LONGER, not shorter (removing what had effectively been an implicit cap) — damageMultiplier is the lever that actually shortens things once the ceiling isn't artificially truncating fights; went 1.6 -> 2.8 after measuring that duration keeps dropping sharply as multiplier climbs while decisiveness (fraction resolving by real destruction, not forced retreat) holds or improves, not degrades.", "_combatV2Readme": "Constants for the VegaCombatV2 per-ship prototype only (behind ?movsim's Live/V2 toggle) — completely separate from 'combat' above, which the live engine still reads unmodified. World space is continuous 2D, not a lane, and combat runs in continuous simulated TIME, not discrete rounds: every ship has its own firing cooldown (turnSeconds, seconds between shots — armed the moment it first comes into weapon range of its target, matching real-time engagement rather than a synchronized lockstep round) and moves/turns continuously every tick rather than snapping once per round. moveUnitsPerSpeed is world units per SECOND now (was per old discrete round, at 100) — deliberately slowed down, not just time-converted, per Brian's explicit ask for a slower, more deliberate pace. turnRateScale converts each hull's turnRateBase (still degrees, still hand-tuned per hull, still living in the hulls block) into a per-second max angular velocity; spinUpSeconds is how long a ship takes to spin up to that max rate from a standing start, which is what gives turning real momentum instead of an instant snap. separationUnit/separationWeight tune the collision-avoidance steering (VegaCombatV2.js's computeSeparation) — separationUnit is the 'personal space' radius per point of a hull's sizeScale, so bigger ships keep proportionally more distance. Ships also carry real LINEAR momentum (vx/vy, independent of facing) — each hull's brakeSeconds (hulls block) sets how hard it can actually decelerate; a hull whose stopping distance at full speed exceeds beamRange physically cannot stop before reaching its target and blows through for another pass instead (see computeShipMove's comment). avoidAccel is a flat, hull-independent acceleration budget for collision avoidance ONLY, deliberately NOT drawn from a hull's own (possibly weak) linearAccel — several ships converging on the same weighted-random target approach nearly in formation, and a frigate's deliberately poor brakes must not also mean it can't swerve around a teammate on that same course. Kept deliberately gentle (not a strong repulsion) per Brian's explicit ask — ships should attempt to avoid each other, not bounce, and overlapping when their actual objectives require it (e.g. a strafing pass, or several ships converging on one target) is fine. beamRange/missileRange are unchanged by any of this (spatial, not temporal) — every other combat constant (hit-chance coefficients, cloakEvasion, singularityShieldPierce) is shared by reading rules.combat directly, since none of it is range/position/time-scaled; retreatAfterRound/disengageRound are reinterpreted as seconds (×turnSeconds) rather than duplicated here. damageMultiplier scales each weapon's raw output (before shield mitigation) in VegaCombatV2.js's fireMounts() ONLY — the live engine's weapon damage (read from the same shared VegaShips.js designs) is completely untouched, so this shortens V2 battle length without changing live-game balance at all. Brian's explicit ask, after watching V2 play out live and finding fights took too many exchanges to resolve. centeringAccel is a flat, hull-independent acceleration budget (same shape as avoidAccel) that gently pulls a ship back toward roughly where the battle STARTED once it has wandered meaningfully beyond that starting footprint — VegaCombatV2.js's computeCenteringForce/createBattle for the full mechanism; Brian's ask after noticing battles look great early on and then drift off the fixed camera framing by the end. Zero force within the battle's own actual starting spread (formation-and-fleet-size-aware, not a guessed world fraction), ramping in only beyond it. maxDurationSeconds is V2's OWN total time budget (VegaCombatV2.js's maxDurationSec(b) helper), deliberately decoupled from rules.combat.maxRounds (shared with the live engine's round cap) — measuring actual battle outcomes found the disengage timer was firing in essentially every simulated battle before combat concluded naturally regardless of damage level, so raising the ceiling (120s -> 240s) and disengageFraction (0.85 -> 0.9, now 216s before the weaker side is forced to flee, was 102s) gives combat meaningfully more room to resolve on its own merits. IMPORTANT interaction: raising the duration ceiling alone made battles LONGER, not shorter (removing what had effectively been an implicit cap) — damageMultiplier is the lever that actually shortens things once the ceiling isn't artificially truncating fights; went 1.6 -> 2.8 after measuring that duration keeps dropping sharply as multiplier climbs while decisiveness (fraction resolving by real destruction, not forced retreat) holds or improves, not degrades.",

View File

@ -399,6 +399,21 @@ function manageFleets(rules, state, e, strat) {
for (const enemy of strat.enemies) { for (const enemy of strat.enemies) {
for (const c of empireColonies(state, enemy.idx)) { for (const c of empireColonies(state, enemy.idx)) {
if (!reach[c.starIdx]) continue; if (!reach[c.starIdx]) continue;
// Nothing left to shoot at: batteries are down, no defending fleet
// of the enemy's own is present, and we already hold orbit here
// with warships of our own. Sending another warship fleet at an
// already-beaten colony achieves nothing (prepareBattleAt returns
// null once defenseHp<=0 with no defender) and used to mean idle
// fleet after idle fleet independently retargeting the same dying
// colony turn after turn instead of opening a fresh front or
// reinforcing a fight that still needs winning (Brian's ask,
// 2026-08-15). Excluded fleets fall through to step 5's rally
// logic below, which naturally joins the fleet already here.
const alreadyBeaten = c.defenseHp <= 0
&& !state.fleets.some((f) => f.starIdx === c.starIdx && f.empireIdx === enemy.idx && f.ships.length);
const weHoldOrbitHere = state.fleets
.some((f) => f.starIdx === c.starIdx && f.empireIdx === e && f.ships.length);
if (alreadyBeaten && weHoldOrbitHere) continue;
// Planetary defences count for less than live warships: they cannot // Planetary defences count for less than live warships: they cannot
// chase, cannot reinforce, and can be ground down across several // chase, cannot reinforce, and can be ground down across several
// turns of siege. Weighting them one-for-one against fleet power made // turns of siege. Weighting them one-for-one against fleet power made

View File

@ -151,6 +151,15 @@ function buildingEffectMult(rules, colony, key) {
} }
return m; return m;
} }
// The four buildings that raise a colony's defenseHp cap (channel: 'defense'
// — missilebase/planetaryshield/artemisnet) or its ground defence against
// invasion (groundbattery, channel: null but effects.groundDefense). Derived
// from the data rather than a hardcoded id list so a future defensive
// building picks this up automatically.
function isDefensiveBuildingId(rules, id) {
const b = rules.buildings[id];
return !!b && (b.channel === 'defense' || b.effects?.groundDefense != null);
}
export function colonyMaxPop(rules, state, colony) { export function colonyMaxPop(rules, state, colony) {
const emp = state.empires[colony.empireIdx]; const emp = state.empires[colony.empireIdx];
@ -201,6 +210,27 @@ export function colonyDefenseCap(rules, state, colony) {
return Math.round((100 + comps.planetaryShield * 30) * buildingMult(rules, colony, 'defense')); return Math.round((100 + comps.planetaryShield * 30) * buildingMult(rules, colony, 'defense'));
} }
// A fleet only threatens a colony if it can actually fire a shot — a pure
// troop-transport or colony-ship fleet never can (pendingBattlesFor's own
// comment). Factored out so every place that cares whether a colony is under
// active siege (defenseHp regen freeze + defensive-building queue freeze in
// processColony, pendingBattlesFor's own foe scan, VegaAI's attack-fleet
// dispatch throttle) agrees on exactly the same definition and can never
// drift apart.
function isHostileWarshipFleet(rules, state, fleet, ownerIdx) {
return fleet.empireIdx !== ownerIdx
&& atWar(state, ownerIdx, fleet.empireIdx)
&& fleet.ships.some((s) => rules.hulls[s.hullId]?.role === 'warship' && s.count > 0);
}
// True when a live hostile fleet with at least one warship-role ship sits at
// `starIdx`, at war with `ownerIdx` — "this colony is under active siege"
// (Brian's ask, 2026-08-15). A stray scout or unarmed transport does not
// count.
export function hasHostileWarshipFleet(rules, state, starIdx, ownerIdx) {
return state.fleets.some((f) => f.starIdx === starIdx && isHostileWarshipFleet(rules, state, f, ownerIdx));
}
/** /**
* BC per turn actually reaching the build queue, used for the "N turns" figures * 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 * the colony screen quotes. That is the `ships` share PLUS the two spillovers
@ -942,10 +972,20 @@ function processColony(rules, state, colony) {
indSpill = Math.max(0, share.industry - built * factoryCost); indSpill = Math.max(0, share.industry - built * factoryCost);
} }
// --- Defence: planetary batteries, capped by tech. // --- Defence: planetary batteries, capped by tech. Frozen entirely while a
// live hostile warship fleet sits in orbit (rules.combat.siegeFreezesDefense
// !== false) — this passive trickle, completely independent of siege state,
// used to mean beating a colony's batteries to 0 in one battle only ever
// bought ONE turn's peace, since this line put them straight back above 0
// the very next beginEmpireTurn, re-arming pendingBattlesFor's/
// prepareBattleAt's own `defenseHp > 0` gates below with a fresh "Under
// Attack" popup every turn (Brian's ask, 2026-08-15). Frozen production
// spills into the build queue exactly like an already-capped colony's does.
const capD = colonyDefenseCap(rules, state, colony); const capD = colonyDefenseCap(rules, state, colony);
const sieged = rules.combat.siegeFreezesDefense !== false
&& hasHostileWarshipFleet(rules, state, colony.starIdx, e);
let defSpill = 0; let defSpill = 0;
if (colony.defenseHp >= capD) { if (colony.defenseHp >= capD || sieged) {
defSpill = share.defense; defSpill = share.defense;
} else { } else {
const added = Math.min(capD - colony.defenseHp, share.defense); const added = Math.min(capD - colony.defenseHp, share.defense);
@ -953,13 +993,29 @@ function processColony(rules, state, colony) {
defSpill = Math.max(0, share.defense - added); defSpill = Math.max(0, share.defense - added);
} }
// --- Construction: the build queue (ships and buildings). // --- Construction: the build queue (ships and buildings). A defensive
// building (missilebase/groundbattery/planetaryshield/artemisnet) at the
// FRONT of the queue is also frozen while sieged — completing one mid-siege
// (or letting it creep to 100% progress, ready to pop the instant the enemy
// fleet leaves) is the same class of bug as the defenseHp trickle above,
// just one queue slot over (Brian's ask, 2026-08-15). Deliberately uniform:
// an item already sitting at high progress when the siege starts is frozen
// exactly like one at 0%, rather than special-cased to still complete.
// Strict FIFO: a blocked defensive building at the front stalls the whole
// queue behind it (matches how an empty queue already behaves), so
// remaining build this turn spills to research rather than skipping ahead
// to fund something else.
let build = share.ships + indSpill + defSpill; let build = share.ships + indSpill + defSpill;
let buildSpill = 0; let buildSpill = 0;
let blockedFront = false;
let guard = 0; let guard = 0;
while (build > 0 && colony.queue.length > 0 && guard < 20) { while (build > 0 && colony.queue.length > 0 && guard < 20) {
guard += 1; guard += 1;
const item = colony.queue[0]; const item = colony.queue[0];
if (sieged && item.kind === 'building' && isDefensiveBuildingId(rules, item.id)) {
blockedFront = true;
break;
}
const cost = queueItemCost(rules, state, colony, item); const cost = queueItemCost(rules, state, colony, item);
const need = cost - item.progress; const need = cost - item.progress;
if (build >= need) { if (build >= need) {
@ -971,7 +1027,7 @@ function processColony(rules, state, colony) {
build = 0; build = 0;
} }
} }
if (colony.queue.length === 0) buildSpill = build; if (colony.queue.length === 0 || blockedFront) buildSpill = build;
// --- Research absorbs everything left over. // --- Research absorbs everything left over.
const research = (share.research + ecoSpill + buildSpill) * (skills.researchMult ?? 1); const research = (share.research + ecoSpill + buildSpill) * (skills.researchMult ?? 1);
@ -1622,8 +1678,7 @@ export function pendingBattlesFor(rules, state, e) {
// got an Under Attack notice (and often several, once per AI empire's // got an Under Attack notice (and often several, once per AI empire's
// move that turn) for a fight that could never do anything to it // move that turn) for a fight that could never do anything to it
// (Brian's ask, 2026-08-14). // (Brian's ask, 2026-08-14).
if (g.starIdx === starIdx && g.empireIdx !== e && atWar(state, e, g.empireIdx) if (g.starIdx === starIdx && isHostileWarshipFleet(rules, state, g, e)) {
&& g.ships.some((s) => rules.hulls[s.hullId]?.role === 'warship' && s.count > 0)) {
foes.add(g.empireIdx); foes.add(g.empireIdx);
} }
} }

View File

@ -2287,6 +2287,157 @@ section('5c. AI attack-fleet escalation');
} }
} }
// ---------------------------------------------------------------------------
section('5d. Siege freezes passive defenseHp regen (processColony)');
// ---------------------------------------------------------------------------
// Brian's ask, 2026-08-15: a colony beaten to defenseHp 0 must STAY at 0 for
// as long as a hostile warship fleet remains, not regenerate straight back
// above 0 next turn and re-arm the pendingBattlesFor/prepareBattleAt gates
// section 5b already covers — that one-turn reprieve was the actual cause of
// a fresh "Under Attack" notice popping every single turn a siege dragged on.
{
const mk = (speciesIds) => {
const st = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'spiral', seed: 202, difficultyId: 'normal',
speciesIds, humanIndex: 0,
});
st.rules = RULES;
st.fleets = [];
return st;
};
// --- the trickle itself: frozen while sieged, resumes once the fleet leaves.
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[0];
const colony = Logic.colonyAt(st, home);
colony.defenseHp = 0;
colony.sliders = { ships: 0, defense: 1, industry: 0, ecology: 0, research: 0 };
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 1, home, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
Logic.beginEmpireTurn(RULES, st, 0);
check('defenseHp does not regenerate while a hostile warship fleet is present',
colony.defenseHp === 0, `${colony.defenseHp}`);
st.fleets = st.fleets.filter((f) => f.empireIdx !== 1);
Logic.beginEmpireTurn(RULES, st, 0);
check('defenseHp resumes regenerating once the hostile fleet is gone',
colony.defenseHp > 0, `${colony.defenseHp}`);
}
// --- across several sieged turns in a row, defenseHp never re-arms, so
// pendingBattlesFor/prepareBattleAt never surface a battle for it — the
// exact recurring-notification scenario Brian reported.
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[0];
const colony = Logic.colonyAt(st, home);
colony.defenseHp = 0;
colony.sliders = { ships: 0, defense: 1, industry: 0, ecology: 0, research: 0 };
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 1, home, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
for (let i = 0; i < 3; i += 1) Logic.beginEmpireTurn(RULES, st, 0);
check('after several sieged turns, defenseHp is still 0 (never re-armed)', colony.defenseHp === 0);
check('pendingBattlesFor never surfaces it',
!Logic.pendingBattlesFor(RULES, st, 0).some((p) => p.starIdx === home && p.other === 1));
check('prepareBattleAt returns null (no battle at all)',
Logic.prepareBattleAt(RULES, st, home, 0, 1) === null);
}
// --- a defensive building at the front of the queue does not complete
// while sieged, and completes normally once the siege lifts.
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[0];
const colony = Logic.colonyAt(st, home);
colony.defenseHp = 0;
colony.sliders = { ships: 0, defense: 0, industry: 1, ecology: 0, research: 0 };
Logic.grantTech(RULES, st, 0, 'hypervrockets');
Logic.enqueue(RULES, st, colony, 'building', 'missilebase');
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 1, home, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
for (let i = 0; i < 30; i += 1) Logic.beginEmpireTurn(RULES, st, 0);
check('a defensive building at the front of the queue does not complete while sieged',
!colony.buildings.includes('missilebase'), JSON.stringify(colony.queue));
st.fleets = st.fleets.filter((f) => f.empireIdx !== 1);
for (let i = 0; i < 30; i += 1) Logic.beginEmpireTurn(RULES, st, 0);
check('the same building completes normally once the siege lifts',
colony.buildings.includes('missilebase'));
}
// --- the siegeFreezesDefense knob: false restores the old always-regen
// behaviour exactly, so a difficulty preset (or a future save) can opt out.
{
const noFreezeRules = compileRules({ ...rulesJson, combat: { ...rulesJson.combat, siegeFreezesDefense: false } });
const st = Logic.createGame(noFreezeRules, {
sizeId: 'medium', shapeId: 'spiral', seed: 202, difficultyId: 'normal',
speciesIds: ['human', 'kkrix'], humanIndex: 0,
});
st.rules = noFreezeRules;
st.fleets = [];
const home = st.galaxy.homeIdx[0];
const colony = Logic.colonyAt(st, home);
colony.defenseHp = 0;
colony.sliders = { ships: 0, defense: 1, industry: 0, ecology: 0, research: 0 };
Diplo.declareWar(noFreezeRules, st, 0, 1);
Logic.addFleet(noFreezeRules, st, 1, home, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
Logic.beginEmpireTurn(noFreezeRules, st, 0);
check('siegeFreezesDefense: false restores the old always-regen behaviour',
colony.defenseHp > 0, `${colony.defenseHp}`);
}
}
// ---------------------------------------------------------------------------
section('5e. AI siege dispatch throttle');
// ---------------------------------------------------------------------------
// Brian's ask, 2026-08-15: a fresh idle warship fleet must not open a second
// attack order against an enemy colony this empire already holds orbit over
// with defenseHp beaten to 0 — there's nothing left to shoot at, and doing so
// used to mean idle fleet after idle fleet dribbling in on separate turns
// instead of reinforcing the siege already under way (via step 5's rally) or
// finding a fresh front.
{
const st = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', seed: 1, difficultyId: 'normal',
speciesIds: ['kkrix', 'rrashaa'], humanIndex: -1,
});
st.rules = RULES;
st.fleets = [];
Logic.grantTech(RULES, st, 0, 'thoriumcells');
const home0 = st.galaxy.homeIdx[0];
const home1 = st.galaxy.homeIdx[1];
const elsewhere = st.galaxy.stars.findIndex((s, i) => i !== home0 && i !== home1);
check('a third star exists to park the threat fleet away from the target', elsewhere >= 0);
Diplo.declareWar(RULES, st, 0, 1);
// Push computeStrategy into 'war' phase, same as section 5c.
Logic.addFleet(RULES, st, 1, elsewhere, [{ hullId: 'battleship', mark: 1, count: 5 }]);
const defCol = st.colonies.find((c) => c.starIdx === home1);
defCol.defenseHp = 0;
// A fresh idle warship fleet at home, created (and so processed by
// manageFleets) BEFORE the siege fleet below — this fixture has only one
// owned colony, so an idle fleet with no bigger friend nearby gets pulled
// back to it by step 5's unrelated pre-existing "gather at the colony
// nearest the front" fallback; creating fresh first means it's evaluated
// while the siege fleet still genuinely holds home1, isolating the guard
// clause this test exists for from that unrelated ordering quirk.
const fresh = Logic.addFleet(RULES, st, 0, home0, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
// We already hold orbit over the beaten colony with a live warship fleet,
// EQUAL power to fresh (so step 5's rally has no strictly-bigger friend to
// chase toward home1 either).
const siege = Logic.addFleet(RULES, st, 0, home1, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
check('the siege and fresh fleets start at equal power (isolates the guard clause)',
Logic.fleetPower(RULES, st, siege) === Logic.fleetPower(RULES, st, fresh));
Logic.beginEmpireTurn(RULES, st, 0);
AI.runAITurn(RULES, st, 0);
check('a fresh fleet does not open a redundant attack order against an already-beaten, already-held colony',
fresh.starIdx === home0 && fresh.toStar < 0,
JSON.stringify({ starIdx: fresh.starIdx, toStar: fresh.toStar }));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
section('6. Colony economy'); section('6. Colony economy');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------