Changes to battle system so the player can view battles initiated by the other side.

This commit is contained in:
Brian Fertig 2026-08-14 12:17:14 -06:00
parent 52b9f2b29f
commit 2b1fe96bf6
3 changed files with 175 additions and 18 deletions

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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');
// ---------------------------------------------------------------------------