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.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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue