diff --git a/assets/fx/vega/vega-planet-beam.mp3 b/assets/fx/vega/vega-planet-beam.mp3 new file mode 100644 index 0000000..3403dc9 Binary files /dev/null and b/assets/fx/vega/vega-planet-beam.mp3 differ diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index ee979d2..308dde5 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -501,7 +501,7 @@ "missileRange": 6, "retreatAfterRound": 1, "disengageRound": 25, - "planetDefenseBase": 20, + "planetDefenseScale": 0.06, "bombardPopKill": 0.22, "groundOddsScale": 0.01, "cloakEvasion": 0.02, diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index caea9c5..615e404 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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' }, diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 55b6c11..f3c1732 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -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(); } diff --git a/src/games/mastervega/VegaAI.js b/src/games/mastervega/VegaAI.js index 82ad56e..c42ec94 100644 --- a/src/games/mastervega/VegaAI.js +++ b/src/games/mastervega/VegaAI.js @@ -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); diff --git a/src/games/mastervega/VegaCombat.js b/src/games/mastervega/VegaCombat.js index 53987e3..c77b905 100644 --- a/src/games/mastervega/VegaCombat.js +++ b/src/games/mastervega/VegaCombat.js @@ -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, }; diff --git a/src/games/mastervega/VegaCombatV2.js b/src/games/mastervega/VegaCombatV2.js index b56c9e4..a8fe6b9 100644 --- a/src/games/mastervega/VegaCombatV2.js +++ b/src/games/mastervega/VegaCombatV2.js @@ -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, diff --git a/src/games/mastervega/VegaCombatViewV2.js b/src/games/mastervega/VegaCombatViewV2.js index 056c050..9afee4c 100644 --- a/src/games/mastervega/VegaCombatViewV2.js +++ b/src/games/mastervega/VegaCombatViewV2.js @@ -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; diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 3294815..6b08df6 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -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 diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 112db4f..16c9124 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -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 diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index 06c63e9..290a90c 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -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 diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index ac51978..1c51b1c 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -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'); // ---------------------------------------------------------------------------