diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index bdd5eea..55b6c11 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -1193,16 +1193,25 @@ export default class MasterOfVegaGame extends Phaser.Scene { ); this.runAudienceQueue(() => { this.playPlayerBattles(() => { - Logic.endEmpireTurn(this.rules, this.state, this.state.humanIndex, { skipMove: true }); + Logic.endEmpireTurn(this.rules, this.state, this.state.humanIndex, + { skipMove: true, deferBattlesFor: this.state.humanIndex }); this.runToHumanTurn(); }); }); } - // Fight the human's battles one at a time on the tactical screen. Each one is - // prepared by the engine, driven tick by tick by the view, and its outcome - // handed straight back — so a battle the player fights and one the AI - // auto-resolves go through exactly the same code. + // Fight every battle the human is currently in, one at a time on the + // tactical screen — whoever attacked whom. Each is prepared by the engine, + // driven tick by tick by the view, and its outcome handed straight back, + // so a battle the player fights here and an AI-vs-AI one auto-resolved by + // resolveCombats() still go through exactly the same prepareBattleAt/ + // applyBattleOutcome code, just with a human-chosen formation and a screen + // to watch. Re-derives Logic.pendingBattlesFor fresh every call (a no-op + // if nothing's pending), so it's safe to call from more than one place: + // onEndTurn() after the human's own move, AND runToHumanTurn()'s AI loop + // after every AI empire's move, since endEmpireTurn is told to defer any + // battle touching the human rather than auto-resolve it (Brian's ask, + // 2026-08-14) — this is what actually shows it. playPlayerBattles(done) { const me = this.state.humanIndex; const pending = Logic.pendingBattlesFor(this.rules, this.state, me); @@ -1340,8 +1349,16 @@ export default class MasterOfVegaGame extends Phaser.Scene { const e = this.state.current; Logic.beginEmpireTurn(this.rules, this.state, e); runAITurn(this.rules, this.state, e); - Logic.endEmpireTurn(this.rules, this.state, e); - this.time.delayedCall(60, step); + Logic.endEmpireTurn(this.rules, this.state, e, { deferBattlesFor: this.state.humanIndex }); + // This AI empire's own move can bring it into contact with the human — + // same interactive battle + formation picker the human's own movement + // already gets (Brian's ask, 2026-08-14). playPlayerBattles re-derives + // Logic.pendingBattlesFor fresh every call and returns immediately when + // nothing is pending, so this costs nothing on the (common) turn where + // no battle happened, and steps the human straight into the tactical + // view the instant one did — before the loop moves on to the next + // empire, so fights never stack up unresolved across turns. + this.playPlayerBattles(() => this.time.delayedCall(60, step)); }; step(); } diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 476de4b..3294815 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -1398,7 +1398,19 @@ export function atWar(state, a, b) { // Resolve every star where hostile forces now share orbit. Called once per // empire turn after movement, so a fleet that arrives is engaged immediately. -export function resolveCombats(rules, state) { +// +// `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) { @@ -1415,6 +1427,7 @@ export function resolveCombats(rules, state) { 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); } @@ -1424,6 +1437,7 @@ export function resolveCombats(rules, state) { 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); @@ -1539,23 +1553,37 @@ export function applyBattleOutcome(rules, state, prepared, 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. +// 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(); - for (const f of state.fleets) { - if (f.starIdx < 0 || f.empireIdx !== e) continue; - if (seen.has(f.starIdx)) continue; - seen.add(f.starIdx); + const addStar = (starIdx) => { + if (seen.has(starIdx)) return; + seen.add(starIdx); const foes = new Set(); for (const g of state.fleets) { - if (g.starIdx === f.starIdx && g.empireIdx !== e && atWar(state, e, g.empireIdx)) foes.add(g.empireIdx); + if (g.starIdx === starIdx && g.empireIdx !== e && atWar(state, e, g.empireIdx)) foes.add(g.empireIdx); } - const colony = colonyAt(state, f.starIdx); + 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: f.starIdx, other }); + 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; } @@ -2061,10 +2089,17 @@ function checkVictory(rules, state) { export function moveFleetsFor(rules, state, e) { moveFleets(rules, state, e); } -export function endEmpireTurn(rules, state, e, { skipMove = false } = {}) { +// `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); + resolveCombats(rules, state, { excludeEmpire: deferBattlesFor }); for (const emp of state.empires) checkElimination(rules, state, emp.idx); } diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 03b7428..ac51978 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -1695,6 +1695,111 @@ section('5. Combat'); singularityRate > 0.52 && singularityRate < 0.8, `attacker (singularity) won ${(singularityRate * 100).toFixed(1)}%`); } +// --------------------------------------------------------------------------- +section('5b. Deferred battles (human-visible combat)'); +// --------------------------------------------------------------------------- +// Brian's ask, 2026-08-14: the human should get the interactive tactical +// view for EVERY battle their empire is in, not just ones they started. +// endEmpireTurn's deferBattlesFor option lets resolveCombats skip an +// empire's fights instead of auto-resolving them; pendingBattlesFor +// (extended to also catch an undefended colony under siege) is what finds +// them again afterward so MasterOfVegaGame.js can hand them to the player. +{ + 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; + }; + const shipCount = (st, e) => Logic.empireFleets(st, e) + .reduce((t, f) => t + f.ships.reduce((n, s) => n + s.count, 0), 0); + + // --- resolveCombats' excludeEmpire: skips the excluded empire's pairs, + // still fights everyone else present at the same star. + { + const st = mk(['human', 'kkrix', 'rrashaa']); + const home = st.galaxy.homeIdx[0]; + Diplo.declareWar(RULES, st, 0, 1); + Diplo.declareWar(RULES, st, 2, 1); + Logic.addFleet(RULES, st, 0, home, [{ hullId: 'cruiser', mark: 1, count: 3 }]); + Logic.addFleet(RULES, st, 1, home, [{ hullId: 'frigate', mark: 1, count: 1 }]); + Logic.addFleet(RULES, st, 2, home, [{ hullId: 'cruiser', mark: 1, count: 3 }]); + const results = Logic.resolveCombats(RULES, st, { excludeEmpire: 0 }); + check('excludeEmpire leaves the excluded empire fully untouched', shipCount(st, 0) === 3); + // A one-frigate-vs-three-cruisers mismatch can retreat with zero losses + // rather than dying outright, so "a fight happened" is checked via the + // resolved-battle record itself, not an assumed ship-count drop. + check('excludeEmpire still lets an un-excluded pair fight at the same star', + results.some((r) => r.attackerIdx === 1 && r.defenderIdx === 2), JSON.stringify(results)); + } + + // --- pendingBattlesFor now also finds an undefended colony under siege + // (previously only found stars where e had a FLEET of its own present). + { + const st = mk(['human', 'kkrix']); + const otherStar = st.galaxy.stars.findIndex((s, i) => !st.galaxy.homeIdx.includes(i) + && Logic.canColonize(RULES, st, 0, i, 0)); + check('a spare colonizable star exists for the siege test', otherStar >= 0); + if (otherStar >= 0) { + Logic.foundColony(RULES, st, 0, otherStar, 0, 200); + Diplo.declareWar(RULES, st, 0, 1); + Logic.addFleet(RULES, st, 1, otherStar, [{ hullId: 'cruiser', mark: 1, count: 2 }]); + const pending = Logic.pendingBattlesFor(RULES, st, 0); + check('an undefended colony under siege now shows up as a pending battle', + pending.some((p) => p.starIdx === otherStar && p.other === 1), JSON.stringify(pending)); + } + } + + // --- endEmpireTurn's deferBattlesFor: the human's fight is left completely + // untouched by an AI empire's own endEmpireTurn call, but still findable + // via pendingBattlesFor afterward — the exact sequence + // MasterOfVegaGame.js's runToHumanTurn now relies on. + { + const st = mk(['human', 'kkrix']); + const home = st.galaxy.homeIdx[0]; + Diplo.declareWar(RULES, st, 0, 1); + Logic.addFleet(RULES, st, 0, home, [{ hullId: 'cruiser', mark: 1, count: 3 }]); + Logic.addFleet(RULES, st, 1, home, [{ hullId: 'frigate', mark: 1, count: 1 }]); + Logic.endEmpireTurn(RULES, st, 1, { deferBattlesFor: 0 }); + check("deferBattlesFor leaves the human's ships untouched by the AI's own endEmpireTurn", + shipCount(st, 0) === 3); + check("deferBattlesFor leaves the attacker's ships untouched too — nobody fought at all", + shipCount(st, 1) === 1); + let pending = Logic.pendingBattlesFor(RULES, st, 0); + check('the deferred battle is still findable via pendingBattlesFor afterward', + pending.some((p) => p.starIdx === home && p.other === 1), JSON.stringify(pending)); + + // Actually fight it through prepareBattleAt/applyBattleOutcome — the same + // engine calls playPlayerBattles makes — to confirm a deferred fight + // resolves normally once driven interactively, formation choice and all. + const prepared = Logic.prepareBattleAt(RULES, st, home, 0, 1, { humanFormation: 'power_pressure' }); + check('a deferred battle still prepares normally', !!prepared); + if (prepared) { + Logic.applyBattleOutcome(RULES, st, prepared, CombatV2.runBattle(prepared.battle)); + pending = Logic.pendingBattlesFor(RULES, st, 0); + check('applying the outcome resolves it — no longer pending', + !pending.some((p) => p.starIdx === home && p.other === 1), JSON.stringify(pending)); + } + } + + // --- regression: omitting deferBattlesFor (the default) behaves exactly + // as before — nothing opts in by accident, so every existing headless + // caller (tests, the AI self-play soak) is unaffected. + { + const st = mk(['human', 'kkrix']); + const home = st.galaxy.homeIdx[0]; + Diplo.declareWar(RULES, st, 0, 1); + Logic.addFleet(RULES, st, 0, home, [{ hullId: 'cruiser', mark: 1, count: 3 }]); + Logic.addFleet(RULES, st, 1, home, [{ hullId: 'frigate', mark: 1, count: 1 }]); + Logic.endEmpireTurn(RULES, st, 1); + check('without deferBattlesFor, a battle involving empire 0 still auto-resolves as before', + !Logic.pendingBattlesFor(RULES, st, 0).some((p) => p.starIdx === home && p.other === 1)); + } +} + // --------------------------------------------------------------------------- section('6. Colony economy'); // ---------------------------------------------------------------------------