Changes to battle system so the player can view battles initiated by the other side.
This commit is contained in:
parent
52b9f2b29f
commit
2b1fe96bf6
|
|
@ -1193,16 +1193,25 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
);
|
);
|
||||||
this.runAudienceQueue(() => {
|
this.runAudienceQueue(() => {
|
||||||
this.playPlayerBattles(() => {
|
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();
|
this.runToHumanTurn();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fight the human's battles one at a time on the tactical screen. Each one is
|
// Fight every battle the human is currently in, one at a time on the
|
||||||
// prepared by the engine, driven tick by tick by the view, and its outcome
|
// tactical screen — whoever attacked whom. Each is prepared by the engine,
|
||||||
// handed straight back — so a battle the player fights and one the AI
|
// driven tick by tick by the view, and its outcome handed straight back,
|
||||||
// auto-resolves go through exactly the same code.
|
// 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) {
|
playPlayerBattles(done) {
|
||||||
const me = this.state.humanIndex;
|
const me = this.state.humanIndex;
|
||||||
const pending = Logic.pendingBattlesFor(this.rules, this.state, me);
|
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;
|
const e = this.state.current;
|
||||||
Logic.beginEmpireTurn(this.rules, this.state, e);
|
Logic.beginEmpireTurn(this.rules, this.state, e);
|
||||||
runAITurn(this.rules, this.state, e);
|
runAITurn(this.rules, this.state, e);
|
||||||
Logic.endEmpireTurn(this.rules, this.state, e);
|
Logic.endEmpireTurn(this.rules, this.state, e, { deferBattlesFor: this.state.humanIndex });
|
||||||
this.time.delayedCall(60, step);
|
// 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();
|
step();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1398,7 +1398,19 @@ export function atWar(state, a, b) {
|
||||||
|
|
||||||
// Resolve every star where hostile forces now share orbit. Called once per
|
// Resolve every star where hostile forces now share orbit. Called once per
|
||||||
// empire turn after movement, so a fleet that arrives is engaged immediately.
|
// 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 results = [];
|
||||||
const byStar = new Map();
|
const byStar = new Map();
|
||||||
for (const f of state.fleets) {
|
for (const f of state.fleets) {
|
||||||
|
|
@ -1415,6 +1427,7 @@ export function resolveCombats(rules, state) {
|
||||||
for (const b of empires) {
|
for (const b of empires) {
|
||||||
if (a >= b) continue;
|
if (a >= b) continue;
|
||||||
if (!atWar(state, a, b)) continue;
|
if (!atWar(state, a, b)) continue;
|
||||||
|
if (a === excludeEmpire || b === excludeEmpire) continue;
|
||||||
const result = fightAt(rules, state, starIdx, a, b);
|
const result = fightAt(rules, state, starIdx, a, b);
|
||||||
if (result) results.push(result);
|
if (result) results.push(result);
|
||||||
}
|
}
|
||||||
|
|
@ -1424,6 +1437,7 @@ export function resolveCombats(rules, state) {
|
||||||
for (const a of empires) {
|
for (const a of empires) {
|
||||||
if (a === defenderIdx) continue;
|
if (a === defenderIdx) continue;
|
||||||
if (!atWar(state, 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 (state.fleets.some((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx && f.ships.length)) continue;
|
||||||
if (colony.defenseHp <= 0) continue;
|
if (colony.defenseHp <= 0) continue;
|
||||||
const result = fightAt(rules, state, starIdx, a, defenderIdx);
|
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
|
// 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) {
|
export function pendingBattlesFor(rules, state, e) {
|
||||||
const out = [];
|
const out = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const f of state.fleets) {
|
const addStar = (starIdx) => {
|
||||||
if (f.starIdx < 0 || f.empireIdx !== e) continue;
|
if (seen.has(starIdx)) return;
|
||||||
if (seen.has(f.starIdx)) continue;
|
seen.add(starIdx);
|
||||||
seen.add(f.starIdx);
|
|
||||||
const foes = new Set();
|
const foes = new Set();
|
||||||
for (const g of state.fleets) {
|
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) {
|
if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx) && colony.defenseHp > 0) {
|
||||||
foes.add(colony.empireIdx);
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -2061,10 +2089,17 @@ function checkVictory(rules, state) {
|
||||||
|
|
||||||
export function moveFleetsFor(rules, state, e) { moveFleets(rules, state, e); }
|
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 (state.empires[e].alive) {
|
||||||
if (!skipMove) moveFleets(rules, state, e);
|
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);
|
for (const emp of state.empires) checkElimination(rules, state, emp.idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1695,6 +1695,111 @@ section('5. Combat');
|
||||||
singularityRate > 0.52 && singularityRate < 0.8, `attacker (singularity) won ${(singularityRate * 100).toFixed(1)}%`);
|
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');
|
section('6. Colony economy');
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue