Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig 869f041b39 feat(master-vega): overhaul bombard/invasion UX, consequences, and battle logic
- **Visual Feedback**: Introduce VegaBombardScreen.js — a two-stage popup for
  bombard/invasion results featuring JIT-loaded videos (per planet type or
  invading species) and animated stat bars showing population, factory, and
  building losses.
- **Expanded Consequences**: Factory and building destruction now scale with
  the population kill ratio. On colony wipeout, weapon Mark gates planetary
  ruin/ruination (toxic/radiated/dead/asteroids) via a weighted probability table.
- **AI & Tactics**: Ursaal now queue troop transports alongside warships for
  invasion fleets. Added `bombardHoldFireChance` trait (0–1) to let species
  defer bombing when invasion troops are staged, preserving intact colonies
  without creating fortress-world stalemates.
- **Battle Logic Fixes**: Correct `prepareBattleAt` and `pendingBattlesFor` to
  properly handle stars with multiple colonies, ignore transport-only fleets,
  and suppress pending battles when planetary defenses are depleted.
- **UI/Reporting**: Wire player-initiated and AI-initiated attacks into the
  new popup queue. Add `bombard` and `invasionFailed` to notable turn report
  events with detailed outcome descriptions.
- **Testing & Tooling**: Add comprehensive verification tests for planet
  destruction distribution, building damage rates, hold-fire chances, and
  battle edge cases. Expose `window.game` for devtools debugging.
2026-08-14 23:17:31 -06:00
Brian Fertig 3bad628968 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.
2026-08-14 14:24:22 -06:00
Brian Fertig 2b1fe96bf6 Changes to battle system so the player can view battles initiated by the other side. 2026-08-14 12:17:14 -06:00
42 changed files with 1656 additions and 73 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -160,6 +160,37 @@
"radiated": { "key": "vega-colony-radiated", "path": "assets/videos/vega/colony-radiated.mp4" }
},
"_bombardVideosReadme": "Bombard/Invade popup clips (VegaBombardScreen.js) — 16:9, muted, looping, one per COLONISABLE planetType id (a colony can only ever exist on one of those, same set as colonyVideos above). Only a subset is recorded so far. A colonisable type missing from this block falls back to the 'terran' entry (Brian's call, 2026-08-14) rather than to a still image — there is no bombardment-appropriate still to fall back to the way colonyVideos falls back to worldBackgrounds — so the popup always has SOME footage even for an as-yet-unrecorded world type. If 'terran' itself is ever removed, the popup falls back further to no video at all (headline text only), same as colonyVideos falling all the way through to the gradient. Naming: assets/videos/vega/bombard-<typeId>.mp4.",
"bombardVideos": {
"terran": { "key": "vega-bombard-terran", "path": "assets/videos/vega/bombard-terran.mp4" },
"ocean": { "key": "vega-bombard-ocean", "path": "assets/videos/vega/bombard-ocean.mp4" },
"tundra": { "key": "vega-bombard-tundra", "path": "assets/videos/vega/bombard-tundra.mp4" },
"jungle": { "key": "vega-bombard-jungle", "path": "assets/videos/vega/bombard-jungle.mp4" },
"inferno": { "key": "vega-bombard-inferno", "path": "assets/videos/vega/bombard-inferno.mp4" },
"arid": { "key": "vega-bombard-arid", "path": "assets/videos/vega/bombard-arid.mp4" },
"desert": { "key": "vega-bombard-desert", "path": "assets/videos/vega/bombard-desert.mp4" },
"toxic": { "key": "vega-bombard-toxic", "path": "assets/videos/vega/bombard-toxic.mp4" },
"steppe": { "key": "vega-bombard-steppe", "path": "assets/videos/vega/bombard-steppe.mp4" },
"minimal": { "key": "vega-bombard-minimal", "path": "assets/videos/vega/bombard-minimal.mp4" },
"barren": { "key": "vega-bombard-barren", "path": "assets/videos/vega/bombard-barren.mp4" },
"dead": { "key": "vega-bombard-dead", "path": "assets/videos/vega/bombard-dead.mp4" },
"radiated": { "key": "vega-bombard-radiated", "path": "assets/videos/vega/bombard-radiated.mp4" }
},
"_invadeVideosReadme": "Invasion popup clips (VegaBombardScreen.js) — 16:9, muted, looping, one per SPECIES (the INVADING empire's species, not the target planet's type — landing troops is about who's doing the landing). Brian's call (2026-08-14): no per-species invasion footage exists yet, so every entry below is a PLACEHOLDER pointing at the bombard-terran clip until real footage is recorded. Swap one in the same way as every other JIT video set here: drop assets/videos/vega/invade-<speciesId>.mp4 and repoint that species' path at it — nothing else changes. A species entirely absent (or with a null path) falls back to no video at all (headline text only), same as bombardVideos falling through when even 'terran' is missing.",
"invadeVideos": {
"human": { "key": "vega-invade-human", "path": "assets/videos/vega/invade-human.mp4" },
"kestrelli": { "key": "vega-invade-kestrelli", "path": "assets/videos/vega/invade-kestrelli.mp4" },
"ursaal": { "key": "vega-invade-ursaal", "path": "assets/videos/vega/invade-ursaal.mp4" },
"umbrix": { "key": "vega-invade-umbrix", "path": "assets/videos/vega/invade-umbrix.mp4" },
"kkrix": { "key": "vega-invade-kkrix", "path": "assets/videos/vega/invade-kkrix.mp4" },
"mekhan": { "key": "vega-invade-mekhan", "path": "assets/videos/vega/invade-mekhan.mp4" },
"rrashaa": { "key": "vega-invade-rrashaa", "path": "assets/videos/vega/invade-rrashaa.mp4" },
"cerebrai": { "key": "vega-invade-cerebrai", "path": "assets/videos/vega/invade-cerebrai.mp4" },
"ssakar": { "key": "vega-invade-ssakar", "path": "assets/videos/vega/invade-ssakar.mp4" },
"lithox": { "key": "vega-invade-lithox", "path": "assets/videos/vega/invade-lithox.mp4" }
},
"_audienceVideosReadme": "Audience-screen MOOD clips for the full-screen diplomacy interface — one per diplomacy-capable species x {angry, neutral, happy}, muted, looping. Recorded at 896x576 (1.5556:1, close to but not exactly the 3:2 the VegaAudience.js frame is cut to — the clip is scaled by WIDTH only, never setDisplaySize, so it sits slightly short of the frame's full height rather than being stretched). Lithox has no entry at all: canNegotiate() is permanently false for them (traits.diplomacy <= -100), so the Audience screen never opens for that species and a clip would never play. A species entirely absent (null) or a mood missing inside a recorded species falls back to that species' portraitVideos clip (256x256, letterboxed to 3:2), then to portraitStills — same three-tier fallback convention as makeSpeciesPortrait, so the roster fills one species (or one mood) at a time with no code change. Naming: assets/videos/vega/audience-<speciesId>-<mood>.mp4.",
"audienceVideos": {
"human": {

View File

@ -198,7 +198,7 @@
{ "id": "blackhole","name": "Black Hole", "color": "#3a2b52", "coreColor": "#050308", "radius": 10, "weight": 1, "planetBias": -9, "richBias": 0, "special": "blackhole", "desc": "Light bends around it. Fleets that linger do not return." }
],
"_speciesReadme": "MOO1's ten races with everything but Humans renamed. Multipliers are relative to 1.0; flat modifiers are additive percentage points. techAffinity is bucketed by VegaRules.techCostFactor into MOO1's Poor/Average/Good/Excellent research-COST multiplier (125/100/80/60%) for that field — matching the real game, where racial skill changes what a tech costs, never whether it exists in your tree. It is ALSO still read by VegaLogic.rollTechAvailability to bias which techs are available at all, which is the OPPOSITE of how MOO1 actually works (there, availability is a flat ~50% roll per tech regardless of skill, 75% for a single research-focused race, with the sole guarantee that the last surviving tech in a rung is never struck out) — that double duty is a known stopgap, to be removed once the tech tree grows branching alternatives per rung for the roll to have something real to strike between.",
"_speciesReadme": "MOO1's ten races with everything but Humans renamed. Multipliers are relative to 1.0; flat modifiers are additive percentage points. techAffinity is bucketed by VegaRules.techCostFactor into MOO1's Poor/Average/Good/Excellent research-COST multiplier (125/100/80/60%) for that field — matching the real game, where racial skill changes what a tech costs, never whether it exists in your tree. It is ALSO still read by VegaLogic.rollTechAvailability to bias which techs are available at all, which is the OPPOSITE of how MOO1 actually works (there, availability is a flat ~50% roll per tech regardless of skill, 75% for a single research-focused race, with the sole guarantee that the last surviving tech in a rung is never struck out) — that double duty is a known stopgap, to be removed once the tech tree grows branching alternatives per rung for the roll to have something real to strike between. `bombardHoldFireChance` (added 2026-08-14, Brian's ask, widened same day from an original true/false `prefersAnnihilation` once Ursaal needed a THIRD point on the same dial): when a hostile colony's invasion odds aren't yet favourable but this empire has transports staged for one, VegaAI.js's bombard step (2a in manageFleets) rolls this chance, per turn, to hold fire instead of bombing — so the invasion in progress isn't wasted on a colony that's about to be a smoking crater. 0.65 is the default (most species would rather wait for a second transport wave than glass the world). Rrashaa (a warrior culture built around ship weapons rather than ground troops) and Lithox (colonizeAnything/hostileImmune — they don't need an intact population to resettle a world afterward) are 0: they bomb exactly as readily as every species did before this trait existed. Ursaal is 0.95: their +50 ground-combat trait makes invasion their entire military identity, so they hold fire almost every time rather than waste it. It is never 1 for anyone, by design — the roll runs fresh every turn regardless of how high the chance is, so even Ursaal's fortress-siege eventually breaks by bombardment instead of stalling forever (see the fortress-world stalemate note in VegaAI.js's own comment on this).",
"species": [
{
"id": "human", "name": "Human", "adjective": "Human", "plural": "Humans",
@ -211,7 +211,8 @@
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.5, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 10, "groundDefense": 10,
"espionage": 0, "counterEspionage": 0, "diplomacy": 40,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false,
"bombardHoldFireChance": 0.65
},
"techAffinity": { "computers": 1.0, "construction": 1.0, "forcefields": 1.0, "planetology": 1.0, "propulsion": 1.0, "weapons": 1.0 }
},
@ -226,7 +227,8 @@
"industryMult": 0.9, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 50, "groundAttack": -10, "groundDefense": -10,
"espionage": 0, "counterEspionage": 0, "diplomacy": 10,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false,
"bombardHoldFireChance": 0.65
},
"techAffinity": { "computers": 1.0, "construction": 0.9, "forcefields": 1.1, "planetology": 0.9, "propulsion": 1.3, "weapons": 1.0 }
},
@ -241,7 +243,8 @@
"industryMult": 1.0, "researchMult": 0.8, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 50, "groundDefense": 50,
"espionage": 0, "counterEspionage": 0, "diplomacy": -20,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": true, "hostileImmune": false, "colonizeAnything": false
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": true, "hostileImmune": false, "colonizeAnything": false,
"bombardHoldFireChance": 0.95
},
"techAffinity": { "computers": 0.8, "construction": 1.2, "forcefields": 1.0, "planetology": 1.0, "propulsion": 0.9, "weapons": 1.1 }
},
@ -256,7 +259,8 @@
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
"espionage": 60, "counterEspionage": 30, "diplomacy": -50,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false,
"bombardHoldFireChance": 0.65
},
"techAffinity": { "computers": 1.2, "construction": 1.0, "forcefields": 1.1, "planetology": 0.9, "propulsion": 1.0, "weapons": 1.0 }
},
@ -271,7 +275,8 @@
"industryMult": 1.5, "researchMult": 0.8, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
"espionage": -20, "counterEspionage": 0, "diplomacy": -40,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false,
"bombardHoldFireChance": 0.65
},
"techAffinity": { "computers": 0.8, "construction": 1.3, "forcefields": 0.9, "planetology": 1.1, "propulsion": 1.0, "weapons": 1.0 }
},
@ -286,7 +291,8 @@
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 2.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
"espionage": 0, "counterEspionage": 0, "diplomacy": -10,
"maxPopMult": 1.0, "factoriesPerPop": 4, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
"maxPopMult": 1.0, "factoriesPerPop": 4, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false,
"bombardHoldFireChance": 0.65
},
"techAffinity": { "computers": 1.2, "construction": 1.3, "forcefields": 1.0, "planetology": 0.8, "propulsion": 1.0, "weapons": 1.0 }
},
@ -301,7 +307,8 @@
"industryMult": 1.0, "researchMult": 0.9, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 50, "shipDefense": 0, "groundAttack": 0, "groundDefense": -25,
"espionage": 0, "counterEspionage": 0, "diplomacy": -25,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false,
"bombardHoldFireChance": 0
},
"techAffinity": { "computers": 1.0, "construction": 1.0, "forcefields": 0.9, "planetology": 0.9, "propulsion": 1.0, "weapons": 1.3 }
},
@ -316,7 +323,8 @@
"industryMult": 1.0, "researchMult": 2.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": -50, "groundDefense": -50,
"espionage": 0, "counterEspionage": 0, "diplomacy": 0,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false,
"bombardHoldFireChance": 0.65
},
"techAffinity": { "computers": 1.4, "construction": 1.2, "forcefields": 1.2, "planetology": 1.2, "propulsion": 1.2, "weapons": 1.2 }
},
@ -331,7 +339,8 @@
"industryMult": 1.0, "researchMult": 0.85, "tradeMult": 1.0, "growthMult": 2.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 10, "groundDefense": 0,
"espionage": 0, "counterEspionage": 0, "diplomacy": -20,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false,
"bombardHoldFireChance": 0.65
},
"techAffinity": { "computers": 0.9, "construction": 1.0, "forcefields": 0.9, "planetology": 1.2, "propulsion": 1.0, "weapons": 1.0 }
},
@ -346,7 +355,8 @@
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 0.5, "ecologyMult": 0.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
"espionage": -20, "counterEspionage": 20, "diplomacy": -100,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": true, "hostileImmune": true, "colonizeAnything": true
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": true, "hostileImmune": true, "colonizeAnything": true,
"bombardHoldFireChance": 0
},
"techAffinity": { "computers": 0.9, "construction": 1.2, "forcefields": 1.1, "planetology": 0.7, "propulsion": 0.9, "weapons": 1.0 }
}
@ -501,8 +511,11 @@
"missileRange": 6,
"retreatAfterRound": 1,
"disengageRound": 25,
"planetDefenseBase": 20,
"planetDefenseScale": 0.06,
"bombardPopKill": 0.22,
"bombardFactoryKillMult": 1,
"bombardBuildingDestroyMult": 1,
"bombardDefensiveBuildingDestroyMult": 0.25,
"groundOddsScale": 0.01,
"cloakEvasion": 0.02,
"singularityShieldPierce": 0.5

View File

@ -171,6 +171,10 @@ export const MANIFEST = {
// The Galactic Council session ceremony's persistent anchor visual, same
// 2:3 eager-load treatment as the GNN clip above (VegaCouncilSession.js).
{ type: 'video', key: 'vega-council-anchor', path: 'assets/videos/vega/galactic-council.mp4' },
// Bombard/Invade popup clips are JIT-loaded per planet type instead —
// see bombardVideos in mastervega-artwork.json and VegaBombardScreen.js
// — same convention as colonyVideos/researchVideos/advisorVideos, none
// of which are listed here either.
// The colony-founding cue. It is not part of the shuffled soundtrack —
// VegaColonyIntro.js ducks that and plays this over the vignette instead.
// Small, and it has to be ready the instant the vignette opens.
@ -213,6 +217,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';
@ -38,6 +39,7 @@ import { openTurnReportScreen } from './VegaTurnReportScreen.js';
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
import * as Gnn from './VegaGnn.js';
import { openGnnScreen } from './VegaGnnScreen.js';
import { openBombardPopup } from './VegaBombardScreen.js';
import { openCouncilSessionScreen } from './VegaCouncilSession.js';
import { openAudienceScreen } from './VegaAudience.js';
import { playIntroVideo } from './VegaIntroVideo.js';
@ -1193,17 +1195,30 @@ 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.
playPlayerBattles(done) {
// 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 screen to watch (and, when the
// human has a fleet in this fight, a formation choice — see the
// humanIsDefender/humanHasFleetHere gate below). 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, UNLESS they're purely defending with no fleet
// of their own present), 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, { attacked = false } = {}) {
const me = this.state.humanIndex;
const pending = Logic.pendingBattlesFor(this.rules, this.state, me);
if (!pending.length) { done(); return; }
@ -1213,11 +1228,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');
@ -1233,7 +1245,38 @@ export default class MasterOfVegaGame extends Phaser.Scene {
next(i + 1);
},
});
}, { closable: false });
};
// A defending human with no fleet in orbit — only planetary defenses
// fighting — has no formation to choose a doctrine FOR, so asking is a
// pointless prompt in front of a fight they have zero ships in (Brian's
// ask, 2026-08-14). Routed into the same no-prompt path `attacked:true`
// already uses, regardless of which call site found this battle.
const colony = Logic.colonyAt(this.state, starIdx);
const humanIsDefender = colony?.empireIdx === me;
const humanHasFleetHere = Logic.fleetsAt(this.state, starIdx)
.some((f) => f.empireIdx === me && f.ships.length);
if (attacked || (humanIsDefender && !humanHasFleetHere)) {
// 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);
}
@ -1315,6 +1358,36 @@ export default class MasterOfVegaGame extends Phaser.Scene {
finishAll();
}
};
// AI-initiated bombardments/invasions against the player's own
// colonies get their own rich popup (VegaBombardScreen.js)
// here, one per event, shown BEFORE the turn report (Brian's
// ask, 2026-08-14: the attack itself is the bigger beat, so it
// leads rather than getting buried after the report). Player-
// initiated attacks already got this popup synchronously the
// moment they were clicked (VegaSystemView.js), so only events
// the human did NOT initiate and where the human is the one
// being attacked qualify — an AI-vs-AI bombardment still gets
// its ordinary turn-report row (it's in NOTABLE_TYPES) but
// never this popup.
const me = this.state.humanIndex;
const attackEvents = notable.filter((ev) => ev.empire !== me && (
(ev.type === 'bombard' && ev.target === me)
|| (ev.type === 'captured' && ev.from === me)
|| (ev.type === 'invasionFailed' && ev.defenderIdx === me)
));
const attackPopupOpts = (ev) => {
if (ev.type === 'bombard') {
return { kind: 'bombard', attackerIdx: ev.empire, defenderIdx: ev.target, starIdx: ev.starIdx, result: ev };
}
const defenderIdx = ev.type === 'captured' ? ev.from : ev.defenderIdx;
return {
kind: 'invade', attackerIdx: ev.empire, defenderIdx, starIdx: ev.starIdx,
result: {
captured: ev.type === 'captured', attackersLeft: ev.attackersLeft,
defendersLeft: ev.defendersLeft, troops: ev.troops, planetTypeId: ev.planetTypeId,
},
};
};
const openReport = () => {
if (notable.length) {
openTurnReportScreen(this, this.rules, this.state, notable, openGnnIfPending);
@ -1322,8 +1395,13 @@ export default class MasterOfVegaGame extends Phaser.Scene {
openGnnIfPending();
}
};
const runChoices = (queue) => {
const showAttackPopups = (queue) => {
if (!queue.length) { openReport(); return; }
const [ev, ...rest] = queue;
openBombardPopup(this, this.rules, this.state, attackPopupOpts(ev), () => showAttackPopups(rest));
};
const runChoices = (queue) => {
if (!queue.length) { showAttackPopups(attackEvents); return; }
const [next, ...rest] = queue;
openResearchChoiceScreen(this, this.rules, this.state, this.state.humanIndex, this.art,
next.field, next.choices, next.completedTechId, () => runChoices(rest));
@ -1340,8 +1418,19 @@ 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 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), { attacked: true });
};
step();
}

View File

@ -173,6 +173,37 @@ function manageColony(rules, state, e, colony, strat) {
// Warships when threatened, or a standing patrol once developed.
const warship = preferredWarship(rules, state, e, budget);
// Ursaal build invasion fleets, not battle fleets that happen to carry
// marines as an afterthought — their +50 ground-combat trait makes
// marines the actual war-winning asset, so transports compete for queue
// slots on EQUAL footing with warships (roughly one-for-one, up to a
// standing invasion force) instead of only getting built once warship
// demand happens to go quiet, and the buildup starts in `expand` phase
// already, ahead of any declared war (Brian's ask, 2026-08-14: "build
// troop transports as part of their strategy for invasion fleets to
// begin with"). Every other species keeps the original warships-first,
// transports-only-at-war order below.
if (spec.id === 'ursaal' && (strat.phase === 'war' || strat.phase === 'expand') && warship) {
const fleets = empireFleets(state, e);
const transportCount = fleets.reduce((t, f) => t
+ f.ships.filter((s) => s.hullId === 'transport').reduce((n, s) => n + s.count, 0), 0);
const warshipCount = fleets.reduce((t, f) => t
+ f.ships.filter((s) => rules.hulls[s.hullId]?.role === 'warship').reduce((n, s) => n + s.count, 0), 0);
// A bigger standing invasion force than the generic war-phase floor
// (10, below) — Ursaal keep marines ready even with no war declared yet.
let wanted = 14;
for (const enemy of strat.enemies) {
for (const c of empireColonies(state, enemy.idx)) {
wanted = Math.max(wanted, Math.ceil((c.pop / 8 + 6) * 1.6 / (rules.hulls.transport.troops ?? 4)));
}
}
if (transportCount < Math.min(wanted, 60) && transportCount <= warshipCount) {
enqueue(rules, state, colony, 'ship', 'transport');
return;
}
}
// At war, keep building: a fleet that stops at parity can never break
// through, and the enemy is building too.
const needFleet = strat.phase === 'war'
@ -258,7 +289,27 @@ function manageFleets(rules, state, e, strat) {
// worth far more than a dead one, and bombarding unconditionally turned
// every war into scorched earth — 805 worlds burned against 40 taken.
const forecast = invasionForecast(rules, state, e, fleet.starIdx);
if (!forecast || !forecast.favourable) bombard(rules, state, e, fleet.starIdx);
// Marines already staged here (or en route) make an invasion of THIS
// colony a live option, not a hypothetical — most species would rather
// wait for a second transport wave than glass the world they're trying
// to take (Brian's ask, 2026-08-14). How readily a species holds fire
// for that is `bombardHoldFireChance` on its trait block in
// data/mastervega-rules.json — 0 for a species that bombs exactly as
// readily as every species did before this change (an annihilator, or
// simply not built for ground combat), up toward 1 for a species built
// around taking worlds intact (Ursaal, whose +50 ground combat makes
// invasion their whole strength). This is a PREFERENCE, not a ban even
// at the high end: holding fire unconditionally would recreate the
// exact fortress-world stalemate bombard was added to fix (see the
// comment above), so the roll runs fresh every turn no matter how high
// the chance is — it only ever suppresses this turn's bombardment, not
// the war.
const spec = rules.species[emp.speciesId];
const invasionStaged = state.fleets.some((f) => f.empireIdx === e
&& (f.starIdx === fleet.starIdx || f.toStar === fleet.starIdx)
&& f.ships.some((s) => s.hullId === 'transport' && s.count > 0));
const holdFire = invasionStaged && rand(state) < (spec.traits.bombardHoldFireChance ?? 0.65);
if ((!forecast || !forecast.favourable) && !holdFire) bombard(rules, state, e, fleet.starIdx);
}
}
@ -356,11 +407,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 +476,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

@ -714,6 +714,28 @@ export function hasAdvisorVideo(scene, speciesId) {
return !!scene.cache.video?.exists(advisorVideoKey(speciesId));
}
// Bombard/Invade popup clips (VegaBombardScreen.js) — one per planet type,
// JIT-loaded the same way as the three sets above. A type with no clip
// recorded falls back to the 'terran' entry (documented in the manifest's
// bombardVideos readme), so the popup is never left with nothing to show.
export const bombardVideoKey = (typeId) => `vega-bombard-${typeId}`;
export function hasBombardVideo(scene, typeId) {
return !!scene.cache.video?.exists(bombardVideoKey(typeId));
}
// Invasion popup clips (VegaBombardScreen.js) — a SEPARATE set from the
// bombard clips above, keyed by the INVADING empire's species rather than
// the target planet's type (marines landing is about who is doing the
// landing, not what world they're landing on). Every species currently
// points at a placeholder (see the manifest's invadeVideos readme) until
// real per-species footage is recorded.
export const invadeVideoKey = (speciesId) => `vega-invade-${speciesId}`;
export function hasInvadeVideo(scene, speciesId) {
return !!scene.cache.video?.exists(invadeVideoKey(speciesId));
}
// Frame helpers — the one place that knows how the sheets are indexed.
export const shipFrame = (rules, speciesId, hullId) => {
const s = rules.species[speciesId];

View File

@ -0,0 +1,405 @@
// Master of Vega — the Bombard/Invade results popup: a two-stage takeover
// (attack-in-progress video + headline, then "View Results" reveals the
// outcome) for every bombardment or invasion where the human player is
// either the attacker or the defender.
//
// Player-initiated actions open this the instant VegaSystemView.js's
// Bombard/Invade button resolves (the engine call already ran; this just
// presents the already-computed result). AI-initiated actions against a
// human colony open it during MasterOfVegaGame.js's runToHumanTurn() turn-
// report chain, one popup per notable bombard/captured/invasionFailed event
// where the human is the defender.
//
// `result` is passed through with the SAME field names bombard()/invade()
// already return AND the same field names their pushEvent() payloads
// already carry (VegaLogic.js) — by design, so callers never have to
// reshape data before handing it to this screen:
// bombard: { killed, factoriesLost, buildingsDestroyed, destroyed, planetConverted, planetTypeId,
// popBefore, popAfter, factoriesBefore, factoriesAfter, buildingsBefore, buildingsAfter }
// invade: { captured, attackersLeft, defendersLeft, troops, planetTypeId }
//
// The results stage animates the popBefore/After, factoriesBefore/After and
// buildingsBefore/After triples as three draining count-up bars
// (buildBombardBars) — Brian's ask (2026-08-14), modelled on the GNN/Council
// vote-reveal bars elsewhere in this game.
import * as Phaser from 'phaser';
import { Button } from './VegaButton.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { modalShell, FONT, uiClick } from './VegaScreens.js';
import { sourceWidth, bombardVideoKey, invadeVideoKey } from './VegaArt.js';
// Fallback video for any planet type without its own recorded bombard clip —
// Brian's call (2026-08-14): only a handful of planetType ids have footage
// so far, and there is no bombardment-appropriate STILL to fall back to the
// way colonyVideos falls back to worldBackgrounds, so an unrecorded type
// borrows this one instead of showing nothing.
const FALLBACK_VIDEO_TYPE = 'terran';
// Fallback size used before the video's first frame decodes — see
// VegaArt.js's sourceWidth() doc comment for why a fresh Video reports a
// placeholder rather than 0. The real clips are authored 16:9; this is just
// a safe placeholder aspect until the texture is real.
const VIDEO_SRC_W = 1280;
const WIDTH = 1100;
// Tall enough that the video, the headline below it (which can wrap to a
// couple of lines for a long empire/star name) and the View Results button
// never crowd each other — the first cut of this at HEIGHT 800 had the
// button overlapping the headline text.
const HEIGHT = 960;
const VIDEO_W = WIDTH - 60;
const VIDEO_H = Math.round((VIDEO_W * 9) / 16);
const VIDEO_TOP_PAD = 20;
const HEADLINE_GAP = 34;
// --------------------------------------------------------------------------
// JIT loading — one-for-one with VegaResearchScreen.js's ensureOneResearchVideo
// (itself one-for-one with VegaAudience.js's warmBytes/ensureOneAudienceVideo
// pair), generalised to a literal {key, path} rather than baking in one
// typeId/speciesId dimension — this screen needs TWO independent video sets
// (bombardVideos by planet type, invadeVideos by the invading species) and
// the loader itself doesn't care which produced the pair.
function artworkPath(scene, section, id) {
return scene.cache.json.get('mastervega-artwork')?.[section]?.[id]?.path ?? null;
}
/** The planet type to actually load a bombard clip for: `typeId`, the fallback, or none. */
function pickBombardVideoType(scene, typeId) {
if (typeId && artworkPath(scene, 'bombardVideos', typeId)) return typeId;
if (artworkPath(scene, 'bombardVideos', FALLBACK_VIDEO_TYPE)) return FALLBACK_VIDEO_TYPE;
return null;
}
/**
* The {key, path} pair to actually load for this popup, or null for no
* video. Bombard picks by the target planet's type (with the 'terran'
* fallback above); invade picks by the INVADING empire's species no
* fallback species, since every species currently has a (placeholder)
* entry in invadeVideos, so a miss here means the species truly has none.
*/
function pickVideo(scene, opts, attackerSpeciesId) {
if (opts.kind === 'invade') {
const path = attackerSpeciesId ? artworkPath(scene, 'invadeVideos', attackerSpeciesId) : null;
return path ? { key: invadeVideoKey(attackerSpeciesId), path } : null;
}
const typeId = pickBombardVideoType(scene, opts.result?.planetTypeId);
return typeId ? { key: bombardVideoKey(typeId), path: artworkPath(scene, 'bombardVideos', typeId) } : null;
}
const warmed = new WeakMap();
function warmBytes(scene, key, path) {
let store = warmed.get(scene);
if (!store) { store = new Map(); warmed.set(scene, store); }
if (store.has(key)) return;
try {
const el = document.createElement('video');
el.preload = 'auto';
el.muted = true;
el.setAttribute('playsinline', 'playsinline');
el.src = path;
el.load();
store.set(key, el);
} catch (err) { /* prefetch is an optimisation, never a requirement */ }
}
const inFlight = new WeakMap();
function pendingFor(scene) {
let set = inFlight.get(scene);
if (!set) { set = new Set(); inFlight.set(scene, set); }
return set;
}
function ensureOneVideo(scene, key, path, onDone) {
if (path) warmBytes(scene, key, path);
if (scene.cache.video?.exists(key) || !path) { onDone?.(); return; }
const pending = pendingFor(scene);
const doneEvent = `filecomplete-video-${key}`;
if (onDone) {
let onLoad;
let onError;
const settle = () => {
scene.load.off(doneEvent, onLoad);
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
onDone();
};
onLoad = () => settle();
onError = (file) => { if (file?.key === key) settle(); };
scene.load.once(doneEvent, onLoad);
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
}
if (pending.has(key)) return;
pending.add(key);
let clearDone;
let clearError;
const clear = () => {
scene.load.off(doneEvent, clearDone);
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError);
pending.delete(key);
};
clearDone = () => clear();
clearError = (file) => { if (file?.key === key) clear(); };
scene.load.once(doneEvent, clearDone);
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError);
scene.load.video(key, path, false); // noAudio: false — plays with sound
if (!scene.load.isLoading()) scene.load.start();
}
// --------------------------------------------------------------------------
function planetTypeName(rules, typeId) {
return rules.planetTypes[typeId]?.name ?? typeId;
}
function buildingName(rules, id) {
return rules.buildings[id]?.name ?? id;
}
function line(scene, x, y, w, text, color = '#e8f4ff', size = '20px') {
return scene.add.text(x, y, text, {
fontFamily: FONT, fontSize: size, color, wordWrap: { width: w },
});
}
// --------------------------------------------------------------------------
// Results-stage stat bars — population/factories/buildings draining from
// their pre-strike total down to what's left, modelled on
// VegaCouncilSession.js's vote-reveal bars (buildVoting/drawBar/addCounter):
// a Graphics fill cleared and redrawn every tick rather than GNN's
// scaleX-on-a-static-shape approach, since (like Council's bars, unlike
// GNN's race) only a handful of these ever animate at once and nothing
// needs to swap rank order mid-flight.
const BAR_H = 22;
const BAR_ROW_H = 84; // label row + bar + caption row + gap to the next stat
const BAR_ANIM_MS = 1800;
function buildStatBar(scene, container, x, y, w, { label, before, after, color, verb }) {
const colorInt = Phaser.Display.Color.HexStringToColor(color).color;
container.add(scene.add.text(x, y, label.toUpperCase(), {
fontFamily: FONT, fontSize: '15px', color: '#a8c4e0',
}));
const killedText = scene.add.text(x + w, y, '', {
fontFamily: FONT, fontSize: '16px', color,
}).setOrigin(1, 0);
container.add(killedText);
const trackY = y + 22;
container.add(scene.add.rectangle(x, trackY, w, BAR_H, 0x1b2b42).setOrigin(0, 0));
const fillG = scene.add.graphics().setPosition(x, trackY);
container.add(fillG);
const captionText = scene.add.text(x + w, trackY + BAR_H + 4, '', {
fontFamily: FONT, fontSize: '14px', color: '#7f97b3',
}).setOrigin(1, 0);
container.add(captionText);
const draw = (value) => {
fillG.clear();
fillG.fillStyle(colorInt, 1);
const frac = before > 0 ? Phaser.Math.Clamp(value / before, 0, 1) : 0;
fillG.fillRect(0, 0, Math.max(w * frac, 0.01), BAR_H);
killedText.setText(`${Math.round(before - value)} ${verb}`);
captionText.setText(`${Math.round(value)} / ${Math.round(before)} remaining`);
};
draw(before);
return { draw, after };
}
/**
* Three draining bars (population/factories/buildings) animating from their
* pre-strike totals down to what's left, all racing together over
* BAR_ANIM_MS. Returns `{ height, stop }` `stop()` halts the tween and
* the counter sound early (Close button, or the popup tearing down before
* the animation finishes).
*/
function buildBombardBars(scene, container, x, y0, w, result) {
const stats = [
{
label: 'Population', color: '#ff6a6a', verb: 'killed',
before: result.popBefore ?? 0, after: result.popAfter ?? 0,
},
{
label: 'Factories', color: '#ffb347', verb: 'lost',
before: result.factoriesBefore ?? 0, after: result.factoriesAfter ?? 0,
},
{
label: 'Buildings', color: '#6fc4ff', verb: 'destroyed',
before: result.buildingsBefore ?? 0, after: result.buildingsAfter ?? 0,
},
];
const bars = stats.map((s, i) => ({
...buildStatBar(scene, container, x, y0 + i * BAR_ROW_H, w, s),
before: s.before,
}));
const counterSound = scene.sound.add(SFX.VEGA_COUNTER, { volume: 0.9 });
counterSound.play();
const tween = scene.tweens.addCounter({
from: 0, to: 1, duration: BAR_ANIM_MS, ease: 'Linear',
onUpdate: (tw) => {
const t = tw.getValue();
for (const b of bars) b.draw(Phaser.Math.Linear(b.before, b.after, t));
},
onComplete: () => {
for (const b of bars) b.draw(b.after);
counterSound.stop();
counterSound.destroy();
},
});
return {
height: stats.length * BAR_ROW_H,
stop() {
tween.stop();
counterSound.stop();
counterSound.destroy();
},
};
}
function headlineFor(rules, state, opts) {
const { kind, attackerIdx, defenderIdx, starIdx } = opts;
const star = state.galaxy.stars[starIdx];
const attacker = state.empires[attackerIdx]?.name ?? '?';
const defender = state.empires[defenderIdx]?.name ?? '?';
return kind === 'invade'
? `The ${attacker} launch an invasion of the ${defender} colony at ${star.name}.`
: `The ${attacker} bombard the ${defender} colony at ${star.name}.`;
}
function resultLines(scene, rules, x, y0, w, opts) {
const { kind, result } = opts;
const rows = [];
if (kind === 'invade') {
rows.push(`Marines committed: ${Math.round(result.troops ?? 0)}`);
if (result.captured) {
rows.push([`Invasion successful — ${Math.round(result.attackersLeft ?? 0)} marines secured the world.`, '#7fd8a0']);
rows.push('The colony changes hands; its population is halved and its defenses razed in the fighting.');
} else {
rows.push([`Invasion failed — every marine was lost. ~${Math.round(result.defendersLeft ?? 0)} defenders held.`, '#e08a8a']);
}
} else {
// Population/factories/buildings COUNTS are shown as the animated bars
// above this text block (buildBombardBars) — this only adds the names of
// what was destroyed, which a bar can't convey on its own.
rows.push(result.buildingsDestroyed?.length
? `Buildings destroyed: ${result.buildingsDestroyed.map((id) => buildingName(rules, id)).join(', ')}`
: 'Buildings destroyed: none');
if (result.destroyed) {
rows.push(['The colony has been bombed out of existence.', '#e08a8a']);
}
if (result.planetConverted) {
rows.push([`The bombardment has ruined the world — its surface is now ${planetTypeName(rules, result.planetConverted).toUpperCase()}.`, '#ffb27a']);
}
}
const out = [];
let y = y0;
for (const row of rows) {
const [text, color] = Array.isArray(row) ? row : [row, '#e8f4ff'];
const t = line(scene, x, y, w, text, color, '19px');
out.push(t);
y += t.height + 14;
}
return out;
}
/**
* Open the bombard/invade popup. `opts`:
* kind: 'bombard' | 'invade'
* attackerIdx, defenderIdx: empire indices
* starIdx: the star the colony sits at
* result: see the field-name tables in the file header comment
*/
export function openBombardPopup(scene, rules, state, opts, onClose) {
const shell = modalShell(scene, opts.kind === 'invade' ? 'Invasion' : 'Bombardment', null, {
width: WIDTH, height: HEIGHT, closable: false,
});
const { body } = shell;
const content = scene.add.container(0, 0);
shell.add(content);
let video = null;
let attackStageAlive = false;
const destroyVideo = () => { video?.destroy(); video = null; };
function addVideo(key) {
const vx = body.x + body.w / 2;
const vy = body.y + VIDEO_TOP_PAD + VIDEO_H / 2;
video = scene.add.video(vx, vy, key);
video.setLoop(true);
// Plays with sound — a browser that blocks autoplay-with-audio fires
// 'locked' and this falls back to muted rather than silently stalling,
// same convention as VegaIntroVideo.js's own audible clip.
video.setMute(false);
video.setVolume(1);
const fit = () => video.setScale(VIDEO_W / sourceWidth(video, VIDEO_SRC_W));
fit();
video.on('created', fit);
video.on('playing', fit);
video.once('locked', () => { if (video?.scene) video.setMute(true); });
video.play(true);
content.add(video);
}
function enterAttack() {
content.removeAll(true);
attackStageAlive = true;
content.add(scene.add.rectangle(
body.x + body.w / 2, body.y + VIDEO_TOP_PAD + VIDEO_H / 2, VIDEO_W, VIDEO_H, 0x0b1220, 1,
));
const attackerSpeciesId = state.empires[opts.attackerIdx]?.speciesId;
const picked = pickVideo(scene, opts, attackerSpeciesId);
if (picked) {
if (scene.cache.video?.exists(picked.key)) {
addVideo(picked.key);
} else {
ensureOneVideo(scene, picked.key, picked.path, () => {
if (attackStageAlive && scene.cache.video?.exists(picked.key)) addVideo(picked.key);
});
}
}
content.add(line(scene, body.x, body.y + VIDEO_TOP_PAD + VIDEO_H + HEADLINE_GAP, body.w,
headlineFor(rules, state, opts), '#e8f4ff', '22px'));
content.add(new Button(scene, body.x + body.w / 2, body.y + body.h - 34, 'View Results', uiClick(scene, () => {
enterResults();
}), { width: 260, height: 52, fontSize: 20 }));
}
let bars = null;
function enterResults() {
attackStageAlive = false;
destroyVideo();
content.removeAll(true);
bars = null;
content.add(line(scene, body.x, body.y, body.w, headlineFor(rules, state, opts), '#cfe8ff', '20px'));
let textY = body.y + 60;
if (opts.kind === 'bombard') {
bars = buildBombardBars(scene, content, body.x, textY, body.w, opts.result);
textY += bars.height + 10;
}
for (const t of resultLines(scene, rules, body.x, textY, body.w, opts)) content.add(t);
content.add(new Button(scene, body.x + body.w / 2, body.y + body.h - 34, 'Close', () => {
playSound(scene, SFX.VEGA_CLOSE);
bars?.stop();
destroyVideo();
shell.destroy();
onClose?.();
}, { width: 220, height: 52, fontSize: 20 }));
}
enterAttack();
return shell;
}

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
@ -1398,7 +1415,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 +1444,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 +1454,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);
@ -1474,7 +1505,35 @@ function applyBattleLosses(rules, state, starIdx, e, survivors) {
// fallback for an unset side. AI-vs-AI battles (fightAt, below) never pass
// this and are unaffected — both sides stay silently random as before.
export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation = null } = {}) {
const colony = colonyAt(state, starIdx);
// Must search ALL colonies at this star for one owned by a or b, not just
// take colonyAt()'s first-in-array match — a star can host colonies from
// more than one empire (e.g. one side's home cluster with several worlds
// of its own sharing a system with the other side's single outpost), and
// colonyAt() would silently hand back whichever of THOSE happened to be
// first, with no relation to who's actually fighting here. That flips
// attacker/defender when the wrong owner's colony gets picked — the
// "defender" becomes empire b regardless, so if the real defender (say,
// the human, with no fleet of their own at this star) gets miscast as the
// ATTACKER instead, `!attacker.ships.length` trips below and this returns
// null every time: a battle that silently never happens, no error, for as
// long as the hostile fleet sits there (Brian's ask, 2026-08-14 — this is
// the exact same class of bug bombard()/invade() already had fixed via
// targetColonyAt(), just never applied here).
//
// It is also possible for BOTH a and b to have a colony at this star
// (three of the hostile empire's worlds and one of the human's, all
// sharing one system, is exactly the shape that surfaced this bug) — in
// that case prefer whichever side's colony has no fleet of its OWN present
// here. That side is the one actually under siege by planetary defense
// alone; the other side's colony, sitting behind its own fleet, isn't the
// one this particular clash is about. If both or neither qualify (a rarer,
// genuinely ambiguous shape), fall back to whichever comes first.
const colonyCandidates = coloniesAt(state, starIdx).filter((c) => c.empireIdx === a || c.empireIdx === b);
const noFleetOfOwnHere = (c) => !state.fleets.some((f) => f.starIdx === starIdx
&& f.empireIdx === c.empireIdx && f.ships.length);
const colony = colonyCandidates.length <= 1
? (colonyCandidates[0] ?? null)
: (colonyCandidates.find(noFleetOfOwnHere) ?? colonyCandidates[0]);
const defenderIdx = colony && (colony.empireIdx === a || colony.empireIdx === b) ? colony.empireIdx : b;
const attackerIdx = defenderIdx === a ? b : a;
@ -1539,23 +1598,67 @@ 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);
// A fleet with no warship-role ship in it — pure troop transports
// (role 'troops') or colony ships (role 'colony') — can never fire a
// shot: bombard() already refuses them (it sums only warship-role
// damage), and an invasion is a completely separate direct resolution
// (invade()/VegaAI.js's own forecast-gated decision) that never goes
// through this battle system at all. Counting one as a "foe" here used
// to prepare a real (non-null) battle purely because
// prepareBattleAt's attacker.ships.length check only counts hulls, not
// combat capability — so a colony sitting under a transport-only siege
// got an Under Attack notice (and often several, once per AI empire's
// move that turn) for a fight that could never do anything to it
// (Brian's ask, 2026-08-14).
if (g.starIdx === starIdx && g.empireIdx !== e && atWar(state, e, g.empireIdx)
&& g.ships.some((s) => rules.hulls[s.hullId]?.role === 'warship' && s.count > 0)) {
foes.add(g.empireIdx);
}
}
const colony = colonyAt(state, f.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 });
// Same colonyAt()-is-owner-blind trap prepareBattleAt just got fixed for
// (Brian's ask, 2026-08-14): a star can host colonies from more than one
// empire, so this has to search all of them for one that's actually
// hostile to e, not just check whichever one colonyAt() handed back.
const colony = coloniesAt(state, starIdx)
.find((c) => c.empireIdx !== e && atWar(state, e, c.empireIdx) && c.defenseHp > 0);
if (colony) foes.add(colony.empireIdx);
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. Gated on
// defenseHp > 0 (Brian's ask, 2026-08-14): once those batteries are
// already down to zero AND the loop above found no fleet of e's own at
// this star either, there is nothing left to defend with, so this is not
// actually a battle — prepareBattleAt already returns null for exactly
// this case (no defender ships and no positive defenseHp), which used to
// mean a hostile fleet simply sitting in orbit of an already-beaten colony
// popped an "Under Attack" notice every single turn it lingered, with
// nothing ever shown after the player clicked through. Any real
// consequence of that fleet sitting there (bombardment, invasion) is
// reported separately by VegaBombardScreen.js's popup and the turn
// report — this pending-battle entry was never the thing carrying that
// news, just a dead-end prompt in front of it.
for (const c of empireColonies(state, e)) {
if (c.defenseHp > 0) addStar(c.starIdx);
}
return out;
}
@ -1753,17 +1856,55 @@ export function bombardedThisTurn(state, e, colony) {
// attacker fleet at that star regardless of which one was passed in, so an
// AI with two fleets at one siege was double-charging the same pooled
// damage before this cap existed.
// Buildings whose entire job is standing between the colony and an attacking
// fleet — more resistant to bombardment-destruction than an automated
// factory or research lab, since they're already absorbing the hit.
const DEFENSIVE_BUILDING_IDS = new Set(['missilebase', 'groundbattery', 'planetaryshield', 'artemisnet']);
// Planet-type conversion table for a bombardment run that wipes a colony to
// zero population. Rolled once, keyed off the highest Mark warship that
// contributed damage to this strike — "the weapon tech used against this
// colony" is exactly what design.mark already folds in (VegaShips.js's
// markFor). Checked in this fixed order; the first hit wins and the rest are
// skipped, so at most one conversion ever happens. A row is naturally a
// no-op below its minMark (mark < minMark short-circuits before any roll),
// so Mark I bombardment that wipes a colony never converts the planet.
const PLANET_DESTRUCTION_TABLE = [
{ typeId: 'asteroids', minMark: 6, base: 0.25, step: 0.25 },
{ typeId: 'dead', minMark: 4, base: 0.25, step: 0.20 },
{ typeId: 'radiated', minMark: 2, base: 0.10, step: 0.25 },
{ typeId: 'toxic', minMark: 2, base: 0.10, step: 0 },
];
// Exported (alongside bombard() itself) purely so tools/verifyMasterOfVega.js
// can sample this table's distribution directly instead of fighting bombard's
// full precondition/side-effect machinery for thousands of trials.
export function rollPlanetDestruction(state, mark) {
for (const row of PLANET_DESTRUCTION_TABLE) {
if (mark < row.minMark) continue;
const chance = Math.min(1, row.base + row.step * (mark - row.minMark));
if (rand(state) < chance) return row.typeId;
}
return null;
}
export function bombard(rules, state, e, starIdx, orbit = null) {
const colony = targetColonyAt(state, e, starIdx, orbit);
if (!colony || colony.empireIdx === e) return null;
if (!atWar(state, e, colony.empireIdx)) return null;
if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null;
// Captured before any wipeout conversion below can mutate it — the popup
// uses this to pick a bombardment video for the world AS IT LOOKED going
// into the attack, not what it may have just become.
const planetTypeId = planetOf(state, colony).typeId;
const emp = state.empires[e];
if (bombardedThisTurn(state, e, colony)) return null;
const spec = rules.species[emp.speciesId];
let damage = 0;
let cracker = false;
let maxMark = 0;
for (const f of state.fleets) {
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
for (const st of f.ships) {
@ -1771,6 +1912,7 @@ export function bombard(rules, state, e, starIdx, orbit = null) {
if (d.role !== 'warship') continue;
damage += d.damage * st.count;
if (d.planetCracker) cracker = true;
if (d.mark > maxMark) maxMark = d.mark;
}
}
if (damage <= 0) return null;
@ -1785,22 +1927,68 @@ export function bombard(rules, state, e, starIdx, orbit = null) {
// A Stellar Converter cracks the crust; nothing is left to invade.
if (cracker) colony.pop = Math.max(0, colony.pop - kill);
// Population loss is only half the story — the same strike also batters
// the ground infrastructure, scaled by how much of the colony it just
// erased (0 for a scratch, up to 1 for a full wipeout).
const killRatio = Math.min(1, (before - colony.pop) / Math.max(1, before));
const factoriesBefore = colony.factories;
const factoriesLost = Math.round(colony.factories * killRatio * (rules.combat.bombardFactoryKillMult ?? 1));
colony.factories = Math.max(0, colony.factories - factoriesLost);
const buildingsBefore = colony.buildings.length;
const buildingsDestroyed = [];
colony.buildings = colony.buildings.filter((id) => {
const mult = DEFENSIVE_BUILDING_IDS.has(id)
? (rules.combat.bombardDefensiveBuildingDestroyMult ?? 0.25)
: (rules.combat.bombardBuildingDestroyMult ?? 1);
if (rand(state) < killRatio * mult) { buildingsDestroyed.push(id); return false; }
return true;
});
const destroyed = colony.pop < 1;
let planetConverted = null;
if (destroyed) {
planetConverted = rollPlanetDestruction(state, maxMark);
if (planetConverted) {
const planet = planetOf(state, colony);
const type = rules.planetTypes[planetConverted];
planet.typeId = planetConverted;
planet.basePop = Math.round(rules.planetSizes[planet.sizeId].basePop * type.habitability);
}
}
const buildingsAfter = colony.buildings.length;
// popAfter is pinned to exactly 0 on a wipeout for the results bar's sake —
// colony.pop itself is only floored below 1, not to 0, and the colony
// object is about to be discarded either way.
const popAfter = destroyed ? 0 : colony.pop;
pushEvent(state, {
type: 'bombard', empire: e, target: colony.empireIdx, starIdx,
killed: Math.round(before - colony.pop), cracker, turn: state.turn,
factoriesLost, buildingsDestroyed, maxMark, destroyed, planetConverted, planetTypeId,
popBefore: before, popAfter, factoriesBefore, factoriesAfter: colony.factories,
buildingsBefore, buildingsAfter,
});
if (colony.pop < 1) {
if (destroyed) {
const owner = colony.empireIdx;
state.colonies = state.colonies.filter((c) => c !== colony);
pushEvent(state, { type: 'colonyDestroyed', empire: e, target: owner, starIdx, turn: state.turn });
recomputeTotals(rules, state);
checkLastColony(state, owner, e);
checkElimination(rules, state, owner, e);
return { destroyed: true, killed: Math.round(before) };
return {
destroyed: true, killed: Math.round(before), factoriesLost, buildingsDestroyed, planetConverted, planetTypeId,
popBefore: before, popAfter, factoriesBefore, factoriesAfter: colony.factories, buildingsBefore, buildingsAfter,
};
}
recomputeTotals(rules, state);
return { destroyed: false, killed: Math.round(before - colony.pop) };
return {
destroyed: false, killed: Math.round(before - colony.pop), factoriesLost, buildingsDestroyed,
planetConverted: null, planetTypeId,
popBefore: before, popAfter, factoriesBefore, factoriesAfter: colony.factories, buildingsBefore, buildingsAfter,
};
}
// What a landing at this system would look like right now. Exported because
@ -1858,6 +2046,9 @@ export function invade(rules, state, e, starIdx, orbit = null) {
if (!fleet) return null;
const stack = fleet.ships.find((s) => s.hullId === 'transport' && s.count > 0);
// For the results popup's video pick — same convention as bombard().
const planetTypeId = planetOf(state, colony).typeId;
const emp = state.empires[e];
const spec = rules.species[emp.speciesId];
const comps = empireComponents(rules, state, e);
@ -1882,16 +2073,22 @@ export function invade(rules, state, e, starIdx, orbit = null) {
colony.pop = Math.max(1, colony.pop * 0.5);
colony.queue = [];
colony.defenseHp = 0;
pushEvent(state, { type: 'captured', empire: e, from, starIdx, colonyId: colony.id, turn: state.turn });
pushEvent(state, {
type: 'captured', empire: e, from, starIdx, colonyId: colony.id, turn: state.turn,
troops, attackersLeft: result.attackersLeft, defendersLeft: result.defendersLeft, planetTypeId,
});
state.empires[from].attitude[e] = Math.max(-100, Math.min(100, (state.empires[from].attitude[e] ?? 0) - 40));
state.empires[e]._range = null; state.empires[e]._rangeAt = -1;
checkLastColony(state, from, e);
checkElimination(rules, state, from, e);
} else {
pushEvent(state, { type: 'invasionFailed', empire: e, starIdx, turn: state.turn });
pushEvent(state, {
type: 'invasionFailed', empire: e, defenderIdx: colony.empireIdx, starIdx, turn: state.turn,
troops, attackersLeft: result.attackersLeft, defendersLeft: result.defendersLeft, planetTypeId,
});
}
recomputeTotals(rules, state);
return result;
return { ...result, troops, planetTypeId };
}
// Pushed the instant an attack (capture or bombardment) leaves the victim
@ -2061,10 +2258,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);
}
@ -2847,6 +3051,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;
@ -2861,6 +3073,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

@ -20,6 +20,7 @@ import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js';
import { planetFrame, starFrame } from './VegaArt.js';
import { openColonyView, CHANNEL_COLOUR, itemName, etaText } from './VegaColonyView.js';
import { openColonyIntro, ensureColonyVideo, promptColonyName } from './VegaColonyIntro.js';
import { openBombardPopup } from './VegaBombardScreen.js';
import {
CHANNELS, coloniesAt, canColonize, colonize, colonyMaxPop, colonyProduction,
colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyBuildRate,
@ -236,22 +237,25 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
// but-lost invasion (every troop consumed either way — see invade()
// in VegaLogic.js) were visually identical to nothing happening at
// all, since the only record was a turn-report event the player
// wouldn't see until ending their turn. scene.log() surfaces the
// outcome immediately in the same ticker the rest of the HUD uses.
// wouldn't see until ending their turn. A refusal still surfaces
// immediately via scene.log() in the ticker; an action that actually
// fires now opens VegaBombardScreen.js's results popup instead, since
// there is real outcome data (kills, factories/buildings lost, a
// captured or ruined world) worth a proper reveal, not a log line.
// Bombard is capped at once per colony per turn (VegaLogic.js's
// lastBombardTurn) — disabled here rather than hidden, since it's a
// temporary "wait for next turn" state, not a structural
// unavailability like Invade's "no transports" below.
const defenderIdx = colony.empireIdx;
const bombardBtn = new Button(scene, panelX + 120, y + 20, 'Bombard', uiClick(scene, () => {
const result = bombard(rules, state, viewerIdx, starIdx, colony.orbit);
if (!result) {
scene.log?.('Bombard failed — you need orbital superiority and warships with a working weapon.');
} else if (result.destroyed) {
scene.log?.(`Bombarded the ${owner.name} colony — ${result.killed} killed, the colony was wiped out.`);
} else {
scene.log?.(`Bombarded the ${owner.name} colony — ${result.killed} killed.`);
return;
}
onChanged?.(); rebuild();
openBombardPopup(scene, rules, state, {
kind: 'bombard', attackerIdx: viewerIdx, defenderIdx, starIdx, result,
}, () => { onChanged?.(); rebuild(); });
}), { width: 220, height: 42, bg: 0x6b2230 });
if (alreadyBombarded) bombardBtn.setEnabled(false);
console_.add(bombardBtn);
@ -260,12 +264,11 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
const result = invade(rules, state, viewerIdx, starIdx, colony.orbit);
if (!result) {
scene.log?.('Invasion could not be launched — you need orbital superiority and a troop transport in orbit.');
} else if (result.captured) {
scene.log?.(`Invasion of the ${owner.name} colony succeeded — ${result.attackersLeft} marines secured it.`);
} else {
scene.log?.(`Invasion of the ${owner.name} colony failed — all marines were lost.`);
return;
}
onChanged?.(); rebuild();
openBombardPopup(scene, rules, state, {
kind: 'invade', attackerIdx: viewerIdx, defenderIdx, starIdx, result,
}, () => { onChanged?.(); rebuild(); });
}), { width: 220, height: 42 }));
}
}

View File

@ -8,8 +8,17 @@ import { habitableForEmpire, recommendColonyFocus, recommendAllocationFocus } fr
import { describeTechEffects } from './VegaTechEffects.js';
// Events worth interrupting the player for. Everything else (refit,
// spyCaught, techStolen, invasionFailed, leaderHired, victory — which
// already has its own showVictoryOverlay) stays in the small ticker log only.
// spyCaught, techStolen, leaderHired, victory — which already has its own
// showVictoryOverlay) stays in the small ticker log only.
//
// `bombard` and `invasionFailed` were ticker-only until the Bombard/Invade
// results popup (VegaBombardScreen.js) needed a way to reach the player when
// an AI empire is the one doing the attacking — see the notable-events walk
// in MasterOfVegaGame.js's runToHumanTurn(), which opens that popup for any
// notable bombard/captured/invasionFailed event where the human is the
// defender. Being notable here ALSO gives them this turn report's row, which
// is deliberately kept (rather than suppressed) so a scan of the report
// alone still tells the whole story.
//
// `colonised` is deliberately NOT here. Founding a colony gets its own
// full-screen vignette the instant it happens (VegaColonyIntro.js), and a row
@ -28,7 +37,7 @@ import { describeTechEffects } from './VegaTechEffects.js';
export const NOTABLE_TYPES = new Set([
'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', 'captured',
'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'tradeAgreementFormed',
'councilRefused', 'eliminated', 'advisorRecommendation',
'councilRefused', 'eliminated', 'advisorRecommendation', 'bombard', 'invasionFailed',
]);
// Exploring, researching, founding a colony and finishing a build-queue item
@ -66,6 +75,8 @@ export const TYPE_LABEL = {
councilRefused: 'Council Refused',
eliminated: 'Empire Eliminated',
advisorRecommendation: 'Advisor Recommendation',
bombard: 'Bombardment',
invasionFailed: 'Invasion Repelled',
};
// Sort weight — diplomacy/territory/council news first (most consequential),
@ -231,6 +242,34 @@ function describeColonyDestroyed(rules, state, ev, name) {
return { headline, lines: [] };
}
function describeBombard(rules, state, ev, name) {
const me = state.humanIndex;
const star = state.galaxy.stars[ev.starIdx];
let headline;
if (ev.empire === me) headline = `We bombard the ${name(ev.target)} colony at ${star.name}${ev.killed} killed.`;
else if (ev.target === me) headline = `The ${name(ev.empire)} bombard our colony at ${star.name}${ev.killed} killed.`;
else headline = `The ${name(ev.empire)} bombard the ${name(ev.target)} colony at ${star.name}.`;
const lines = [];
if (ev.factoriesLost) lines.push(line(`${ev.factoriesLost} factories destroyed.`, '#e0b08a'));
if (ev.buildingsDestroyed?.length) {
lines.push(line(`Buildings lost: ${ev.buildingsDestroyed.map((id) => rules.buildings[id]?.name ?? id).join(', ')}`, '#e0b08a'));
}
if (ev.planetConverted) {
lines.push(line(`The world's surface is now ${rules.planetTypes[ev.planetConverted]?.name ?? ev.planetConverted}.`, '#ffb27a'));
}
return { headline, lines };
}
function describeInvasionFailed(rules, state, ev, name) {
const me = state.humanIndex;
const star = state.galaxy.stars[ev.starIdx];
let headline;
if (ev.empire === me) headline = `Our invasion of ${star.name} failed — every marine was lost.`;
else if (ev.defenderIdx === me) headline = `The ${name(ev.empire)}'s invasion of our colony at ${star.name} was repelled.`;
else headline = `The ${name(ev.empire)}'s invasion of ${star.name} failed.`;
return { headline, lines: [] };
}
function describeTreaty(rules, state, ev, name, verb) {
const me = state.humanIndex;
let headline;
@ -269,6 +308,8 @@ export function describeEvent(rules, state, ev) {
case 'contact': out = describeContact(rules, state, ev, name); break;
case 'captured': out = describeCaptured(rules, state, ev, name); break;
case 'colonyDestroyed': out = describeColonyDestroyed(rules, state, ev, name); break;
case 'bombard': out = describeBombard(rules, state, ev, name); break;
case 'invasionFailed': out = describeInvasionFailed(rules, state, ev, name); break;
case 'warDeclared': out = describeTreaty(rules, state, ev, name, 'declare war on'); break;
case 'peace': out = describeTreaty(rules, state, ev, name, 'make peace with'); break;
case 'alliance': out = describeTreaty(rules, state, ev, name, 'form an alliance with'); break;

View File

@ -227,6 +227,11 @@ const config = {
};
const game = new Phaser.Game(config);
// Devtools console hook — Phaser itself doesn't track running instances
// anywhere reachable from outside the module, so there is no way to get back
// to a live game/scene/state from the console without this. Harmless in a
// locally-served dev build; not something a production site would want.
window.game = game;
// Phaser positions its DOM overlay container with margins only (no explicit
// left/top); under Scale.FIT the absolutely-positioned container drifts away

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

@ -42,7 +42,7 @@ import {
import {
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey,
colonyVideoKey, audienceVideoKey, sourceWidth, advisorVideoKey,
colonyVideoKey, audienceVideoKey, sourceWidth, advisorVideoKey, bombardVideoKey, invadeVideoKey,
} from '../src/games/mastervega/VegaArt.js';
import { CHAT } from '../src/games/mastervega/VegaChat.js';
// Turn-report classification is Phaser-free, so what the "New Turn" popup will
@ -82,6 +82,31 @@ section('1. Rules integrity');
check('six tech fields', RULES.techFieldList.length === 6, `${RULES.techFieldList.length}`);
check('ten species', RULES.speciesList.length === 10, `${RULES.speciesList.length}`);
// bombardHoldFireChance (Brian's ask, 2026-08-14): VegaAI.js's bombard step
// rolls this per turn to decide whether a species holds fire while an
// invasion of the same colony is staged. Every species must declare it
// explicitly (no silent `undefined` defaulting to some behavior by
// accident), it must be a valid probability, and the three species it was
// actually tuned for should sit where the design says they do — 0 for the
// two annihilators, high (but never 1 — see VegaAI.js's own comment on why
// a fortress siege must still resolve eventually) for Ursaal, the 0.65
// default for everyone else.
for (const s of RULES.speciesList) {
const v = s.traits.bombardHoldFireChance;
check(`species ${s.id} declares bombardHoldFireChance explicitly`, typeof v === 'number');
check(`species ${s.id} bombardHoldFireChance is a valid probability`, v >= 0 && v < 1, `${v}`);
}
const annihilators = RULES.speciesList.filter((s) => s.traits.bombardHoldFireChance === 0).map((s) => s.id).sort();
check('exactly rrashaa and lithox bomb as readily as before this trait existed (chance 0)',
JSON.stringify(annihilators) === JSON.stringify(['lithox', 'rrashaa']), JSON.stringify(annihilators));
check('ursaal holds fire far more than the 0.65 default, reflecting their ground-combat identity',
RULES.species.ursaal.traits.bombardHoldFireChance > 0.85, `${RULES.species.ursaal.traits.bombardHoldFireChance}`);
const defaultRate = RULES.speciesList.filter((s) => !['ursaal', 'rrashaa', 'lithox'].includes(s.id));
for (const s of defaultRate) {
check(`species ${s.id} sits at the 0.65 default (not specially tuned)`,
s.traits.bombardHoldFireChance === 0.65, `${s.traits.bombardHoldFireChance}`);
}
// Every tech ranked (proves the chains are acyclic and fully reachable).
check('every tech is ranked', Object.keys(RULES.techRank).length === RULES.techList.length);
for (const t of RULES.techList) {
@ -684,6 +709,51 @@ section('2. Procedural art');
console.log(` (${colClips}/${colonisableTotal} colony-founding clips recorded; `
+ 'the rest fall back to the world backdrop)');
// Bombard/Invade popup clips (VegaBombardScreen.js), one per COLONISABLE
// planet type. A type with no clip falls back to the 'terran' entry (that
// fallback itself must exist, or VegaBombardScreen.js's popup falls all
// the way through to no video at all) rather than to a still image — same
// reasoning as colonyVideos above for the key-matches-the-loader check.
const bombVids = artJson.bombardVideos ?? {};
const bombVidIds = Object.keys(bombVids).filter((k) => !k.startsWith('_'));
check('bombardVideos has a terran entry for the fallback path to resolve to',
bombVidIds.includes('terran') && !!bombVids.terran?.path);
for (const id of bombVidIds) {
check(`bombardVideos entry ${id} is a known planet type`, !!RULES.planetTypes[id]);
check(`bombardVideos ${id} is a colonisable type`, !!RULES.planetTypes[id]?.colonizable);
check(`bombardVideos ${id} key matches the loader's key`,
bombVids[id]?.key === bombardVideoKey(id), `${bombVids[id]?.key} vs ${bombardVideoKey(id)}`);
if (bombVids[id]?.path) {
check(`bombardVideos ${id} file exists`, existsSync(join(root, bombVids[id].path)),
bombVids[id].path);
check(`bombardVideos ${id} is an mp4`, bombVids[id].path.endsWith('.mp4'));
}
}
console.log(` (${bombVidIds.length}/${colonisableTotal} bombardment clips recorded; `
+ "the rest fall back to 'terran'");
// Invasion popup clips (VegaBombardScreen.js), one per SPECIES (the
// invading empire's, not the target planet's type) — a separate video set
// from bombardVideos above. Every species currently points at a
// placeholder (Brian's ask, 2026-08-14: no real invasion footage exists
// yet), so unlike bombardVideos there is no "recorded vs gap" split to
// report — only that every species has an entry and every key matches.
const invadeVids = artJson.invadeVideos ?? {};
const invadeVidIds = Object.keys(invadeVids).filter((k) => !k.startsWith('_'));
for (const id of invadeVidIds) {
check(`invadeVideos entry ${id} is a known species`, !!RULES.species[id]);
check(`invadeVideos ${id} key matches the loader's key`,
invadeVids[id]?.key === invadeVideoKey(id), `${invadeVids[id]?.key} vs ${invadeVideoKey(id)}`);
if (invadeVids[id]?.path) {
check(`invadeVideos ${id} file exists`, existsSync(join(root, invadeVids[id].path)),
invadeVids[id].path);
check(`invadeVideos ${id} is an mp4`, invadeVids[id].path.endsWith('.mp4'));
}
}
for (const s of RULES.speciesList) {
check(`species ${s.id} has an invadeVideos entry`, invadeVidIds.includes(s.id));
}
// Colonies-screen advisor loops (VegaColoniesScreen.js), one per species.
// Declared-or-null like shipVideos/audienceVideos: a gap is legal (falls
// back to the species portrait), but a key that does not match
@ -1515,6 +1585,205 @@ section('4c. Population transport');
}
}
// ---------------------------------------------------------------------------
section('4c2. Bombardment consequences — factories, buildings, planet ruin');
// ---------------------------------------------------------------------------
// Brian's ask (2026-08-14): a bombardment run that wipes a colony out now
// carries escalating risk of ruining the planet itself (toxic/radiated/
// dead/asteroids), scaled by the highest weapon Mark in the attacking fleet,
// and every bombardment (wipeout or not) now also damages factories and
// buildings proportional to the same population-kill ratio.
{
// rollPlanetDestruction(state, mark) is a pure function of a PRNG state and
// an integer mark — sampled directly (not through bombard()'s full
// precondition/side-effect machinery) so thousands of trials per Mark are
// cheap. Table, from Brian's spec: checked in order Asteroids -> Dead ->
// Radiated -> Toxic, first hit wins, so each row's own "chance" is
// CONDITIONAL on every earlier row having missed — that conditional rate
// is exactly what these checks measure.
function sampleConversion(mark, trials) {
const counts = {
asteroids: 0, dead: 0, radiated: 0, toxic: 0, none: 0,
};
const rngState = { rngState: (mark * 104729 + 17) | 0 };
for (let i = 0; i < trials; i += 1) {
counts[Logic.rollPlanetDestruction(rngState, mark) ?? 'none'] += 1;
}
return counts;
}
const pct = (n, d) => (d > 0 ? (100 * n) / d : 0);
const near = (observed, expected, tolPP) => Math.abs(observed - expected) <= tolPP;
const TRIALS = 20000;
const TOL = 3; // percentage points — generous given TRIALS and a fixed p
const markI = sampleConversion(1, TRIALS);
check('Mark I bombardment that wipes a colony never converts the planet',
markI.none === TRIALS, JSON.stringify(markI));
// [mark, expected radiated row-chance %, expected dead row-chance %, expected asteroids row-chance %]
const TABLE = [
[2, 10, 0, 0], [3, 35, 0, 0], [4, 60, 25, 0], [5, 85, 45, 0], [6, 100, 65, 25], [7, 100, 85, 50],
];
for (const [mark, expRadiated, expDead, expAsteroids] of TABLE) {
const c = sampleConversion(mark, TRIALS);
const label = markNumeral(mark);
const asteroidsRate = pct(c.asteroids, TRIALS);
if (mark >= 6) {
check(`Mark ${label} asteroids row-chance ~= ${expAsteroids}% (min(100, 25+25*(mark-6)))`,
near(asteroidsRate, expAsteroids, TOL), `observed ${asteroidsRate.toFixed(1)}%`);
} else {
check(`Mark ${label} asteroids never rolls below its Mark VI threshold`, c.asteroids === 0);
}
const consideredForDead = c.dead + c.radiated + c.toxic + c.none; // passed the asteroids check
const deadRate = pct(c.dead, consideredForDead);
if (mark >= 4) {
check(`Mark ${label} dead row-chance ~= ${expDead}% (min(100, 25+20*(mark-4)))`,
near(deadRate, expDead, TOL), `observed ${deadRate.toFixed(1)}%`);
} else {
check(`Mark ${label} dead never rolls below its Mark IV threshold`, c.dead === 0);
}
const consideredForRadiated = c.radiated + c.toxic + c.none; // passed asteroids and dead
const radiatedRate = pct(c.radiated, consideredForRadiated);
check(`Mark ${label} radiated row-chance ~= ${expRadiated}% (min(100, 10+25*(mark-2)))`,
near(radiatedRate, expRadiated, TOL), `observed ${radiatedRate.toFixed(1)}%`);
}
// Toxic's row-chance is flat 10% regardless of mark (no step) — measurable
// wherever asteroids/dead/radiated all missed. Mark II is where that
// remainder is largest (least contested by the other three), so it gives
// the tightest signal.
{
const c = sampleConversion(2, TRIALS);
const consideredForToxic = c.toxic + c.none;
const toxicRate = pct(c.toxic, consideredForToxic);
check('toxic row-chance stays flat ~10% (no per-mark scaling)',
near(toxicRate, 10, TOL), `observed ${toxicRate.toFixed(1)}%`);
}
// The cap: radiated's own formula already reaches/exceeds 100% by Mark VI
// (10 + 25*4 = 110), so — once capped at 100% of whatever's left after
// asteroids/dead — toxic is mathematically unreachable from Mark VI on.
// This is the clearest signature that the min(1, ...) cap is actually
// biting rather than silently rolling a probability above 1.
{
const c6 = sampleConversion(6, TRIALS);
check('at Mark VI the radiated cap already consumes the whole remainder — toxic never fires',
c6.toxic === 0, JSON.stringify(c6));
}
}
{
// Factory/building damage: built through the real Logic.bombard() (not a
// sampled pure function) since the killRatio it produces depends on the
// engine's actual damage/shield math. Same attacker fleet and starting pop
// every trial keeps kill — and therefore killRatio — constant, so any
// spread across trials is purely each building's own destruction roll.
const stF = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'cluster', seed: 8181, difficultyId: 'normal',
speciesIds: ['human', 'kkrix'], humanIndex: 0,
});
stF.rules = RULES;
let starIdxF = -1;
for (let i = 0; i < stF.galaxy.stars.length; i += 1) {
if ((stF.galaxy.stars[i].planets?.length ?? 0) >= 1 && !stF.galaxy.homeIdx.includes(i)) { starIdxF = i; break; }
}
check('factory/building damage test galaxy has a spare star', starIdxF >= 0);
if (starIdxF >= 0) {
// Small enough that 12 mark-1 frigates land a mid-range killRatio (not
// a scratch, not a wipeout) — calibrated empirically, since kill is a
// fixed absolute number independent of pop and this fleet/tech combo
// kills ~4-5 population points per strike.
const FIXTURE_POP = 15;
const target = Logic.foundColony(RULES, stF, 1, starIdxF, 0, FIXTURE_POP);
Diplo.declareWar(RULES, stF, 0, 1);
Logic.addFleet(RULES, stF, 0, starIdxF, [{ hullId: 'frigate', count: 12, mark: 1 }]);
const allBuildingIds = RULES.buildingList.map((b) => b.id);
const FACTORIES_BASE = 20;
const resetFixture = () => {
target.pop = FIXTURE_POP;
target.factories = FACTORIES_BASE;
target.buildings = [...allBuildingIds];
stF.empires[0].lastBombardTurn[target.id] = -1;
};
// Deterministic: factory loss has no rand() in it at all.
resetFixture();
const popBefore = target.pop;
const r1 = Logic.bombard(RULES, stF, 0, starIdxF);
check('bombard-consequences fixture actually lands and does not destroy the colony',
!!r1 && !r1.destroyed, JSON.stringify(r1));
const killRatio = r1 ? (popBefore - target.pop) / popBefore : 0;
check('fixture kill ratio is in a usable mid-range for the statistical checks below',
killRatio > 0.15 && killRatio < 0.85, `killRatio ${killRatio.toFixed(3)}`);
const expectedFactoriesLost = Math.round(FACTORIES_BASE * killRatio * (RULES.combat.bombardFactoryKillMult ?? 1));
check('factoriesLost matches killRatio * bombardFactoryKillMult exactly',
r1.factoriesLost === expectedFactoriesLost,
`got ${r1.factoriesLost} expected ${expectedFactoriesLost} (killRatio ${killRatio.toFixed(3)})`);
const DEFENSIVE_IDS = new Set(['missilebase', 'groundbattery', 'planetaryshield', 'artemisnet']);
const economicIds = allBuildingIds.filter((id) => !DEFENSIVE_IDS.has(id));
const defensiveIds = allBuildingIds.filter((id) => DEFENSIVE_IDS.has(id));
const destroyedCount = {};
for (const id of allBuildingIds) destroyedCount[id] = 0;
const TRIALS_B = 500;
for (let i = 0; i < TRIALS_B; i += 1) {
resetFixture();
const res = Logic.bombard(RULES, stF, 0, starIdxF);
for (const id of res?.buildingsDestroyed ?? []) destroyedCount[id] += 1;
}
const econRate = economicIds.reduce((t, id) => t + destroyedCount[id], 0) / (economicIds.length * TRIALS_B);
const defRate = defensiveIds.reduce((t, id) => t + destroyedCount[id], 0) / (defensiveIds.length * TRIALS_B);
const expectEcon = killRatio * (RULES.combat.bombardBuildingDestroyMult ?? 1);
const expectDef = killRatio * (RULES.combat.bombardDefensiveBuildingDestroyMult ?? 0.25);
check('economic buildings are destroyed at ~killRatio * bombardBuildingDestroyMult',
Math.abs(econRate - expectEcon) < 0.08, `observed ${(econRate * 100).toFixed(1)}% expected ~${(expectEcon * 100).toFixed(1)}%`);
check('defensive buildings are destroyed at ~killRatio * bombardDefensiveBuildingDestroyMult',
Math.abs(defRate - expectDef) < 0.08, `observed ${(defRate * 100).toFixed(1)}% expected ~${(expectDef * 100).toFixed(1)}%`);
check('defensive buildings are meaningfully more resistant to bombardment than economic ones',
defRate < econRate, `defensive ${(defRate * 100).toFixed(1)}% vs economic ${(econRate * 100).toFixed(1)}%`);
// The results popup (VegaBombardScreen.js) picks its video off
// result.planetTypeId — worth confirming it is the colony's REAL planet
// type, captured before any wipeout conversion could have overwritten it.
check('bombard() result carries the colony\'s actual planet typeId',
r1.planetTypeId === stF.galaxy.stars[starIdxF].planets[target.orbit].typeId,
`${r1.planetTypeId} vs ${stF.galaxy.stars[starIdxF].planets[target.orbit].typeId}`);
}
}
{
// describeEvent() must hit the real 'bombard'/'invasionFailed' cases (added
// alongside NOTABLE_TYPES membership above), not the generic default
// fallback (`{ headline: ev.type, lines: [] }`) — a fallback headline would
// just be the literal string "bombard" and never mention who or where.
const stD = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'cluster', seed: 2323, difficultyId: 'normal',
speciesIds: ['human', 'kkrix'], humanIndex: 0,
});
const bombardEv = {
type: 'bombard', empire: 1, target: 0, starIdx: stD.galaxy.homeIdx[0], killed: 5,
cracker: false, turn: 1, factoriesLost: 2, buildingsDestroyed: ['researchlab'],
maxMark: 3, destroyed: false, planetConverted: null,
};
const bombardDesc = describeEvent(RULES, stD, bombardEv);
check('describeEvent(bombard) does not fall through to the generic default',
bombardDesc.headline !== 'bombard', bombardDesc.headline);
check('describeEvent(bombard) mentions the factories/buildings lost',
bombardDesc.lines.some((l) => l.text.includes('factories'))
&& bombardDesc.lines.some((l) => l.text.includes('researchlab') || l.text.includes('Research Lab')),
JSON.stringify(bombardDesc.lines));
const invasionFailedEv = {
type: 'invasionFailed', empire: 1, defenderIdx: 0, starIdx: stD.galaxy.homeIdx[0], turn: 1,
troops: 20, attackersLeft: 0, defendersLeft: 3,
};
const invFailDesc = describeEvent(RULES, stD, invasionFailedEv);
check('describeEvent(invasionFailed) does not fall through to the generic default',
invFailDesc.headline !== 'invasionFailed', invFailDesc.headline);
}
// ---------------------------------------------------------------------------
section('4d. Sensor scan range');
// ---------------------------------------------------------------------------
@ -1662,6 +1931,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);
@ -1695,6 +1997,296 @@ 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 finds a colony-with-no-fleet-of-its-own under siege
// (previously only found stars where e had a FLEET of its own present) as
// long as it still has SOME planetary defense to actually fight with. Once
// that defense is ground down to zero and there is still no fleet of e's
// own there, it stops showing up — prepareBattleAt returns null for
// exactly that case (no defender ships, no positive defenseHp), so
// surfacing it as "pending" only ever produced an Under Attack notice with
// nothing following it, every single turn the hostile fleet lingered
// (Brian's ask, 2026-08-14).
{
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) {
const colony = Logic.foundColony(RULES, st, 0, otherStar, 0, 200);
colony.defenseHp = 20; // a freshly founded colony starts at 0 — give it batteries to fight with
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('a colony under siege with defenseHp remaining shows up as a pending battle',
pending.some((p) => p.starIdx === otherStar && p.other === 1), JSON.stringify(pending));
colony.defenseHp = 0;
const pendingAfterDefeat = Logic.pendingBattlesFor(RULES, st, 0);
check('once that colony is down to zero defenseHp with no fleet of its own, it no longer shows up as pending',
!pendingAfterDefeat.some((p) => p.starIdx === otherStar && p.other === 1), JSON.stringify(pendingAfterDefeat));
}
}
// --- a hostile fleet made ENTIRELY of troop transports (role 'troops',
// zero weapon mounts) is not a foe pendingBattlesFor should surface: it
// can never bombard (that already requires warship-role damage) and an
// invasion never goes through this battle system at all (invade()/
// VegaAI.js's forecast-gated decision is a separate direct resolution) —
// so a real prepared battle for one is a foregone, content-free non-event
// that used to produce a fresh Under Attack notice every time this scan
// ran (Brian's ask, 2026-08-14: reported as "4+ attack messages a turn,
// yet nothing happens" against a system with 22 troop transports and no
// warships).
{
const st = mk(['human', 'kkrix']);
const home = st.galaxy.homeIdx[0];
// defenseHp>0 so the colony-loop's addStar actually fires for this star
// (see the previous test) — otherwise this test would pass vacuously,
// never reaching the fleet-foe filter this block exists to check.
Logic.colonyAt(st, home).defenseHp = 20;
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 1, home, [{ hullId: 'transport', mark: 1, count: 22 }]);
check('a transport-only hostile fleet at the human home world does not show up as a pending battle',
!Logic.pendingBattlesFor(RULES, st, 0).some((p) => p.starIdx === home && p.other === 1),
JSON.stringify(Logic.pendingBattlesFor(RULES, st, 0)));
// Control: the same fleet WITH even one warship mixed in still counts —
// this only excludes fleets with zero combat capability, not every
// fleet that happens to be carrying marines.
Logic.addFleet(RULES, st, 1, home, [{ hullId: 'frigate', mark: 1, count: 1 }]);
check('the same fleet with a single warship added back in shows up as a pending battle again',
Logic.pendingBattlesFor(RULES, st, 0).some((p) => p.starIdx === home && p.other === 1),
JSON.stringify(Logic.pendingBattlesFor(RULES, st, 0)));
}
// --- prepareBattleAt must not pick the WRONG colony when a star hosts
// colonies from more than one empire — colonyAt() returns whichever is
// first in state.colonies, with no relation to who's actually fighting
// here. Reported by Brian, 2026-08-14: a system where the hostile empire
// had three colonies and the human had one, all sharing a star; every
// "Under Attack" notice against the human's colony there prepared a
// battle with the human wrongly cast as ATTACKER (with zero ships of
// their own present, since collectShips found the human's real ships at
// the wrong empire's slot), so `!attacker.ships.length` returned null
// every single time — no error, no battle, forever, once per AI empire's
// move each turn.
{
const st = mk(['human', 'kkrix']);
let starIdx = -1;
for (let i = 0; i < st.galaxy.stars.length; i += 1) {
if ((st.galaxy.stars[i].planets?.length ?? 0) >= 2 && !st.galaxy.homeIdx.includes(i)) { starIdx = i; break; }
}
check('a star with 2+ planets exists for the multi-colony test', starIdx >= 0);
if (starIdx >= 0) {
// Kkrix founded FIRST so colonyAt()'s old first-match behaviour would
// have picked theirs, not the human's — same precondition-proving
// trick as the earlier "ally sharing a system" fixture above.
Logic.foundColony(RULES, st, 1, starIdx, 1, 50);
const humanColony = Logic.foundColony(RULES, st, 0, starIdx, 0, 50);
humanColony.defenseHp = 20;
Diplo.declareWar(RULES, st, 0, 1);
Logic.addFleet(RULES, st, 1, starIdx, [{ hullId: 'battleship', mark: 1, count: 3 }]);
const pending = Logic.pendingBattlesFor(RULES, st, 0);
check('the human colony under siege still shows up as pending despite sharing a star with the enemy',
pending.some((p) => p.starIdx === starIdx && p.other === 1), JSON.stringify(pending));
const prepared = Logic.prepareBattleAt(RULES, st, starIdx, 0, 1);
check('prepareBattleAt correctly casts the human as DEFENDER (not attacker) despite the colonyAt() ambiguity',
!!prepared && prepared.defenderIdx === 0 && prepared.attackerIdx === 1, JSON.stringify(prepared));
}
}
// --- 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('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');
// ---------------------------------------------------------------------------