fix(mastervega): balance planetary defense and improve AI attack logic

- Replace flat planetDefenseBase (20) with proportional planetDefenseScale
  (0.06), so colony defense damage scales purely with current defenseHp.
  A near-depleted colony now barely scratches attackers, while a fully
  tech'd one still tops out ~110 damage — fixing "attacked with the
  strength of a much-better-defended planet" (Brian, 2026-08-14).

- Add AI war memory: track consecutive losses per enemy and escalate
  attackMultiplier up to 3x so repeated losing attacks stop dribbling
  small fleets. A win resets the streak. Prevents permanent phoney wars
  where both sides build to equal strength and nothing moves.

- Show "Under Attack" notice (one-button, no formation picker) when AI
  attacks the player, since formation choice is meaningless for defense.

- Add planetary beam fire SFX for combat view.

- Add event `seq` cursor for war memory to survive event list trimming.

- Full test coverage for planet damage formula and AI escalation behavior.
This commit is contained in:
Brian Fertig 2026-08-14 14:24:22 -06:00
parent 2b1fe96bf6
commit 3bad628968
12 changed files with 323 additions and 24 deletions

Binary file not shown.

View File

@ -501,7 +501,7 @@
"missileRange": 6,
"retreatAfterRound": 1,
"disengageRound": 25,
"planetDefenseBase": 20,
"planetDefenseScale": 0.06,
"bombardPopKill": 0.22,
"groundOddsScale": 0.01,
"cloakEvasion": 0.02,

View File

@ -213,6 +213,10 @@ export const MANIFEST = {
{ type: 'audio', key: 'sfx-vega-missle-launch-67', path: 'assets/fx/vega/vega-missle-launch-67.mp3' },
{ type: 'audio', key: 'sfx-vega-missle-hit-12345', path: 'assets/fx/vega/vega-missle-hit-12345.mp3' },
{ type: 'audio', key: 'sfx-vega-missle-hit-67', path: 'assets/fx/vega/vega-missle-hit-67.mp3' },
// Planetary defence battery fire cue — a colony has no weapon mount to
// Mark-band like a ship (VegaCombatV2.js's planet entity is a flat
// damage scalar), so this is the one cue for every planet fire event.
{ type: 'audio', key: 'sfx-vega-planet-beam', path: 'assets/fx/vega/vega-planet-beam.mp3' },
// Per-warship-class destruction cues. Filename has a typo ("friggate")
// that the cache key does not repeat — see Sounds.js.
{ type: 'audio', key: 'sfx-vega-destroy-frigate', path: 'assets/fx/vega/vega-destroy-friggate.mp3' },

View File

@ -30,6 +30,7 @@ import { openCombatViewV2 } from './VegaCombatViewV2.js';
import {
FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
openSaveScreen, openLoadScreen, showVictoryOverlay, openFormationPicker,
openAttackNotice,
} from './VegaScreens.js';
import { openColoniesScreen } from './VegaColoniesScreen.js';
import { openResearchScreen } from './VegaResearchScreen.js';
@ -1205,14 +1206,16 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// 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
// applyBattleOutcome code, just with a screen to watch (and, when the
// human started it, a formation choice). 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
// (`attacked` left false — the human chose this fight, so they choose the
// formation), AND runToHumanTurn()'s AI loop after every AI empire's move
// (`attacked: true` — see below), 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, { attacked = false } = {}) {
const me = this.state.humanIndex;
const pending = Logic.pendingBattlesFor(this.rules, this.state, me);
if (!pending.length) { done(); return; }
@ -1222,11 +1225,8 @@ export default class MasterOfVegaGame extends Phaser.Scene {
const { starIdx, other } = pending[i];
this.map?.panToStar(starIdx, 260);
this.modalOpen = true;
// Forced (closable: false) — there's no sensible "cancel" once fleets
// are already committed to this fight. Only the human's own side is
// ever asked; the AI opponent always picks silently (Logic
// .prepareBattleAt's own comment).
openFormationPicker(this, (humanFormation) => {
const startBattle = (humanFormation) => {
const prepared = Logic.prepareBattleAt(this.rules, this.state, starIdx, me, other, { humanFormation });
if (!prepared) { this.modalOpen = false; next(i + 1); return; }
this.music?.setCategory('combat');
@ -1242,7 +1242,28 @@ export default class MasterOfVegaGame extends Phaser.Scene {
next(i + 1);
},
});
}, { closable: false });
};
if (attacked) {
// The formation picker is skipped here (Brian's ask, 2026-08-14):
// VegaFormations.js's own header comment says a chosen strategy
// doesn't change how the battle plays out yet, so asking the player
// to pick one before a fight they didn't start is a pointless extra
// click, not a real decision. Just say who's attacking and where,
// then start the same prepared battle with both sides silently
// randomised — prepareBattleAt's default when humanFormation is null,
// same as any AI-vs-AI fight already gets.
openAttackNotice(this, {
attackerName: this.state.empires[other].name,
starName: this.state.galaxy.stars[starIdx].name,
}, () => startBattle(null));
} else {
// Forced (closable: false) — there's no sensible "cancel" once fleets
// are already committed to this fight. Only the human's own side is
// ever asked; the AI opponent always picks silently (Logic
// .prepareBattleAt's own comment).
openFormationPicker(this, startBattle, { closable: false });
}
};
next(0);
}
@ -1351,14 +1372,17 @@ export default class MasterOfVegaGame extends Phaser.Scene {
runAITurn(this.rules, this.state, e);
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
// same interactive tactical view the human's own movement already gets
// (Brian's ask, 2026-08-14), `attacked: true` so it's a one-button
// "you're under attack" notice instead of the formation picker (that
// choice is the human's to make when THEY start a fight, not something
// to ask for on defense). 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));
this.playPlayerBattles(() => this.time.delayedCall(60, step), { attacked: true });
};
step();
}

View File

@ -356,11 +356,16 @@ function manageFleets(rules, state, e, strat) {
const guard = state.fleets
.filter((f) => f.starIdx === c.starIdx && f.empireIdx === enemy.idx)
.reduce((t, f) => t + fleetPower(rules, state, f), 0) + c.defenseHp * 0.4;
// Attack at rough parity. Demanding a clear local edge produced a
// permanent phoney war: both sides built to the same strength, each
// decided it was not quite winning enough, and nothing ever moved for
// seven hundred turns.
if (power < guard) continue;
// Attack at rough parity — except against an enemy this empire has
// just lost to repeatedly, where attackMultiplier raises the bar
// instead of sending the next fleet in at the same losing size
// (Brian's ask, 2026-08-14). Demanding a clear local edge as the
// DEFAULT case produced a permanent phoney war: both sides built to
// the same strength, each decided it was not quite winning enough,
// and nothing ever moved for seven hundred turns — so the escalation
// only ever kicks in reactively, after real losses, and resets the
// moment an attack actually works.
if (power < guard * attackMultiplier(emp, enemy.idx)) continue;
// Concentrate on whoever is closest to collapse. Spreading pressure
// evenly across every rival keeps them all alive indefinitely; a war
// is only won by finishing somebody off.
@ -420,12 +425,62 @@ function manageFleets(rules, state, e, strat) {
}
}
// --------------------------------------------------------------------------
// War memory — a per-enemy consecutive-loss streak, read once per turn from
// the 'combat' events applyBattleOutcome (VegaLogic.js) already pushes.
// Works identically whether the battle auto-resolved (AI-vs-AI) or was
// fought out interactively by the human (MasterOfVegaGame.js's
// playPlayerBattles) — same event, same shape, either way. A win against an
// enemy resets that enemy's streak to 0; a loss increments it. Read by
// manageFleets' attack decision below to demand progressively more force
// before trying that enemy again, instead of feeding it the same losing
// fleet size on repeat (Brian's ask, 2026-08-14).
//
// Cursored on each event's `seq` (VegaLogic.js's pushEvent), not on
// state.turn: resolveCombats runs globally, from inside WHICHEVER empire's
// endEmpireTurn happens to execute next, so a battle this empire is
// "attacker" in can land in state.events before this empire's own turn is
// even processed this calendar cycle — a turn-number cursor could line up
// exactly with such an event's own turn and skip it. A `seq` cursor has no
// such boundary case: anything with a higher seq than last time is new,
// full stop, and it stays correct even across the trim-to-300 pushEvent
// does once state.events passes 600 (an index or count-based cursor would
// not survive that).
function updateWarMemory(state, e) {
const emp = state.empires[e];
const from = emp.warMemorySeq;
let maxSeq = from;
for (const ev of state.events) {
if (ev.seq > maxSeq) maxSeq = ev.seq;
if (ev.type !== 'combat' || ev.attacker !== e || ev.seq <= from) continue;
const mem = emp.warMemory[ev.defender] ?? (emp.warMemory[ev.defender] = { losses: 0 });
mem.losses = ev.winner === 'attacker' ? 0 : mem.losses + 1;
}
emp.warMemorySeq = maxSeq;
}
// Multiplier on a target's `guard` (manageFleets' local-attack gate) once
// this empire has lost to it a couple of times running: unchanged at 1x for
// the first loss (still just bad luck), then climbs — capped at 3x so a
// structurally weaker empire cannot escalate itself into permanent
// passivity, which is exactly the "phoney war" failure mode the plain
// parity threshold below was chosen to avoid in the first place (see that
// comment). A win resets the streak (updateWarMemory above), so this only
// ever demands more after REPEATED failure, and eases back off the moment
// an attack actually works.
function attackMultiplier(emp, enemyIdx) {
const losses = emp.warMemory[enemyIdx]?.losses ?? 0;
if (losses <= 1) return 1;
return Math.min(3, 1 + (losses - 1) * 0.5);
}
// --------------------------------------------------------------------------
export function runAITurn(rules, state, e) {
const emp = state.empires[e];
if (!emp.alive) return;
updateWarMemory(state, e);
const strat = computeStrategy(rules, state, e);
manageResearch(rules, state, e, strat);
for (const colony of strat.colonies) manageColony(rules, state, e, colony, strat);

View File

@ -93,7 +93,9 @@ export function createBattle(rules, opts) {
x: C.gridCols - 1,
speed: 0,
immobile: true,
damage: C.planetDefenseBase + colony.defenseHp * 0.05,
// Pure proportional — see VegaCombatV2.js's identical formula for why
// the old flat +20-base version was overpowered at low defenseHp.
damage: colony.defenseHp * C.planetDefenseScale,
salvoesLeft: new Map(),
retreated: false,
};

View File

@ -605,7 +605,18 @@ export function createBattle(rules, opts) {
angularAccel: 0,
avoidRadius: C2.separationUnit * 3,
immobile: true,
damage: rules.combat.planetDefenseBase + colony.defenseHp * 0.05,
// Pure proportional — no flat floor. The old formula (a flat +20 base
// plus only +0.05/HP) meant a colony at 1/100 defenseHp and one at a
// fully-teched-out 1800+/1800 both hit for roughly the same ~20-25
// damage, comparable to a tier 5-6 weapon, regardless of how depleted
// its defences actually were — reported as "attacked with the
// strength of a much-better-defended planet" (Brian, 2026-08-14).
// Scaling purely off current HP means a near-dead colony now barely
// scratches anything, a fresh untechnologied one (100 cap) hits at an
// early-weapon-tier ~6, and a maxed-out one (~1848 cap, every
// planetaryShield/defense-building stacked) still tops out around 110
// — same ceiling the old formula had, just actually earned.
damage: colony.defenseHp * rules.combat.planetDefenseScale,
salvoesLeft: new Map(),
retreated: false,
target: null,

View File

@ -431,7 +431,17 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
scene.time.delayedCall(hitDelay, () => {
if (to.container.active) fx.hit(to.container.x, to.container.y, missile ? 0xffb060 : 0xffd28a);
});
if (ev.weaponKind === 'beam' || missile) {
// A planet has no weapon mount to band by Mark (ev.weaponKind is
// null for its fire events — VegaCombatV2.js's queueDamage falls
// back to a bare damage scalar), so it gets its own fixed cue
// instead of falling through weaponSfxKey's beam/missile dispatch.
if (from.ship.isPlanet) {
const now = scene.time.now;
if (now >= nextSoundAt) {
nextSoundAt = now + SOUND_MIN_GAP_MS;
playSound(scene, SFX.VEGA_PLANET_BEAM);
}
} else if (ev.weaponKind === 'beam' || missile) {
const now = scene.time.now;
if (now >= nextSoundAt) {
nextSoundAt = now + SOUND_MIN_GAP_MS;

View File

@ -43,7 +43,13 @@ export function randInt(state, n) { return Math.floor(rand(state) * n); }
export function attachRules(state, rules) { state.rules = rules; return state; }
// `seq` is a permanent, ever-increasing id independent of the event's
// position in `state.events` — VegaAI.js's updateWarMemory (per-empire
// combat-outcome tracking) needs a cursor that survives the trim below
// unscathed; an array-index or count-based cursor would silently go stale
// (or worse, skip real events) the moment a trim ran between two reads.
const pushEvent = (state, ev) => {
ev.seq = state.nextEventSeq += 1;
state.events.push(ev);
if (state.events.length > 600) state.events = state.events.slice(-300);
};
@ -515,6 +521,7 @@ export function createGame(rules, opts) {
fleets: [],
nextColonyId: 0,
nextFleetId: 0,
nextEventSeq: 0,
events: [],
council: {
nextTurn: rules.council.firstTurn, lastResult: null, history: [], pendingSession: false,
@ -561,6 +568,16 @@ export function createGame(rules, opts) {
// button could be clicked any number of times in one turn.
lastBombardTurn: {},
fleetIntrusions: {},
// enemyIdx -> { losses }, a consecutive-attack-loss streak read by
// VegaAI.js's manageFleets to demand progressively more force before
// trying that enemy again rather than attacking at the same losing
// fleet size on repeat. warMemorySeq is the highest event `seq`
// (pushEvent, above) already scanned for 'combat' events (VegaAI.js's
// updateWarMemory) — plain fields, not underscore-prefixed caches,
// since unlike _comps/_range they aren't recomputable from current
// tech and have to survive save/load.
warMemory: {},
warMemorySeq: 0,
explored: {},
spyPoints: 0,
// otherIdx -> 'steal' | 'sabotage', missing entry means 'steal' (see
@ -2882,6 +2899,14 @@ export function serialize(state) {
export function deserialize(json) {
const state = JSON.parse(json);
if (state.version !== 1) return null;
// A save from before pushEvent stamped events with `seq` has none on any
// existing event — nextEventSeq starts at 0 either way (pushEvent uses
// pre-increment), so the first NEW event in a reloaded old save gets seq 1,
// safely past every un-seq'd historical event's implicit `undefined`
// (always fails `ev.seq > cursor`, so old events are just never
// war-memory-countable, same cold-start tradeoff as every other
// back-filled field here).
state.nextEventSeq ??= 0;
for (const emp of state.empires) {
emp._comps = null; emp._compsAt = -1; emp._range = null; emp._rangeAt = -1;
emp._designs = null; emp._designsAt = -1;
@ -2896,6 +2921,8 @@ export function deserialize(json) {
emp.lastBombardTurn ??= {};
emp.fleetIntrusions ??= {};
emp.espionageMission ??= {};
emp.warMemory ??= {};
emp.warMemorySeq ??= 0;
}
// Same convention for a save from before GNN existed — but a bare
// `gnn ??= {history:[]}` is not enough here: state.events is never cleared

View File

@ -225,6 +225,32 @@ export function openFormationPicker(scene, onPick, { closable = true } = {}) {
return shell;
}
/**
* A forced one-button notice for a battle the player didn't start an AI
* empire attacking one of their fleets or colonies (MasterOfVegaGame.js's
* playPlayerBattles, `attacked: true`). Skips the formation picker entirely:
* VegaFormations.js's own header comment says the choice doesn't change how
* a battle actually plays out yet, so asking the player to pick a doctrine
* for a fight they didn't choose to start is a pointless extra click, not a
* real decision (Brian's ask, 2026-08-14). Forced (closable: false) for the
* same reason the formation picker is nothing to cancel, the fleets are
* already in contact.
*/
export function openAttackNotice(scene, { attackerName, starName }, onOk) {
const shell = modalShell(scene, 'Under Attack', null, { width: 780, height: 280, closable: false });
const { body } = shell;
shell.add(scene.add.text(body.x + body.w / 2, body.y + body.h / 2 - 20,
`${attackerName} attacks you at ${starName}.`, {
fontFamily: FONT, fontSize: '22px', color: '#e8f4ff', align: 'center', wordWrap: { width: body.w },
}).setOrigin(0.5));
const btn = new Button(scene, body.x + body.w / 2, body.y + body.h - 34, 'OK', uiClick(scene, () => {
shell.destroy();
onOk();
}), { width: 160, height: 48, fontSize: 18 });
shell.add(btn);
return shell;
}
// --------------------------------------------------------------------------
// Research has its own file, VegaResearchScreen.js — it grew past this
// module's shared factory-function pattern the same way Audience and the

View File

@ -127,6 +127,11 @@ export const SFX = {
VEGA_MISSILE_LAUNCH_67: 'sfx-vega-missle-launch-67',
VEGA_MISSILE_HIT_12345: 'sfx-vega-missle-hit-12345',
VEGA_MISSILE_HIT_67: 'sfx-vega-missle-hit-67',
// Planetary defence battery fire — VegaCombatViewV2.js's handleEvents
// plays this instead of a weaponSfxKey() band whenever the shooter is the
// planet (ev.weaponKind is null for a planet's fire events, since it has
// no weapon mount to band by Mark).
VEGA_PLANET_BEAM: 'sfx-vega-planet-beam',
// One destroy cue per warship class — see VegaCombatView.js's/
// VegaCombatViewV2.js's destroySfxKey(). Only the four combatant hulls
// (frigate/destroyer/cruiser/battleship) have a clip; other hulls play no

View File

@ -1662,6 +1662,39 @@ section('5. Combat');
{ defenseHp: 600, shieldBonus: 5 }), 60);
check('planetary defences make a difference', defended <= undefended, `${defended} vs ${undefended}`);
// Planet damage is pure proportional to current defenseHp — no flat floor
// (Brian's ask, 2026-08-14: the old formula's large flat +20 base meant a
// colony at 1/100 defenseHp fought back nearly as hard as one at its cap,
// reported as "attacked with the strength of a much-better-defended
// planet"). Checked numerically against createBattle's own planet entity
// rather than just a win-rate proxy, since that is the exact value this
// was miscalibrated on.
{
const bLow = Combat.createBattle(RULES, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmp('human', 5), ships: [{ hullId: 'cruiser', count: 1 }] },
defender: { empireIdx: 1, name: 'd', empire: mkEmp('human', 5), ships: [] },
colony: { defenseHp: 5, shieldBonus: 0 }, rnd: mulberry32(1),
});
const bHigh = Combat.createBattle(RULES, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmp('human', 5), ships: [{ hullId: 'cruiser', count: 1 }] },
defender: { empireIdx: 1, name: 'd', empire: mkEmp('human', 5), ships: [] },
colony: { defenseHp: 600, shieldBonus: 0 }, rnd: mulberry32(1),
});
check('planet damage matches defenseHp * planetDefenseScale exactly',
Math.abs(bLow.planet.damage - 5 * RULES.combat.planetDefenseScale) < 1e-9
&& Math.abs(bHigh.planet.damage - 600 * RULES.combat.planetDefenseScale) < 1e-9,
`${bLow.planet.damage} / ${bHigh.planet.damage}`);
check('a near-dead colony hits far softer than a well-defended one, not roughly the same',
bHigh.planet.damage > bLow.planet.damage * 10, `${bLow.planet.damage} vs ${bHigh.planet.damage}`);
const lowDefRate = rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 4 }], 'human', 5, [],
s, { defenseHp: 5, shieldBonus: 0 }), 60);
const highDefRate = rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 4 }], 'human', 5, [],
s, { defenseHp: 600, shieldBonus: 0 }), 60);
check('a nearly-depleted colony is a softer target than a well-defended one in actual play',
lowDefRate >= highDefRate, `${lowDefRate} vs ${highDefRate}`);
}
// Ground combat.
const inv = Combat.resolveInvasion(RULES, mulberry32(7), 60, 50, { groundDefense: 0 }, 0, 40);
check('a large invasion force takes a lightly held world', inv.captured);
@ -1800,6 +1833,108 @@ section('5b. Deferred battles (human-visible combat)');
}
}
// ---------------------------------------------------------------------------
section('5c. AI attack-fleet escalation');
// ---------------------------------------------------------------------------
// Brian's ask, 2026-08-14: an AI empire that keeps losing small attacks
// against the same enemy should wait and mass a bigger fleet instead of
// feeding in the next ship the moment it locally outguns the target, rather
// than dribbling out one-and-two-ship attacks forever. VegaAI.js's
// updateWarMemory/attackMultiplier aren't exported (private to the AI
// controller, same as everything else in that file except runAITurn), so
// this drives the real decision through runAITurn itself and reads the
// observable results: state.empires[e].warMemory, and whether the attack
// fleet actually moved.
{
// A deliberately synthetic setup: small (so the two homeworlds are within
// reach on a Thorium Fuel Cells grant), a big "threat" fleet parked at a
// THIRD star (not the target colony — sitting it at the target would add
// its power to that colony's own guard and confound the test) to push
// computeStrategy into 'war' phase, and the target colony's defenseHp
// hand-set so the attacking fleet's power lands in an exact, chosen ratio
// to its guard (guard = defenseHp * 0.4, no defending fleet of its own).
const st = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', seed: 1, difficultyId: 'normal',
speciesIds: ['kkrix', 'rrashaa'], humanIndex: -1,
});
st.rules = RULES;
st.fleets = [];
Logic.grantTech(RULES, st, 0, 'thoriumcells');
const home0 = st.galaxy.homeIdx[0];
const home1 = st.galaxy.homeIdx[1];
const elsewhere = st.galaxy.stars.findIndex((s, i) => i !== home0 && i !== home1);
check('a third star exists to park the threat fleet away from the target', elsewhere >= 0);
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 1, elsewhere, [{ hullId: 'battleship', mark: 1, count: 5 }]);
const fleet = Logic.addFleet(RULES, st, 0, home0, [{ hullId: 'cruiser', mark: 1, count: 1 }]);
const defCol = st.colonies.find((c) => c.starIdx === home1);
const dock = () => { fleet.starIdx = home0; fleet.toStar = -1; fleet.fromStar = -1; fleet.progress = 0; fleet.total = 0; };
// Mirrors VegaLogic.js's own (unexported) pushEvent exactly — a synthetic
// combat event pushed straight onto state.events with no `seq` would never
// satisfy `ev.seq > cursor` (undefined comparisons are always false) and
// so would either be silently ignored or, worse, reprocessed every call.
const pushCombat = (winner, defenderIdx = 1) => {
st.events.push({
type: 'combat', starIdx: home1, attacker: 0, defender: defenderIdx, winner,
turn: st.turn, seq: st.nextEventSeq += 1,
});
};
// --- fresh (no loss history): attacks at plain parity, same as before this
// feature existed.
{
const power = Logic.fleetPower(RULES, st, fleet);
defCol.defenseHp = Math.round((power * 0.9) / 0.4); // guard = 0.9x power: clears at 1x, fails at 1.5x+
Logic.beginEmpireTurn(RULES, st, 0);
AI.runAITurn(RULES, st, 0);
check('a fresh empire (no loss history) still attacks a beatable target', fleet.toStar === home1);
}
// --- two straight losses against this enemy: the SAME fleet/target ratio
// (still only 0.9x guard headroom) now falls short of the escalated 1.5x
// bar, so it waits instead of attacking again at the same losing size.
{
dock();
st.turn = 3;
pushCombat('defender');
pushCombat('defender');
Logic.beginEmpireTurn(RULES, st, 0);
AI.runAITurn(RULES, st, 0);
check('two straight losses are tallied into warMemory', st.empires[0].warMemory[1]?.losses === 2);
check('with two losses on the books, the same marginal fleet no longer attacks',
fleet.toStar < 0 && fleet.starIdx === home0);
}
// --- a win against this enemy resets the streak, and the same marginal
// fleet is willing to attack again immediately.
{
st.turn = 4;
pushCombat('attacker');
Logic.beginEmpireTurn(RULES, st, 0);
AI.runAITurn(RULES, st, 0);
check('a win resets the loss streak to zero', st.empires[0].warMemory[1].losses === 0);
check('with the streak reset, the same marginal fleet attacks again', fleet.toStar === home1);
}
// --- escalation is capped: even after many more losses, a fleet strong
// enough to clear 3x guard still attacks — if the multiplier grew without
// bound (e.g. to 5.5x at 10 losses) this same fleet/guard ratio would fail.
{
dock();
st.turn = 5;
for (let i = 0; i < 10; i += 1) pushCombat('defender');
st.turn = 16;
fleet.ships[0].count = 20;
const power = Logic.fleetPower(RULES, st, fleet);
defCol.defenseHp = Math.round((power / 3 * 0.95) / 0.4); // guard = power / 3.16: clears a 3x cap, would fail an uncapped 5.5x
Logic.beginEmpireTurn(RULES, st, 0);
AI.runAITurn(RULES, st, 0);
check('ten losses are tallied', st.empires[0].warMemory[1].losses === 10);
check('the escalation multiplier caps at 3x rather than growing without bound',
fleet.toStar === home1);
}
}
// ---------------------------------------------------------------------------
section('6. Colony economy');
// ---------------------------------------------------------------------------