From 46a29795553f75c619d2dc7b1ac237e8a4eeec60 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 15 Aug 2026 10:54:16 -0600 Subject: [PATCH] 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). --- src/games/mastervega/VegaAI.js | 18 +++- src/games/mastervega/VegaLogic.js | 57 ++++++++++-- tools/verifyMasterOfVega.js | 150 ++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 13 deletions(-) diff --git a/src/games/mastervega/VegaAI.js b/src/games/mastervega/VegaAI.js index 8184065..377e956 100644 --- a/src/games/mastervega/VegaAI.js +++ b/src/games/mastervega/VegaAI.js @@ -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 diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 842f500..ddb9345 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -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 diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index d0ceb77..da85bf1 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -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'); // ---------------------------------------------------------------------------