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:
parent
9e463164d9
commit
46a2979555
|
|
@ -10,7 +10,7 @@
|
|||
// from the difficulty multipliers in the rules file.
|
||||
|
||||
import {
|
||||
empireColonies, empireFleets, colonyAt, reachableStars, canColonize, colonize, invade,
|
||||
empireColonies, empireFleets, targetColonyAt, reachableStars, canColonize, colonize, invade,
|
||||
sendFleet, canSendFleet, enqueue, setSlider, setResearchAlloc, colonyProduction,
|
||||
colonyFactoryCap, effectiveFactories, colonyDefenseCap, empireDesign, empireComponents,
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
// Bomb only when we cannot take the world intact. A captured colony is
|
||||
// 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.
|
||||
// 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) {
|
||||
const colony = colonyAt(state, fleet.starIdx);
|
||||
const colony = targetColonyAt(state, e, fleet.starIdx, null);
|
||||
if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx)) {
|
||||
// Only land when the marines can actually carry the world. A failed
|
||||
// landing costs the whole transport wave for nothing, so waiting for a
|
||||
|
|
|
|||
|
|
@ -483,7 +483,15 @@ export function fleetSpeed(rules, state, fleet) {
|
|||
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 spec = rules.species[emp.speciesId];
|
||||
const skills = fleetLeaderSkills(rules, state, fleet);
|
||||
|
|
@ -491,7 +499,7 @@ export function fleetPower(rules, state, fleet) {
|
|||
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;
|
||||
if (d.role !== 'warship' && !(includeBase && d.role === 'base')) continue;
|
||||
p += s.count * (d.hp + d.damage * 4);
|
||||
}
|
||||
return Math.round(p);
|
||||
|
|
@ -1505,7 +1513,12 @@ export function resolveCombats(rules, state, { excludeEmpire = null } = {}) {
|
|||
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) {
|
||||
for (const a of empires) {
|
||||
if (a === defenderIdx) continue;
|
||||
|
|
@ -1513,6 +1526,8 @@ export function resolveCombats(rules, state, { excludeEmpire = null } = {}) {
|
|||
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;
|
||||
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);
|
||||
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;
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
const colony = coloniesAt(state, starIdx)
|
||||
.find((c) => c.empireIdx !== e && atWar(state, e, c.empireIdx) && c.defenseHp > 0);
|
||||
// Gated on e having a genuine warship-role fleet of its own here — a
|
||||
// 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);
|
||||
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
|
||||
// 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) {
|
||||
export 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.
|
||||
// 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) {
|
||||
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);
|
||||
.reduce((t, f) => t + fleetPower(rules, state, f, { includeBase: false }), 0);
|
||||
if (attPower <= 0) return false;
|
||||
return defPower <= 0 || attPower > defPower;
|
||||
}
|
||||
|
|
@ -2115,9 +2149,12 @@ export function invade(rules, state, e, starIdx, orbit = null) {
|
|||
const defPower = state.fleets
|
||||
.filter((f) => f.starIdx === starIdx && f.empireIdx === colony.empireIdx)
|
||||
.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
|
||||
.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;
|
||||
|
||||
const fleet = state.fleets.find((f) => f.starIdx === starIdx && f.empireIdx === e
|
||||
|
|
|
|||
|
|
@ -2438,6 +2438,156 @@ section('5e. AI siege dispatch throttle');
|
|||
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');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue