fix: starbases cannot attack alone; AI targets correct colony at contested stars

- Starbases no longer count toward attacker power in holdsOrbit(), invade(),
  resolveCombats(), pendingBattlesFor(), and prepareBattleAt(). A starbase
  never leaves its home system, so it must never alone enable orbital
  superiority or pick fights with neighbouring hostile colonies (Brian, 2026-08-15).

- VegaAI manageFleets 2a/2b now uses targetColonyAt() instead of colonyAt()
  to resolve the colony it is actually at war with. At multi-colony stars
  this fixes the bug where an idle fleet sitting on a hostile undefended
  colony would silently check a different (not-at-war) colony sharing the
  star and never bombard/invade (Brian, 2026-08-15).

- Add test suite 5f (starbases cannot attack alone) and 5g (AI bombard/invade
  at contested multi-colony stars).
This commit is contained in:
Brian Fertig 2026-08-15 10:54:16 -06:00
parent 9e463164d9
commit 46a2979555
3 changed files with 212 additions and 13 deletions

View File

@ -10,7 +10,7 @@
// from the difficulty multipliers in the rules file. // from the difficulty multipliers in the rules file.
import { import {
empireColonies, empireFleets, colonyAt, reachableStars, canColonize, colonize, invade, empireColonies, empireFleets, targetColonyAt, reachableStars, canColonize, colonize, invade,
sendFleet, canSendFleet, enqueue, setSlider, setResearchAlloc, colonyProduction, sendFleet, canSendFleet, enqueue, setSlider, setResearchAlloc, colonyProduction,
colonyFactoryCap, effectiveFactories, colonyDefenseCap, empireDesign, empireComponents, colonyFactoryCap, effectiveFactories, colonyDefenseCap, empireDesign, empireComponents,
fleetPower, atWar, rand, nextResearchTarget, invasionForecast, bombard, fleetPower, atWar, rand, nextResearchTarget, invasionForecast, bombard,
@ -282,8 +282,18 @@ function manageFleets(rules, state, e, strat) {
// 2a. Bombard whatever we are sitting on top of. Softening the colony makes // 2a. Bombard whatever we are sitting on top of. Softening the colony makes
// the subsequent landing viable, and against a cornered empire that will // the subsequent landing viable, and against a cornered empire that will
// never yield clean orbit it is the only way to finish the war at all. // never yield clean orbit it is the only way to finish the war at all.
//
// targetColonyAt, not colonyAt: a star can host colonies from more than
// one empire (a contested/shared system — exactly the shape a cornered
// empire's last colony ends up in), and colonyAt() returns whichever one
// happens to be first in state.colonies, with no relation to who this
// fleet is actually at war with. That used to mean an idle fleet sitting
// right on top of a hostile, undefended colony would check some OTHER
// (often not-at-war) colony sharing the star, fail the atWar guard, and
// never bombard/invade at all — reported as "everyone is at war with me,
// nobody bombards my defenseless last colony" (Brian, 2026-08-15).
{ {
const colony = colonyAt(state, fleet.starIdx); const colony = targetColonyAt(state, e, fleet.starIdx, null);
if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx) && power > 0) { if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx) && power > 0) {
// Bomb only when we cannot take the world intact. A captured colony is // Bomb only when we cannot take the world intact. A captured colony is
// worth far more than a dead one, and bombarding unconditionally turned // worth far more than a dead one, and bombarding unconditionally turned
@ -314,8 +324,10 @@ function manageFleets(rules, state, e, strat) {
} }
// 2b. Invade a cleared colony, or move up to one our warships are besieging. // 2b. Invade a cleared colony, or move up to one our warships are besieging.
// Same targetColonyAt fix as 2a above — a shared star must resolve to the
// colony we're actually at war with, not an arbitrary array-order pick.
if (hasTransport) { if (hasTransport) {
const colony = colonyAt(state, fleet.starIdx); const colony = targetColonyAt(state, e, fleet.starIdx, null);
if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx)) { if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx)) {
// Only land when the marines can actually carry the world. A failed // Only land when the marines can actually carry the world. A failed
// landing costs the whole transport wave for nothing, so waiting for a // landing costs the whole transport wave for nothing, so waiting for a

View File

@ -483,7 +483,15 @@ export function fleetSpeed(rules, state, fleet) {
return Number.isFinite(speed) ? Math.max(1, speed) : 0; return Number.isFinite(speed) ? Math.max(1, speed) : 0;
} }
export function fleetPower(rules, state, fleet) { // includeBase controls whether a starbase (role 'base') contributes to the
// total — on by default (a starbase is real, countable combat power for
// most purposes: guard formulas, threat assessment, defending itself). Pass
// { includeBase: false } specifically for an ATTACKER's power in an
// orbital-superiority check — a starbase never leaves the system it was
// built in, so it must never be what lets an attack on a hostile colony
// sharing that star succeed; that requires an actual attack fleet (see
// holdsOrbit and invade(), Brian's ask, 2026-08-15).
export function fleetPower(rules, state, fleet, { includeBase = true } = {}) {
const emp = state.empires[fleet.empireIdx]; const emp = state.empires[fleet.empireIdx];
const spec = rules.species[emp.speciesId]; const spec = rules.species[emp.speciesId];
const skills = fleetLeaderSkills(rules, state, fleet); const skills = fleetLeaderSkills(rules, state, fleet);
@ -491,7 +499,7 @@ export function fleetPower(rules, state, fleet) {
for (const s of fleet.ships) { for (const s of fleet.ships) {
if (s.count <= 0) continue; if (s.count <= 0) continue;
const d = empireDesign(rules, state, fleet.empireIdx, s.hullId, Object.keys(skills).length ? skills : null); const d = empireDesign(rules, state, fleet.empireIdx, s.hullId, Object.keys(skills).length ? skills : null);
if (d.role !== 'warship' && d.role !== 'base') continue; if (d.role !== 'warship' && !(includeBase && d.role === 'base')) continue;
p += s.count * (d.hp + d.damage * 4); p += s.count * (d.hp + d.damage * 4);
} }
return Math.round(p); return Math.round(p);
@ -1505,7 +1513,12 @@ export function resolveCombats(rules, state, { excludeEmpire = null } = {}) {
if (result) results.push(result); if (result) results.push(result);
} }
} }
// A hostile fleet in orbit of a defended colony must also fight the planet. // A hostile fleet in orbit of a defended colony must also fight the
// planet — but only a genuine attack fleet, not a lone starbase (or any
// other non-warship presence) sharing the same star. A starbase never
// leaves the system it was built in, so it must never single-handedly
// pick a fight with a neighbouring hostile colony's batteries; that
// needs a real fleet present too (Brian's ask, 2026-08-15).
if (colony) { if (colony) {
for (const a of empires) { for (const a of empires) {
if (a === defenderIdx) continue; if (a === defenderIdx) continue;
@ -1513,6 +1526,8 @@ export function resolveCombats(rules, state, { excludeEmpire = null } = {}) {
if (a === excludeEmpire || defenderIdx === excludeEmpire) continue; if (a === excludeEmpire || defenderIdx === excludeEmpire) continue;
if (state.fleets.some((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx && f.ships.length)) continue; if (state.fleets.some((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx && f.ships.length)) continue;
if (colony.defenseHp <= 0) continue; if (colony.defenseHp <= 0) continue;
if (!state.fleets.some((f) => f.starIdx === starIdx && f.empireIdx === a
&& isHostileWarshipFleet(rules, state, f, defenderIdx))) continue;
const result = fightAt(rules, state, starIdx, a, defenderIdx); const result = fightAt(rules, state, starIdx, a, defenderIdx);
if (result) results.push(result); if (result) results.push(result);
} }
@ -1610,7 +1625,16 @@ export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation =
} }
const defColony = colony && colony.empireIdx === defenderIdx ? colony : null; const defColony = colony && colony.empireIdx === defenderIdx ? colony : null;
if (!attacker.ships.length) return null; if (!attacker.ships.length) return null;
if (!defender.ships.length && !(defColony && defColony.defenseHp > 0)) return null; if (!defender.ships.length) {
// A colony with no defending fleet of its own can only be fought by a
// genuine attack fleet — never by a lone starbase (or any other
// non-warship presence, e.g. a stray scout) that merely happens to
// share the same star. A starbase never leaves the system it was built
// in, so its presence must never be what lets an attack on a
// neighbouring hostile colony succeed (Brian's ask, 2026-08-15).
const attackerHasWarship = attacker.ships.some((s) => rules.hulls[s.hullId]?.role === 'warship');
if (!attackerHasWarship || !(defColony && defColony.defenseHp > 0)) return null;
}
// The planet's typeId rides along purely for the tactical view's art (the // 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 // real planets spritesheet instead of a generic icon) — the combat engine
@ -1710,8 +1734,14 @@ export function pendingBattlesFor(rules, state, e) {
// (Brian's ask, 2026-08-14): a star can host colonies from more than one // (Brian's ask, 2026-08-14): a star can host colonies from more than one
// empire, so this has to search all of them for one that's actually // empire, so this has to search all of them for one that's actually
// hostile to e, not just check whichever one colonyAt() handed back. // hostile to e, not just check whichever one colonyAt() handed back.
const colony = coloniesAt(state, starIdx) // Gated on e having a genuine warship-role fleet of its own here — a
.find((c) => c.empireIdx !== e && atWar(state, e, c.empireIdx) && c.defenseHp > 0); // lone starbase (or any other non-warship presence) sharing this star
// must never be offered "attack this hostile colony" via the tactical
// view, only a real attack fleet can (Brian's ask, 2026-08-15).
const eHasWarshipHere = state.fleets.some((f) => f.starIdx === starIdx && f.empireIdx === e
&& f.ships.some((s) => rules.hulls[s.hullId]?.role === 'warship' && s.count > 0));
const colony = eHasWarshipHere ? coloniesAt(state, starIdx)
.find((c) => c.empireIdx !== e && atWar(state, e, c.empireIdx) && c.defenseHp > 0) : null;
if (colony) foes.add(colony.empireIdx); if (colony) foes.add(colony.empireIdx);
for (const other of foes) out.push({ starIdx, other }); for (const other of foes) out.push({ starIdx, other });
}; };
@ -1886,21 +1916,25 @@ function deliverPopulation(rules, state, f) {
// screen. Without it (VegaAI.js's callers, which only know a starIdx), the // screen. Without it (VegaAI.js's callers, which only know a starIdx), the
// fallback targets whichever colony here the attacker is actually at war // fallback targets whichever colony here the attacker is actually at war
// with, rather than just whichever happens to be first in the array. // with, rather than just whichever happens to be first in the array.
function targetColonyAt(state, e, starIdx, orbit) { export function targetColonyAt(state, e, starIdx, orbit) {
const here = coloniesAt(state, starIdx); const here = coloniesAt(state, starIdx);
if (orbit != null) return here.find((c) => c.orbit === orbit) ?? null; if (orbit != null) return here.find((c) => c.orbit === orbit) ?? null;
return here.find((c) => atWar(state, e, c.empireIdx)) ?? here[0] ?? null; return here.find((c) => atWar(state, e, c.empireIdx)) ?? here[0] ?? null;
} }
// Orbital superiority at a system: our combat power there exceeds theirs. // Orbital superiority at a system: our combat power there exceeds theirs.
// Bombardment and invasion both require it. // Bombardment and invasion both require it. attPower excludes starbases
// (Brian's ask, 2026-08-15) — a starbase sharing a system with a hostile
// colony must never itself grant orbital superiority over it; that requires
// an actual attack fleet. The defender's own starbase still counts toward
// defPower, since it's genuinely defending, not attacking.
export function holdsOrbit(rules, state, e, starIdx, defenderIdx) { export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {
const defPower = state.fleets const defPower = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx) .filter((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx)
.reduce((t, f) => t + fleetPower(rules, state, f), 0); .reduce((t, f) => t + fleetPower(rules, state, f), 0);
const attPower = state.fleets const attPower = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === e) .filter((f) => f.starIdx === starIdx && f.empireIdx === e)
.reduce((t, f) => t + fleetPower(rules, state, f), 0); .reduce((t, f) => t + fleetPower(rules, state, f, { includeBase: false }), 0);
if (attPower <= 0) return false; if (attPower <= 0) return false;
return defPower <= 0 || attPower > defPower; return defPower <= 0 || attPower > defPower;
} }
@ -2115,9 +2149,12 @@ export function invade(rules, state, e, starIdx, orbit = null) {
const defPower = state.fleets const defPower = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === colony.empireIdx) .filter((f) => f.starIdx === starIdx && f.empireIdx === colony.empireIdx)
.reduce((t, f) => t + fleetPower(rules, state, f), 0); .reduce((t, f) => t + fleetPower(rules, state, f), 0);
// Excludes starbases (Brian's ask, 2026-08-15) — same reasoning as
// holdsOrbit: a starbase sharing this system must never itself buy the
// orbital superiority an invasion needs, only a real attack fleet can.
const attPower = state.fleets const attPower = state.fleets
.filter((f) => f.starIdx === starIdx && f.empireIdx === e) .filter((f) => f.starIdx === starIdx && f.empireIdx === e)
.reduce((t, f) => t + fleetPower(rules, state, f), 0); .reduce((t, f) => t + fleetPower(rules, state, f, { includeBase: false }), 0);
if (defPower > 0 && attPower <= defPower) return null; if (defPower > 0 && attPower <= defPower) return null;
const fleet = state.fleets.find((f) => f.starIdx === starIdx && f.empireIdx === e const fleet = state.fleets.find((f) => f.starIdx === starIdx && f.empireIdx === e

View File

@ -2438,6 +2438,156 @@ section('5e. AI siege dispatch throttle');
JSON.stringify({ starIdx: fresh.starIdx, toStar: fresh.toStar })); JSON.stringify({ starIdx: fresh.starIdx, toStar: fresh.toStar }));
} }
// ---------------------------------------------------------------------------
section('5f. Starbases cannot attack a hostile colony alone');
// ---------------------------------------------------------------------------
// Brian's ask, 2026-08-15: a starbase never leaves the system it was built
// in, so its mere presence at a star that also hosts a hostile colony must
// never be enough to attack that colony — bombard/invade/a real tactical
// battle all require an actual attack fleet (a warship-role ship) to also be
// present. Before this, invade()'s and holdsOrbit()'s orbital-superiority
// power sum counted a starbase the same as any warship, so a starbase plus
// an unescorted troop transport could invade straight past a real defending
// fleet; resolveCombats' colony-defense pass and pendingBattlesFor/
// prepareBattleAt would also happily let a lone starbase pick a fight with a
// neighbouring hostile colony's batteries.
{
const mk = (speciesIds) => {
const st = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'spiral', seed: 303, difficultyId: 'normal',
speciesIds, humanIndex: 0,
});
st.rules = RULES;
st.fleets = [];
return st;
};
// --- holdsOrbit: a lone starbase never grants orbital superiority.
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[1];
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 0, home, [{ hullId: 'starbase', mark: 1, count: 1 }]);
check('a lone starbase does not grant orbital superiority (holdsOrbit)',
!Logic.holdsOrbit(RULES, st, 0, home, 1));
Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 1 }]);
check('adding a real warship alongside the starbase grants orbital superiority',
Logic.holdsOrbit(RULES, st, 0, home, 1));
}
// --- invade(): a starbase inflating attPower must not let an unescorted
// transport slip past a real defending fleet.
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[1];
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 1, home, [{ hullId: 'destroyer', mark: 1, count: 3 }]);
Logic.addFleet(RULES, st, 0, home, [{ hullId: 'starbase', mark: 1, count: 1 }]);
Logic.addFleet(RULES, st, 0, home, [{ hullId: 'transport', mark: 1, count: 1 }]);
check('an unescorted transport backed only by a starbase cannot invade past a real defending fleet',
Logic.invade(RULES, st, 0, home) === null);
}
// --- resolveCombats: a lone starbase does not pick a fight with a
// neighbouring hostile colony's undefended batteries.
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[0];
Logic.foundColony(RULES, st, 1, home, 1, 50);
const kkrixColony = st.colonies.find((c) => c.starIdx === home && c.empireIdx === 1);
kkrixColony.defenseHp = 40;
Diplo.declareWar(RULES, st, 0, 1);
st.fleets = [];
Logic.addFleet(RULES, st, 0, home, [{ hullId: 'starbase', mark: 1, count: 1 }]);
const before = kkrixColony.defenseHp;
const results = Logic.resolveCombats(RULES, st, {});
check("resolveCombats does not fight a hostile colony's batteries on a lone starbase's behalf",
!results.some((r) => r.attackerIdx === 0 || r.defenderIdx === 0));
check("the hostile colony's defenseHp is untouched", kkrixColony.defenseHp === before);
}
// --- pendingBattlesFor / prepareBattleAt: a lone starbase never offers
// (or is allowed) a real battle against a neighbouring hostile colony.
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[0];
Logic.foundColony(RULES, st, 1, home, 1, 50);
const kkrixColony = st.colonies.find((c) => c.starIdx === home && c.empireIdx === 1);
kkrixColony.defenseHp = 40;
Diplo.declareWar(RULES, st, 0, 1);
st.fleets = [];
Logic.addFleet(RULES, st, 0, home, [{ hullId: 'starbase', mark: 1, count: 1 }]);
check('a lone starbase does not surface a pending battle against a neighbouring hostile colony',
!Logic.pendingBattlesFor(RULES, st, 0).some((p) => p.starIdx === home && p.other === 1));
check('prepareBattleAt refuses a lone starbase as attacker against an undefended-by-fleet hostile colony',
Logic.prepareBattleAt(RULES, st, home, 0, 1) === null);
Logic.addFleet(RULES, st, 0, home, [{ hullId: 'frigate', mark: 1, count: 1 }]);
check('adding a real warship alongside the starbase makes the battle findable again',
Logic.pendingBattlesFor(RULES, st, 0).some((p) => p.starIdx === home && p.other === 1));
check('prepareBattleAt now prepares a real battle with the human correctly cast as attacker',
!!Logic.prepareBattleAt(RULES, st, home, 0, 1));
}
}
// ---------------------------------------------------------------------------
section('5g. AI bombard/invade at a contested multi-colony star');
// ---------------------------------------------------------------------------
// Brian's ask, 2026-08-15: reported live — everyone at war with the human,
// the human's one remaining colony sits in a star system shared with other
// species' colonies, has zero defenses and no fleet of its own, and nobody
// ever bombards or invades it. Root cause: VegaAI.js's 2a/2b decision gates
// used the unsafe colonyAt(state, fleet.starIdx) — first-in-array, no
// relation to who this fleet is actually at war with — to decide whether
// there was even anything worth attacking at that star. At a star hosting
// several empires' colonies, colonyAt() could resolve to a colony the AI is
// NOT at war with, the atWar guard would then correctly (but pointlessly)
// refuse, and the AI would never even attempt bombard()/invade() against
// the real, at-war, undefended colony sharing that same star — even though
// bombard()/invade() themselves have used the safe targetColonyAt() (which
// finds the colony THIS empire is actually at war with) all along. Fixed by
// having 2a/2b's precondition use targetColonyAt() too, the same helper
// bombard()/invade()/invasionForecast() already trust.
{
const st = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'cluster', seed: 2718, difficultyId: 'normal',
speciesIds: ['human', 'kkrix', 'ssakar', 'rrashaa'], humanIndex: 0,
});
st.rules = RULES;
st.fleets = [];
const attackerIdx = 3; // rrashaa
const humanIdx = 0;
let starIdx = -1;
for (let i = 0; i < st.galaxy.stars.length; i += 1) {
if ((st.galaxy.stars[i].planets?.length ?? 0) >= 3 && !st.galaxy.homeIdx.includes(i)) { starIdx = i; break; }
}
check('a star with 3+ planets exists for the contested-system fixture', starIdx >= 0);
if (starIdx >= 0) {
// kkrix (and ssakar) founded FIRST, so the old colonyAt()'s first-match
// behaviour would have picked one of theirs, not the human's — same
// precondition-proving trick as the earlier "ally sharing a system"
// fixtures in section 5b.
Logic.foundColony(RULES, st, 1, starIdx, 0, 50); // kkrix
Logic.foundColony(RULES, st, 2, starIdx, 1, 50); // ssakar
const humanColony = Logic.foundColony(RULES, st, humanIdx, starIdx, 2, 50);
humanColony.defenseHp = 0;
humanColony.sliders = { ships: 0, defense: 0, industry: 0, ecology: 0, research: 1 };
// The attacker is at war with the human ONLY — kkrix/ssakar are neutral
// to it, so a wrongly-picked colony always fails the atWar guard.
Diplo.declareWar(RULES, st, attackerIdx, humanIdx);
Logic.addFleet(RULES, st, attackerIdx, starIdx, [{ hullId: 'cruiser', mark: 3, count: 2 }]);
const popBefore = humanColony.pop;
Logic.beginEmpireTurn(RULES, st, attackerIdx);
AI.runAITurn(RULES, st, attackerIdx);
const bombarded = Logic.bombardedThisTurn(st, attackerIdx, humanColony);
check("an idle warship fleet at a contested multi-colony star bombards the human's undefended colony it's actually at war with",
bombarded || humanColony.pop < popBefore,
`pop ${popBefore} -> ${humanColony.pop}, bombarded=${bombarded}`);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
section('6. Colony economy'); section('6. Colony economy');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------