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.
This commit is contained in:
parent
3bad628968
commit
869f041b39
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.
|
|
@ -160,6 +160,37 @@
|
||||||
"radiated": { "key": "vega-colony-radiated", "path": "assets/videos/vega/colony-radiated.mp4" }
|
"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.",
|
"_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": {
|
"audienceVideos": {
|
||||||
"human": {
|
"human": {
|
||||||
|
|
|
||||||
|
|
@ -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." }
|
{ "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": [
|
"species": [
|
||||||
{
|
{
|
||||||
"id": "human", "name": "Human", "adjective": "Human", "plural": "Humans",
|
"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,
|
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.5, "growthMult": 1.0, "ecologyMult": 1.0,
|
||||||
"shipAttack": 0, "shipDefense": 0, "groundAttack": 10, "groundDefense": 10,
|
"shipAttack": 0, "shipDefense": 0, "groundAttack": 10, "groundDefense": 10,
|
||||||
"espionage": 0, "counterEspionage": 0, "diplomacy": 40,
|
"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 }
|
"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,
|
"industryMult": 0.9, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
|
||||||
"shipAttack": 0, "shipDefense": 50, "groundAttack": -10, "groundDefense": -10,
|
"shipAttack": 0, "shipDefense": 50, "groundAttack": -10, "groundDefense": -10,
|
||||||
"espionage": 0, "counterEspionage": 0, "diplomacy": 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 }
|
"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,
|
"industryMult": 1.0, "researchMult": 0.8, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
|
||||||
"shipAttack": 0, "shipDefense": 0, "groundAttack": 50, "groundDefense": 50,
|
"shipAttack": 0, "shipDefense": 0, "groundAttack": 50, "groundDefense": 50,
|
||||||
"espionage": 0, "counterEspionage": 0, "diplomacy": -20,
|
"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 }
|
"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,
|
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
|
||||||
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
|
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
|
||||||
"espionage": 60, "counterEspionage": 30, "diplomacy": -50,
|
"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 }
|
"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,
|
"industryMult": 1.5, "researchMult": 0.8, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
|
||||||
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
|
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
|
||||||
"espionage": -20, "counterEspionage": 0, "diplomacy": -40,
|
"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 }
|
"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,
|
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 2.0,
|
||||||
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
|
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
|
||||||
"espionage": 0, "counterEspionage": 0, "diplomacy": -10,
|
"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 }
|
"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,
|
"industryMult": 1.0, "researchMult": 0.9, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
|
||||||
"shipAttack": 50, "shipDefense": 0, "groundAttack": 0, "groundDefense": -25,
|
"shipAttack": 50, "shipDefense": 0, "groundAttack": 0, "groundDefense": -25,
|
||||||
"espionage": 0, "counterEspionage": 0, "diplomacy": -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 }
|
"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,
|
"industryMult": 1.0, "researchMult": 2.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
|
||||||
"shipAttack": 0, "shipDefense": 0, "groundAttack": -50, "groundDefense": -50,
|
"shipAttack": 0, "shipDefense": 0, "groundAttack": -50, "groundDefense": -50,
|
||||||
"espionage": 0, "counterEspionage": 0, "diplomacy": 0,
|
"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 }
|
"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,
|
"industryMult": 1.0, "researchMult": 0.85, "tradeMult": 1.0, "growthMult": 2.0, "ecologyMult": 1.0,
|
||||||
"shipAttack": 0, "shipDefense": 0, "groundAttack": 10, "groundDefense": 0,
|
"shipAttack": 0, "shipDefense": 0, "groundAttack": 10, "groundDefense": 0,
|
||||||
"espionage": 0, "counterEspionage": 0, "diplomacy": -20,
|
"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 }
|
"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,
|
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 0.5, "ecologyMult": 0.0,
|
||||||
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
|
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
|
||||||
"espionage": -20, "counterEspionage": 20, "diplomacy": -100,
|
"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 }
|
"techAffinity": { "computers": 0.9, "construction": 1.2, "forcefields": 1.1, "planetology": 0.7, "propulsion": 0.9, "weapons": 1.0 }
|
||||||
}
|
}
|
||||||
|
|
@ -503,6 +513,9 @@
|
||||||
"disengageRound": 25,
|
"disengageRound": 25,
|
||||||
"planetDefenseScale": 0.06,
|
"planetDefenseScale": 0.06,
|
||||||
"bombardPopKill": 0.22,
|
"bombardPopKill": 0.22,
|
||||||
|
"bombardFactoryKillMult": 1,
|
||||||
|
"bombardBuildingDestroyMult": 1,
|
||||||
|
"bombardDefensiveBuildingDestroyMult": 0.25,
|
||||||
"groundOddsScale": 0.01,
|
"groundOddsScale": 0.01,
|
||||||
"cloakEvasion": 0.02,
|
"cloakEvasion": 0.02,
|
||||||
"singularityShieldPierce": 0.5
|
"singularityShieldPierce": 0.5
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,10 @@ export const MANIFEST = {
|
||||||
// The Galactic Council session ceremony's persistent anchor visual, same
|
// The Galactic Council session ceremony's persistent anchor visual, same
|
||||||
// 2:3 eager-load treatment as the GNN clip above (VegaCouncilSession.js).
|
// 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' },
|
{ 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 —
|
// The colony-founding cue. It is not part of the shuffled soundtrack —
|
||||||
// VegaColonyIntro.js ducks that and plays this over the vignette instead.
|
// VegaColonyIntro.js ducks that and plays this over the vignette instead.
|
||||||
// Small, and it has to be ready the instant the vignette opens.
|
// Small, and it has to be ready the instant the vignette opens.
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import { openTurnReportScreen } from './VegaTurnReportScreen.js';
|
||||||
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
|
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
|
||||||
import * as Gnn from './VegaGnn.js';
|
import * as Gnn from './VegaGnn.js';
|
||||||
import { openGnnScreen } from './VegaGnnScreen.js';
|
import { openGnnScreen } from './VegaGnnScreen.js';
|
||||||
|
import { openBombardPopup } from './VegaBombardScreen.js';
|
||||||
import { openCouncilSessionScreen } from './VegaCouncilSession.js';
|
import { openCouncilSessionScreen } from './VegaCouncilSession.js';
|
||||||
import { openAudienceScreen } from './VegaAudience.js';
|
import { openAudienceScreen } from './VegaAudience.js';
|
||||||
import { playIntroVideo } from './VegaIntroVideo.js';
|
import { playIntroVideo } from './VegaIntroVideo.js';
|
||||||
|
|
@ -1207,14 +1208,16 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
// so a battle the player fights here and an AI-vs-AI one auto-resolved by
|
// so a battle the player fights here and an AI-vs-AI one auto-resolved by
|
||||||
// resolveCombats() still go through exactly the same prepareBattleAt/
|
// resolveCombats() still go through exactly the same prepareBattleAt/
|
||||||
// applyBattleOutcome code, just with a screen to watch (and, when the
|
// applyBattleOutcome code, just with a screen to watch (and, when the
|
||||||
// human started it, a formation choice). Re-derives Logic.pendingBattlesFor
|
// human has a fleet in this fight, a formation choice — see the
|
||||||
// fresh every call (a no-op if nothing's pending), so it's safe to call
|
// humanIsDefender/humanHasFleetHere gate below). Re-derives
|
||||||
// from more than one place: onEndTurn() after the human's own move
|
// Logic.pendingBattlesFor fresh every call (a no-op if nothing's pending),
|
||||||
// (`attacked` left false — the human chose this fight, so they choose the
|
// so it's safe to call from more than one place: onEndTurn() after the
|
||||||
// formation), AND runToHumanTurn()'s AI loop after every AI empire's move
|
// human's own move (`attacked` left false — the human chose this fight, so
|
||||||
// (`attacked: true` — see below), since endEmpireTurn is told to defer any
|
// they choose the formation, UNLESS they're purely defending with no fleet
|
||||||
// battle touching the human rather than auto-resolve it (Brian's ask,
|
// of their own present), AND runToHumanTurn()'s AI loop after every AI
|
||||||
// 2026-08-14) — this is what actually shows it.
|
// 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 } = {}) {
|
playPlayerBattles(done, { attacked = false } = {}) {
|
||||||
const me = this.state.humanIndex;
|
const me = this.state.humanIndex;
|
||||||
const pending = Logic.pendingBattlesFor(this.rules, this.state, me);
|
const pending = Logic.pendingBattlesFor(this.rules, this.state, me);
|
||||||
|
|
@ -1244,7 +1247,17 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (attacked) {
|
// 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):
|
// The formation picker is skipped here (Brian's ask, 2026-08-14):
|
||||||
// VegaFormations.js's own header comment says a chosen strategy
|
// VegaFormations.js's own header comment says a chosen strategy
|
||||||
// doesn't change how the battle plays out yet, so asking the player
|
// doesn't change how the battle plays out yet, so asking the player
|
||||||
|
|
@ -1345,6 +1358,36 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
finishAll();
|
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 = () => {
|
const openReport = () => {
|
||||||
if (notable.length) {
|
if (notable.length) {
|
||||||
openTurnReportScreen(this, this.rules, this.state, notable, openGnnIfPending);
|
openTurnReportScreen(this, this.rules, this.state, notable, openGnnIfPending);
|
||||||
|
|
@ -1352,8 +1395,13 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
openGnnIfPending();
|
openGnnIfPending();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const runChoices = (queue) => {
|
const showAttackPopups = (queue) => {
|
||||||
if (!queue.length) { openReport(); return; }
|
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;
|
const [next, ...rest] = queue;
|
||||||
openResearchChoiceScreen(this, this.rules, this.state, this.state.humanIndex, this.art,
|
openResearchChoiceScreen(this, this.rules, this.state, this.state.humanIndex, this.art,
|
||||||
next.field, next.choices, next.completedTechId, () => runChoices(rest));
|
next.field, next.choices, next.completedTechId, () => runChoices(rest));
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,37 @@ function manageColony(rules, state, e, colony, strat) {
|
||||||
|
|
||||||
// Warships when threatened, or a standing patrol once developed.
|
// Warships when threatened, or a standing patrol once developed.
|
||||||
const warship = preferredWarship(rules, state, e, budget);
|
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
|
// At war, keep building: a fleet that stops at parity can never break
|
||||||
// through, and the enemy is building too.
|
// through, and the enemy is building too.
|
||||||
const needFleet = strat.phase === 'war'
|
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
|
// worth far more than a dead one, and bombarding unconditionally turned
|
||||||
// every war into scorched earth — 805 worlds burned against 40 taken.
|
// every war into scorched earth — 805 worlds burned against 40 taken.
|
||||||
const forecast = invasionForecast(rules, state, e, fleet.starIdx);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -714,6 +714,28 @@ export function hasAdvisorVideo(scene, speciesId) {
|
||||||
return !!scene.cache.video?.exists(advisorVideoKey(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.
|
// Frame helpers — the one place that knows how the sheets are indexed.
|
||||||
export const shipFrame = (rules, speciesId, hullId) => {
|
export const shipFrame = (rules, speciesId, hullId) => {
|
||||||
const s = rules.species[speciesId];
|
const s = rules.species[speciesId];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1505,7 +1505,35 @@ function applyBattleLosses(rules, state, starIdx, e, survivors) {
|
||||||
// fallback for an unset side. AI-vs-AI battles (fightAt, below) never pass
|
// fallback for an unset side. AI-vs-AI battles (fightAt, below) never pass
|
||||||
// this and are unaffected — both sides stay silently random as before.
|
// this and are unaffected — both sides stay silently random as before.
|
||||||
export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation = null } = {}) {
|
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 defenderIdx = colony && (colony.empireIdx === a || colony.empireIdx === b) ? colony.empireIdx : b;
|
||||||
const attackerIdx = defenderIdx === a ? b : a;
|
const attackerIdx = defenderIdx === a ? b : a;
|
||||||
|
|
||||||
|
|
@ -1582,12 +1610,30 @@ export function pendingBattlesFor(rules, state, e) {
|
||||||
seen.add(starIdx);
|
seen.add(starIdx);
|
||||||
const foes = new Set();
|
const foes = new Set();
|
||||||
for (const g of state.fleets) {
|
for (const g of state.fleets) {
|
||||||
if (g.starIdx === 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, starIdx);
|
|
||||||
if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx) && colony.defenseHp > 0) {
|
|
||||||
foes.add(colony.empireIdx);
|
|
||||||
}
|
}
|
||||||
|
// 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 other of foes) out.push({ starIdx, other });
|
||||||
};
|
};
|
||||||
for (const f of state.fleets) {
|
for (const f of state.fleets) {
|
||||||
|
|
@ -1598,9 +1644,21 @@ export function pendingBattlesFor(rules, state, e) {
|
||||||
// defender (planetary batteries alone) — resolveCombats' own colony-
|
// defender (planetary batteries alone) — resolveCombats' own colony-
|
||||||
// defense pass already covers this case; the loop above, scoped to e's
|
// defense pass already covers this case; the loop above, scoped to e's
|
||||||
// FLEETS, previously didn't, so an undefended colony under siege during
|
// FLEETS, previously didn't, so an undefended colony under siege during
|
||||||
// someone else's turn would never surface here at all.
|
// 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)) {
|
for (const c of empireColonies(state, e)) {
|
||||||
addStar(c.starIdx);
|
if (c.defenseHp > 0) addStar(c.starIdx);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -1798,17 +1856,55 @@ export function bombardedThisTurn(state, e, colony) {
|
||||||
// attacker fleet at that star regardless of which one was passed in, so an
|
// 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
|
// AI with two fleets at one siege was double-charging the same pooled
|
||||||
// damage before this cap existed.
|
// 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) {
|
export function bombard(rules, state, e, starIdx, orbit = null) {
|
||||||
const colony = targetColonyAt(state, e, starIdx, orbit);
|
const colony = targetColonyAt(state, e, starIdx, orbit);
|
||||||
if (!colony || colony.empireIdx === e) return null;
|
if (!colony || colony.empireIdx === e) return null;
|
||||||
if (!atWar(state, e, colony.empireIdx)) return null;
|
if (!atWar(state, e, colony.empireIdx)) return null;
|
||||||
if (!holdsOrbit(rules, state, e, starIdx, 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];
|
const emp = state.empires[e];
|
||||||
if (bombardedThisTurn(state, e, colony)) return null;
|
if (bombardedThisTurn(state, e, colony)) return null;
|
||||||
const spec = rules.species[emp.speciesId];
|
const spec = rules.species[emp.speciesId];
|
||||||
let damage = 0;
|
let damage = 0;
|
||||||
let cracker = false;
|
let cracker = false;
|
||||||
|
let maxMark = 0;
|
||||||
for (const f of state.fleets) {
|
for (const f of state.fleets) {
|
||||||
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
|
if (f.starIdx !== starIdx || f.empireIdx !== e) continue;
|
||||||
for (const st of f.ships) {
|
for (const st of f.ships) {
|
||||||
|
|
@ -1816,6 +1912,7 @@ export function bombard(rules, state, e, starIdx, orbit = null) {
|
||||||
if (d.role !== 'warship') continue;
|
if (d.role !== 'warship') continue;
|
||||||
damage += d.damage * st.count;
|
damage += d.damage * st.count;
|
||||||
if (d.planetCracker) cracker = true;
|
if (d.planetCracker) cracker = true;
|
||||||
|
if (d.mark > maxMark) maxMark = d.mark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (damage <= 0) return null;
|
if (damage <= 0) return null;
|
||||||
|
|
@ -1830,22 +1927,68 @@ export function bombard(rules, state, e, starIdx, orbit = null) {
|
||||||
// A Stellar Converter cracks the crust; nothing is left to invade.
|
// A Stellar Converter cracks the crust; nothing is left to invade.
|
||||||
if (cracker) colony.pop = Math.max(0, colony.pop - kill);
|
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, {
|
pushEvent(state, {
|
||||||
type: 'bombard', empire: e, target: colony.empireIdx, starIdx,
|
type: 'bombard', empire: e, target: colony.empireIdx, starIdx,
|
||||||
killed: Math.round(before - colony.pop), cracker, turn: state.turn,
|
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;
|
const owner = colony.empireIdx;
|
||||||
state.colonies = state.colonies.filter((c) => c !== colony);
|
state.colonies = state.colonies.filter((c) => c !== colony);
|
||||||
pushEvent(state, { type: 'colonyDestroyed', empire: e, target: owner, starIdx, turn: state.turn });
|
pushEvent(state, { type: 'colonyDestroyed', empire: e, target: owner, starIdx, turn: state.turn });
|
||||||
recomputeTotals(rules, state);
|
recomputeTotals(rules, state);
|
||||||
checkLastColony(state, owner, e);
|
checkLastColony(state, owner, e);
|
||||||
checkElimination(rules, 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);
|
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
|
// What a landing at this system would look like right now. Exported because
|
||||||
|
|
@ -1903,6 +2046,9 @@ export function invade(rules, state, e, starIdx, orbit = null) {
|
||||||
if (!fleet) return null;
|
if (!fleet) return null;
|
||||||
const stack = fleet.ships.find((s) => s.hullId === 'transport' && s.count > 0);
|
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 emp = state.empires[e];
|
||||||
const spec = rules.species[emp.speciesId];
|
const spec = rules.species[emp.speciesId];
|
||||||
const comps = empireComponents(rules, state, e);
|
const comps = empireComponents(rules, state, e);
|
||||||
|
|
@ -1927,16 +2073,22 @@ export function invade(rules, state, e, starIdx, orbit = null) {
|
||||||
colony.pop = Math.max(1, colony.pop * 0.5);
|
colony.pop = Math.max(1, colony.pop * 0.5);
|
||||||
colony.queue = [];
|
colony.queue = [];
|
||||||
colony.defenseHp = 0;
|
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[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;
|
state.empires[e]._range = null; state.empires[e]._rangeAt = -1;
|
||||||
checkLastColony(state, from, e);
|
checkLastColony(state, from, e);
|
||||||
checkElimination(rules, state, from, e);
|
checkElimination(rules, state, from, e);
|
||||||
} else {
|
} 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);
|
recomputeTotals(rules, state);
|
||||||
return result;
|
return { ...result, troops, planetTypeId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pushed the instant an attack (capture or bombardment) leaves the victim
|
// Pushed the instant an attack (capture or bombardment) leaves the victim
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { modalShell, FONT, ORBIT, uiClick } from './VegaScreens.js';
|
||||||
import { planetFrame, starFrame } from './VegaArt.js';
|
import { planetFrame, starFrame } from './VegaArt.js';
|
||||||
import { openColonyView, CHANNEL_COLOUR, itemName, etaText } from './VegaColonyView.js';
|
import { openColonyView, CHANNEL_COLOUR, itemName, etaText } from './VegaColonyView.js';
|
||||||
import { openColonyIntro, ensureColonyVideo, promptColonyName } from './VegaColonyIntro.js';
|
import { openColonyIntro, ensureColonyVideo, promptColonyName } from './VegaColonyIntro.js';
|
||||||
|
import { openBombardPopup } from './VegaBombardScreen.js';
|
||||||
import {
|
import {
|
||||||
CHANNELS, coloniesAt, canColonize, colonize, colonyMaxPop, colonyProduction,
|
CHANNELS, coloniesAt, canColonize, colonize, colonyMaxPop, colonyProduction,
|
||||||
colonyFactoryCap, effectiveFactories, colonyDefenseCap, colonyBuildRate,
|
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()
|
// but-lost invasion (every troop consumed either way — see invade()
|
||||||
// in VegaLogic.js) were visually identical to nothing happening at
|
// in VegaLogic.js) were visually identical to nothing happening at
|
||||||
// all, since the only record was a turn-report event the player
|
// all, since the only record was a turn-report event the player
|
||||||
// wouldn't see until ending their turn. scene.log() surfaces the
|
// wouldn't see until ending their turn. A refusal still surfaces
|
||||||
// outcome immediately in the same ticker the rest of the HUD uses.
|
// 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
|
// Bombard is capped at once per colony per turn (VegaLogic.js's
|
||||||
// lastBombardTurn) — disabled here rather than hidden, since it's a
|
// lastBombardTurn) — disabled here rather than hidden, since it's a
|
||||||
// temporary "wait for next turn" state, not a structural
|
// temporary "wait for next turn" state, not a structural
|
||||||
// unavailability like Invade's "no transports" below.
|
// unavailability like Invade's "no transports" below.
|
||||||
|
const defenderIdx = colony.empireIdx;
|
||||||
const bombardBtn = new Button(scene, panelX + 120, y + 20, 'Bombard', uiClick(scene, () => {
|
const bombardBtn = new Button(scene, panelX + 120, y + 20, 'Bombard', uiClick(scene, () => {
|
||||||
const result = bombard(rules, state, viewerIdx, starIdx, colony.orbit);
|
const result = bombard(rules, state, viewerIdx, starIdx, colony.orbit);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
scene.log?.('Bombard failed — you need orbital superiority and warships with a working weapon.');
|
scene.log?.('Bombard failed — you need orbital superiority and warships with a working weapon.');
|
||||||
} else if (result.destroyed) {
|
return;
|
||||||
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.`);
|
|
||||||
}
|
}
|
||||||
onChanged?.(); rebuild();
|
openBombardPopup(scene, rules, state, {
|
||||||
|
kind: 'bombard', attackerIdx: viewerIdx, defenderIdx, starIdx, result,
|
||||||
|
}, () => { onChanged?.(); rebuild(); });
|
||||||
}), { width: 220, height: 42, bg: 0x6b2230 });
|
}), { width: 220, height: 42, bg: 0x6b2230 });
|
||||||
if (alreadyBombarded) bombardBtn.setEnabled(false);
|
if (alreadyBombarded) bombardBtn.setEnabled(false);
|
||||||
console_.add(bombardBtn);
|
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);
|
const result = invade(rules, state, viewerIdx, starIdx, colony.orbit);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
scene.log?.('Invasion could not be launched — you need orbital superiority and a troop transport in orbit.');
|
scene.log?.('Invasion could not be launched — you need orbital superiority and a troop transport in orbit.');
|
||||||
} else if (result.captured) {
|
return;
|
||||||
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.`);
|
|
||||||
}
|
}
|
||||||
onChanged?.(); rebuild();
|
openBombardPopup(scene, rules, state, {
|
||||||
|
kind: 'invade', attackerIdx: viewerIdx, defenderIdx, starIdx, result,
|
||||||
|
}, () => { onChanged?.(); rebuild(); });
|
||||||
}), { width: 220, height: 42 }));
|
}), { width: 220, height: 42 }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,17 @@ import { habitableForEmpire, recommendColonyFocus, recommendAllocationFocus } fr
|
||||||
import { describeTechEffects } from './VegaTechEffects.js';
|
import { describeTechEffects } from './VegaTechEffects.js';
|
||||||
|
|
||||||
// Events worth interrupting the player for. Everything else (refit,
|
// Events worth interrupting the player for. Everything else (refit,
|
||||||
// spyCaught, techStolen, invasionFailed, leaderHired, victory — which
|
// spyCaught, techStolen, leaderHired, victory — which already has its own
|
||||||
// already has its own showVictoryOverlay) stays in the small ticker log only.
|
// 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
|
// `colonised` is deliberately NOT here. Founding a colony gets its own
|
||||||
// full-screen vignette the instant it happens (VegaColonyIntro.js), and a row
|
// 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([
|
export const NOTABLE_TYPES = new Set([
|
||||||
'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', 'captured',
|
'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', 'captured',
|
||||||
'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'tradeAgreementFormed',
|
'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'tradeAgreementFormed',
|
||||||
'councilRefused', 'eliminated', 'advisorRecommendation',
|
'councilRefused', 'eliminated', 'advisorRecommendation', 'bombard', 'invasionFailed',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Exploring, researching, founding a colony and finishing a build-queue item
|
// Exploring, researching, founding a colony and finishing a build-queue item
|
||||||
|
|
@ -66,6 +75,8 @@ export const TYPE_LABEL = {
|
||||||
councilRefused: 'Council Refused',
|
councilRefused: 'Council Refused',
|
||||||
eliminated: 'Empire Eliminated',
|
eliminated: 'Empire Eliminated',
|
||||||
advisorRecommendation: 'Advisor Recommendation',
|
advisorRecommendation: 'Advisor Recommendation',
|
||||||
|
bombard: 'Bombardment',
|
||||||
|
invasionFailed: 'Invasion Repelled',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sort weight — diplomacy/territory/council news first (most consequential),
|
// Sort weight — diplomacy/territory/council news first (most consequential),
|
||||||
|
|
@ -231,6 +242,34 @@ function describeColonyDestroyed(rules, state, ev, name) {
|
||||||
return { headline, lines: [] };
|
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) {
|
function describeTreaty(rules, state, ev, name, verb) {
|
||||||
const me = state.humanIndex;
|
const me = state.humanIndex;
|
||||||
let headline;
|
let headline;
|
||||||
|
|
@ -269,6 +308,8 @@ export function describeEvent(rules, state, ev) {
|
||||||
case 'contact': out = describeContact(rules, state, ev, name); break;
|
case 'contact': out = describeContact(rules, state, ev, name); break;
|
||||||
case 'captured': out = describeCaptured(rules, state, ev, name); break;
|
case 'captured': out = describeCaptured(rules, state, ev, name); break;
|
||||||
case 'colonyDestroyed': out = describeColonyDestroyed(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 'warDeclared': out = describeTreaty(rules, state, ev, name, 'declare war on'); break;
|
||||||
case 'peace': out = describeTreaty(rules, state, ev, name, 'make peace with'); 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;
|
case 'alliance': out = describeTreaty(rules, state, ev, name, 'form an alliance with'); break;
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,11 @@ const config = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const game = new Phaser.Game(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
|
// Phaser positions its DOM overlay container with margins only (no explicit
|
||||||
// left/top); under Scale.FIT the absolutely-positioned container drifts away
|
// left/top); under Scale.FIT the absolutely-positioned container drifts away
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ import {
|
||||||
import {
|
import {
|
||||||
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
|
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
|
||||||
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey,
|
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey,
|
||||||
colonyVideoKey, audienceVideoKey, sourceWidth, advisorVideoKey,
|
colonyVideoKey, audienceVideoKey, sourceWidth, advisorVideoKey, bombardVideoKey, invadeVideoKey,
|
||||||
} from '../src/games/mastervega/VegaArt.js';
|
} from '../src/games/mastervega/VegaArt.js';
|
||||||
import { CHAT } from '../src/games/mastervega/VegaChat.js';
|
import { CHAT } from '../src/games/mastervega/VegaChat.js';
|
||||||
// Turn-report classification is Phaser-free, so what the "New Turn" popup will
|
// 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('six tech fields', RULES.techFieldList.length === 6, `${RULES.techFieldList.length}`);
|
||||||
check('ten species', RULES.speciesList.length === 10, `${RULES.speciesList.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).
|
// Every tech ranked (proves the chains are acyclic and fully reachable).
|
||||||
check('every tech is ranked', Object.keys(RULES.techRank).length === RULES.techList.length);
|
check('every tech is ranked', Object.keys(RULES.techRank).length === RULES.techList.length);
|
||||||
for (const t of RULES.techList) {
|
for (const t of RULES.techList) {
|
||||||
|
|
@ -684,6 +709,51 @@ section('2. Procedural art');
|
||||||
console.log(` (${colClips}/${colonisableTotal} colony-founding clips recorded; `
|
console.log(` (${colClips}/${colonisableTotal} colony-founding clips recorded; `
|
||||||
+ 'the rest fall back to the world backdrop)');
|
+ '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.
|
// Colonies-screen advisor loops (VegaColoniesScreen.js), one per species.
|
||||||
// Declared-or-null like shipVideos/audienceVideos: a gap is legal (falls
|
// Declared-or-null like shipVideos/audienceVideos: a gap is legal (falls
|
||||||
// back to the species portrait), but a key that does not match
|
// 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');
|
section('4d. Sensor scan range');
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1769,20 +2038,103 @@ section('5b. Deferred battles (human-visible combat)');
|
||||||
results.some((r) => r.attackerIdx === 1 && r.defenderIdx === 2), JSON.stringify(results));
|
results.some((r) => r.attackerIdx === 1 && r.defenderIdx === 2), JSON.stringify(results));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- pendingBattlesFor now also finds an undefended colony under siege
|
// --- 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).
|
// (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 st = mk(['human', 'kkrix']);
|
||||||
const otherStar = st.galaxy.stars.findIndex((s, i) => !st.galaxy.homeIdx.includes(i)
|
const otherStar = st.galaxy.stars.findIndex((s, i) => !st.galaxy.homeIdx.includes(i)
|
||||||
&& Logic.canColonize(RULES, st, 0, i, 0));
|
&& Logic.canColonize(RULES, st, 0, i, 0));
|
||||||
check('a spare colonizable star exists for the siege test', otherStar >= 0);
|
check('a spare colonizable star exists for the siege test', otherStar >= 0);
|
||||||
if (otherStar >= 0) {
|
if (otherStar >= 0) {
|
||||||
Logic.foundColony(RULES, st, 0, otherStar, 0, 200);
|
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);
|
Diplo.declareWar(RULES, st, 0, 1);
|
||||||
Logic.addFleet(RULES, st, 1, otherStar, [{ hullId: 'cruiser', mark: 1, count: 2 }]);
|
Logic.addFleet(RULES, st, 1, otherStar, [{ hullId: 'cruiser', mark: 1, count: 2 }]);
|
||||||
const pending = Logic.pendingBattlesFor(RULES, st, 0);
|
const pending = Logic.pendingBattlesFor(RULES, st, 0);
|
||||||
check('an undefended colony under siege now shows up as a pending battle',
|
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));
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue