Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig 8f0b079fa4 feat(vega-gnn): suppress ranking/relations pages during espionage alerts
When a fresh espionage notification is pending, skip ranking metrics and
relations pages so the alert reads as a focused notification rather than
being interleaved with unrelated charts. Reopening GNN without pending
events (using history) always shows the full experience, providing an
escape hatch for players who want the charts.
2026-08-13 16:45:53 -06:00
Brian Fertig 57a8a14ade feat(mastervega): add third-party peace requests, pre-battle formation picker, and combat V2 planet fixes
- Add "peace request" diplomacy: player can ask an AI to end a war with a
  third empire, and AIs can ask the player the same. Refusing costs attitude.
  Implemented in VegaDiplomacy.js (wouldAcceptPeaceRequest/requestPeace/offerPeaceRequest),
  VegaAudience.js (Demand Peace button + war picker), and full chat lines for
  all species in VegaChat.js.

- Extract openFormationPicker to VegaScreens.js and wire it into
  MasterOfVegaGame.js so the player chooses a formation before every battle.
  AI opponents still pick silently.

- Fix Combat V2 planet positioning (fixed at worldWidth * 0.75 instead of
  formation-relative), pass typeId for real planet art, and apply the
  Planetary Shield building's shieldBonus (was hardcoded to 0).

- Add icon images to research screen tech field headers.

- Add comprehensive tests for peace requests and prepareBattleAt fixes.
2026-08-13 16:12:10 -06:00
Brian Fertig 590bca1721 feat(master-vega): add Galactic Council Session ceremony UI
Introduce a full-screen, click-advanced ceremony (VegaCouncilSession.js)
that replays runCouncil()'s result delegate-by-delegate instead of hiding
the tally behind the turn-report. Three fixed stages — Roster, Voting,
and Verdict — with a persistent 2:3 portrait anchor video, green-screen
ticker, live scoreboard with animated bars, and sting-then-loop audio.

Key changes:
- VegaLogic.js: expose per-voter `voters` breakdown and `pendingSession`
  flag so the UI can replay the session before the victory check.
- MasterOfVegaGame.js: consume `pendingSession` in `runToHumanTurn()`
  before `state.over`, ensuring a decisive council still gets its ceremony.
- VegaTurnReport.js: remove `council` from NOTABLE_TYPES (ceremony handles
  it); keep `councilRefused` as a distinct war-declaration row.
- assetManifest.js / Sounds.js: register video + sting/loop audio cues.
- VegaScreens.js: add `D.council` depth value.
- verifyMasterOfVega.js: assert voter breakdown consistency and pending
  session lifecycle.
2026-08-13 15:16:43 -06:00
19 changed files with 1164 additions and 222 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

View File

@ -298,6 +298,16 @@
"rows": 2,
"_layout": "One frame per building.frame, 0-15, in rules order."
},
"techfields": {
"key": "vega-techfields",
"path": "assets/images/vega/techfields.png",
"kind": "icon",
"frameWidth": 48,
"frameHeight": 48,
"cols": 3,
"rows": 2,
"_layout": "One frame per tech FIELD (techFields[].iconFrame), 0-5, in rules order: Computers, Construction, Force Fields, Planetology, Propulsion, Weapons. Exact fit, no spares."
},
"techicons": {
"key": "vega-techicons",
"path": null,
@ -306,7 +316,7 @@
"frameHeight": 48,
"cols": 10,
"rows": 10,
"_layout": "Frames 0-5 are the six tech FIELDS (techFields[].iconFrame); frames 6-65 are the original 60 individual techs, 66-77 are the first MOO1-branching alternates, 78-96 are a second branching pass (techs[].iconFrame). 97-99 spare."
"_layout": "One frame per individual tech (techs[].iconFrame), 0-90 — the 60 original techs plus two MOO1-branching passes added later, in rules order. Split out of the combined tech sheet so field icons and tech icons can be painted/sized independently. 91-99 spare."
}
}
}

View File

@ -13,102 +13,102 @@
"_techsReadme": "prereqs is always 0 or 1 entries, pointing at the previous tech in the same field. tier must equal the topological rank. effects is a free-form bag read by VegaShips (weapon/armor/shield/engine/fuelRange/targeting) and VegaLogic (everything else).",
"techs": [
{ "id": "electroniccomputer", "name": "Electronic Computer", "field": "computers", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 6, "desc": "Mark I battle computer. +1 to hit.", "effects": { "targeting": 1 } },
{ "id": "battlescanner", "name": "Battle Scanner", "field": "computers", "tier": 1, "prereqs": ["electroniccomputer"], "cost": 150, "iconFrame": 7, "desc": "Reveals enemy fleet composition and adds combat initiative.", "effects": { "initiative": 2, "scanRange": 1 } },
{ "id": "securecommarray", "name": "Secure Comm Array", "field": "computers", "tier": 1, "prereqs": ["electroniccomputer"], "cost": 150, "iconFrame": 78, "desc": "Encrypted fleet channels. Enemy spies have a harder time reading your signals.", "effects": { "counterEspionage": 14, "scanRange": 1 } },
{ "id": "optroniccomputer", "name": "Optronic Computer", "field": "computers", "tier": 2, "prereqs": ["battlescanner"], "cost": 400, "iconFrame": 8, "desc": "Mark II battle computer. +2 to hit.", "effects": { "targeting": 2 } },
{ "id": "neuralscanner", "name": "Neural Scanner", "field": "computers", "tier": 3, "prereqs": ["optroniccomputer"], "cost": 900, "iconFrame": 9, "desc": "Deep-scan interrogation. Espionage missions far more likely.", "effects": { "espionage": 15 } },
{ "id": "tachyoncomputer", "name": "Tachyon Computer", "field": "computers", "tier": 3, "prereqs": ["optroniccomputer"], "cost": 900, "iconFrame": 66, "desc": "Overclocked targeting matrix, no spycraft. +4 to hit.", "effects": { "targeting": 4 } },
{ "id": "positroniccomputer", "name": "Positronic Computer", "field": "computers", "tier": 4, "prereqs": ["neuralscanner"], "cost": 1800, "iconFrame": 10, "desc": "Mark III battle computer. +3 to hit.", "effects": { "targeting": 3 }, "gnnHeadline": true },
{ "id": "cybersecuritylink", "name": "Cyber Security Link", "field": "computers", "tier": 5, "prereqs": ["positroniccomputer"], "cost": 3200, "iconFrame": 11, "desc": "Hardened colony networks. Enemy spies rarely get through.", "effects": { "counterEspionage": 25 } },
{ "id": "adaptiveresearchalgorithms", "name": "Adaptive Research Algorithms", "field": "computers", "tier": 5, "prereqs": ["positroniccomputer"], "cost": 3200, "iconFrame": 79, "desc": "Machine-learning heuristics comb through lab data around the clock. Research output up 10%, though the network offers no extra hardening against spies.", "effects": { "researchMult": 1.1 } },
{ "id": "emissionsguidance", "name": "Emissions Guidance", "field": "computers", "tier": 6, "prereqs": ["cybersecuritylink"], "cost": 5400, "iconFrame": 12, "desc": "Mark IV battle computer. +4 to hit.", "effects": { "targeting": 4 } },
{ "id": "oracleinterface", "name": "Oracle Interface", "field": "computers", "tier": 7, "prereqs": ["emissionsguidance"], "cost": 8600, "iconFrame": 13, "desc": "Mark V battle computer, and research output climbs sharply.", "effects": { "targeting": 5, "researchMult": 1.15 } },
{ "id": "omniscanarray", "name": "Omniscan Array", "field": "computers", "tier": 7, "prereqs": ["emissionsguidance"], "cost": 8600, "iconFrame": 67, "desc": "Full-spectrum battle sensors. +5 to hit and always strikes first.", "effects": { "targeting": 5, "initiative": 5 } },
{ "id": "cybertroniccomputer","name": "Cybertronic Computer", "field": "computers", "tier": 8, "prereqs": ["oracleinterface"], "cost": 13000, "iconFrame": 14, "desc": "Mark VI battle computer. +6 to hit.", "effects": { "targeting": 6 }, "gnnHeadline": true },
{ "id": "deepfieldsensorarray","name": "Deep Field Sensor Array", "field": "computers", "tier": 8, "prereqs": ["oracleinterface"], "cost": 13000, "iconFrame": 80, "desc": "Long-baseline sensor net. Fleets see further and react faster, at the cost of raw fire-control precision.", "effects": { "scanRange": 2, "initiative": 2 } },
{ "id": "galacticcybernet", "name": "Galactic Cybernet", "field": "computers", "tier": 9, "prereqs": ["cybertroniccomputer"], "cost": 20000, "iconFrame": 15, "desc": "Mark VII battle computer and an empire-wide research grid.", "effects": { "targeting": 7, "researchMult": 1.3 }, "gnnHeadline": true },
{ "id": "electroniccomputer", "name": "Electronic Computer", "field": "computers", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 0, "desc": "Mark I battle computer. +1 to hit.", "effects": { "targeting": 1 } },
{ "id": "battlescanner", "name": "Battle Scanner", "field": "computers", "tier": 1, "prereqs": ["electroniccomputer"], "cost": 150, "iconFrame": 1, "desc": "Reveals enemy fleet composition and adds combat initiative.", "effects": { "initiative": 2, "scanRange": 1 } },
{ "id": "securecommarray", "name": "Secure Comm Array", "field": "computers", "tier": 1, "prereqs": ["electroniccomputer"], "cost": 150, "iconFrame": 72, "desc": "Encrypted fleet channels. Enemy spies have a harder time reading your signals.", "effects": { "counterEspionage": 14, "scanRange": 1 } },
{ "id": "optroniccomputer", "name": "Optronic Computer", "field": "computers", "tier": 2, "prereqs": ["battlescanner"], "cost": 400, "iconFrame": 2, "desc": "Mark II battle computer. +2 to hit.", "effects": { "targeting": 2 } },
{ "id": "neuralscanner", "name": "Neural Scanner", "field": "computers", "tier": 3, "prereqs": ["optroniccomputer"], "cost": 900, "iconFrame": 3, "desc": "Deep-scan interrogation. Espionage missions far more likely.", "effects": { "espionage": 15 } },
{ "id": "tachyoncomputer", "name": "Tachyon Computer", "field": "computers", "tier": 3, "prereqs": ["optroniccomputer"], "cost": 900, "iconFrame": 60, "desc": "Overclocked targeting matrix, no spycraft. +4 to hit.", "effects": { "targeting": 4 } },
{ "id": "positroniccomputer", "name": "Positronic Computer", "field": "computers", "tier": 4, "prereqs": ["neuralscanner"], "cost": 1800, "iconFrame": 4, "desc": "Mark III battle computer. +3 to hit.", "effects": { "targeting": 3 }, "gnnHeadline": true },
{ "id": "cybersecuritylink", "name": "Cyber Security Link", "field": "computers", "tier": 5, "prereqs": ["positroniccomputer"], "cost": 3200, "iconFrame": 5, "desc": "Hardened colony networks. Enemy spies rarely get through.", "effects": { "counterEspionage": 25 } },
{ "id": "adaptiveresearchalgorithms", "name": "Adaptive Research Algorithms", "field": "computers", "tier": 5, "prereqs": ["positroniccomputer"], "cost": 3200, "iconFrame": 73, "desc": "Machine-learning heuristics comb through lab data around the clock. Research output up 10%, though the network offers no extra hardening against spies.", "effects": { "researchMult": 1.1 } },
{ "id": "emissionsguidance", "name": "Emissions Guidance", "field": "computers", "tier": 6, "prereqs": ["cybersecuritylink"], "cost": 5400, "iconFrame": 6, "desc": "Mark IV battle computer. +4 to hit.", "effects": { "targeting": 4 } },
{ "id": "oracleinterface", "name": "Oracle Interface", "field": "computers", "tier": 7, "prereqs": ["emissionsguidance"], "cost": 8600, "iconFrame": 7, "desc": "Mark V battle computer, and research output climbs sharply.", "effects": { "targeting": 5, "researchMult": 1.15 } },
{ "id": "omniscanarray", "name": "Omniscan Array", "field": "computers", "tier": 7, "prereqs": ["emissionsguidance"], "cost": 8600, "iconFrame": 61, "desc": "Full-spectrum battle sensors. +5 to hit and always strikes first.", "effects": { "targeting": 5, "initiative": 5 } },
{ "id": "cybertroniccomputer","name": "Cybertronic Computer", "field": "computers", "tier": 8, "prereqs": ["oracleinterface"], "cost": 13000, "iconFrame": 8, "desc": "Mark VI battle computer. +6 to hit.", "effects": { "targeting": 6 }, "gnnHeadline": true },
{ "id": "deepfieldsensorarray","name": "Deep Field Sensor Array", "field": "computers", "tier": 8, "prereqs": ["oracleinterface"], "cost": 13000, "iconFrame": 74, "desc": "Long-baseline sensor net. Fleets see further and react faster, at the cost of raw fire-control precision.", "effects": { "scanRange": 2, "initiative": 2 } },
{ "id": "galacticcybernet", "name": "Galactic Cybernet", "field": "computers", "tier": 9, "prereqs": ["cybertroniccomputer"], "cost": 20000, "iconFrame": 9, "desc": "Mark VII battle computer and an empire-wide research grid.", "effects": { "targeting": 7, "researchMult": 1.3 }, "gnnHeadline": true },
{ "id": "reinforcedhull", "name": "Reinforced Hull", "field": "construction", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 16, "desc": "Cross-braced spaceframes. Hulls absorb 25% more damage.", "effects": { "armor": { "name": "Reinforced", "hpMult": 1.25 } } },
{ "id": "duralloyarmor", "name": "Duralloy Armour", "field": "construction", "tier": 1, "prereqs": ["reinforcedhull"], "cost": 160, "iconFrame": 17, "desc": "Duralloy plating. Hulls absorb 50% more damage.", "effects": { "armor": { "name": "Duralloy", "hpMult": 1.5 } } },
{ "id": "modularhullframes", "name": "Modular Hull Frames", "field": "construction", "tier": 1, "prereqs": ["reinforcedhull"], "cost": 160, "iconFrame": 81, "desc": "Standardised hull sections. Ships refit for less, though the plating itself is no thicker than reinforced hull.", "effects": { "refitCostMult": 0.85 } },
{ "id": "improvedindustrial", "name": "Improved Industrial Tech","field": "construction","tier": 2,"prereqs": ["duralloyarmor"], "cost": 420, "iconFrame": 18, "desc": "Factories cost 20% less to build and to run.", "effects": { "factoryCostMult": 0.8 } },
{ "id": "nanitehullsealant", "name": "Nanite Hull Sealant", "field": "construction", "tier": 2, "prereqs": ["duralloyarmor"], "cost": 420, "iconFrame": 68, "desc": "Self-sealing microfractures. Ships repair 2% of their hull between combat rounds.", "effects": { "repairPerRound": 0.02 } },
{ "id": "zortriumarmor", "name": "Zortrium Armour", "field": "construction", "tier": 3, "prereqs": ["improvedindustrial"], "cost": 950, "iconFrame": 19, "desc": "Zortrium plating. Hulls absorb 80% more damage.", "effects": { "armor": { "name": "Zortrium", "hpMult": 1.8 } } },
{ "id": "autorepairunit", "name": "Automated Repair Unit", "field": "construction", "tier": 4, "prereqs": ["zortriumarmor"], "cost": 1900, "iconFrame": 20, "desc": "Ships repair 3% of their hull between combat rounds.", "effects": { "repairPerRound": 0.03 } },
{ "id": "roboticassemblylines","name": "Robotic Assembly Lines", "field": "construction", "tier": 4, "prereqs": ["zortriumarmor"], "cost": 1900, "iconFrame": 82, "desc": "Fully robotic production lines undercut even Improved Industrial Tech. Factories cost 25% less to build and run, though the yards spend no effort on hull repair drones.", "effects": { "factoryCostMult": 0.75 }, "gnnHeadline": true },
{ "id": "andriumarmor", "name": "Andrium Armour", "field": "construction", "tier": 5, "prereqs": ["autorepairunit"], "cost": 3400, "iconFrame": 21, "desc": "Andrium plating. Hulls absorb 110% more damage.", "effects": { "armor": { "name": "Andrium", "hpMult": 2.1 } } },
{ "id": "damagecontrol", "name": "Advanced Damage Control","field": "construction","tier": 6, "prereqs": ["andriumarmor"], "cost": 5600, "iconFrame": 22, "desc": "Damaged ships rejoin the line faster and cost less to refit.", "effects": { "repairPerRound": 0.06, "refitCostMult": 0.7 } },
{ "id": "redundantlifesupport","name": "Redundant Life Support","field": "construction", "tier": 6, "prereqs": ["andriumarmor"], "cost": 5600, "iconFrame": 69, "desc": "Backup systems patch hulls faster in the heat of battle. Ships repair 7% each round.", "effects": { "repairPerRound": 0.07 } },
{ "id": "tritaniumarmor", "name": "Tritanium Armour", "field": "construction", "tier": 7, "prereqs": ["damagecontrol"], "cost": 9000, "iconFrame": 23, "desc": "Tritanium plating. Hulls absorb 150% more damage.", "effects": { "armor": { "name": "Tritanium", "hpMult": 2.5 } } },
{ "id": "adamantiumarmor", "name": "Adamantium Armour", "field": "construction", "tier": 8, "prereqs": ["tritaniumarmor"], "cost": 14000, "iconFrame": 24, "desc": "Adamantium plating. Hulls absorb 200% more damage.", "effects": { "armor": { "name": "Adamantium", "hpMult": 3.0 } }, "gnnHeadline": true },
{ "id": "autonomousrepairswarms","name": "Autonomous Repair Swarms", "field": "construction", "tier": 8, "prereqs": ["tritaniumarmor"], "cost": 14000, "iconFrame": 83, "desc": "Self-directing nanite swarms patch hull breaches faster than any yard crew. Ships repair 9% of their hull between combat rounds, though plating stays at Tritanium grade.", "effects": { "repairPerRound": 0.09 } },
{ "id": "neutroniumarmor", "name": "Neutronium Armour", "field": "construction", "tier": 9, "prereqs": ["adamantiumarmor"], "cost": 21000, "iconFrame": 25, "desc": "Degenerate-matter plating. Hulls absorb 260% more damage.", "effects": { "armor": { "name": "Neutronium", "hpMult": 3.6 } }, "gnnHeadline": true },
{ "id": "reinforcedhull", "name": "Reinforced Hull", "field": "construction", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 10, "desc": "Cross-braced spaceframes. Hulls absorb 25% more damage.", "effects": { "armor": { "name": "Reinforced", "hpMult": 1.25 } } },
{ "id": "duralloyarmor", "name": "Duralloy Armour", "field": "construction", "tier": 1, "prereqs": ["reinforcedhull"], "cost": 160, "iconFrame": 11, "desc": "Duralloy plating. Hulls absorb 50% more damage.", "effects": { "armor": { "name": "Duralloy", "hpMult": 1.5 } } },
{ "id": "modularhullframes", "name": "Modular Hull Frames", "field": "construction", "tier": 1, "prereqs": ["reinforcedhull"], "cost": 160, "iconFrame": 75, "desc": "Standardised hull sections. Ships refit for less, though the plating itself is no thicker than reinforced hull.", "effects": { "refitCostMult": 0.85 } },
{ "id": "improvedindustrial", "name": "Improved Industrial Tech","field": "construction","tier": 2,"prereqs": ["duralloyarmor"], "cost": 420, "iconFrame": 12, "desc": "Factories cost 20% less to build and to run.", "effects": { "factoryCostMult": 0.8 } },
{ "id": "nanitehullsealant", "name": "Nanite Hull Sealant", "field": "construction", "tier": 2, "prereqs": ["duralloyarmor"], "cost": 420, "iconFrame": 62, "desc": "Self-sealing microfractures. Ships repair 2% of their hull between combat rounds.", "effects": { "repairPerRound": 0.02 } },
{ "id": "zortriumarmor", "name": "Zortrium Armour", "field": "construction", "tier": 3, "prereqs": ["improvedindustrial"], "cost": 950, "iconFrame": 13, "desc": "Zortrium plating. Hulls absorb 80% more damage.", "effects": { "armor": { "name": "Zortrium", "hpMult": 1.8 } } },
{ "id": "autorepairunit", "name": "Automated Repair Unit", "field": "construction", "tier": 4, "prereqs": ["zortriumarmor"], "cost": 1900, "iconFrame": 14, "desc": "Ships repair 3% of their hull between combat rounds.", "effects": { "repairPerRound": 0.03 } },
{ "id": "roboticassemblylines","name": "Robotic Assembly Lines", "field": "construction", "tier": 4, "prereqs": ["zortriumarmor"], "cost": 1900, "iconFrame": 76, "desc": "Fully robotic production lines undercut even Improved Industrial Tech. Factories cost 25% less to build and run, though the yards spend no effort on hull repair drones.", "effects": { "factoryCostMult": 0.75 }, "gnnHeadline": true },
{ "id": "andriumarmor", "name": "Andrium Armour", "field": "construction", "tier": 5, "prereqs": ["autorepairunit"], "cost": 3400, "iconFrame": 15, "desc": "Andrium plating. Hulls absorb 110% more damage.", "effects": { "armor": { "name": "Andrium", "hpMult": 2.1 } } },
{ "id": "damagecontrol", "name": "Advanced Damage Control","field": "construction","tier": 6, "prereqs": ["andriumarmor"], "cost": 5600, "iconFrame": 16, "desc": "Damaged ships rejoin the line faster and cost less to refit.", "effects": { "repairPerRound": 0.06, "refitCostMult": 0.7 } },
{ "id": "redundantlifesupport","name": "Redundant Life Support","field": "construction", "tier": 6, "prereqs": ["andriumarmor"], "cost": 5600, "iconFrame": 63, "desc": "Backup systems patch hulls faster in the heat of battle. Ships repair 7% each round.", "effects": { "repairPerRound": 0.07 } },
{ "id": "tritaniumarmor", "name": "Tritanium Armour", "field": "construction", "tier": 7, "prereqs": ["damagecontrol"], "cost": 9000, "iconFrame": 17, "desc": "Tritanium plating. Hulls absorb 150% more damage.", "effects": { "armor": { "name": "Tritanium", "hpMult": 2.5 } } },
{ "id": "adamantiumarmor", "name": "Adamantium Armour", "field": "construction", "tier": 8, "prereqs": ["tritaniumarmor"], "cost": 14000, "iconFrame": 18, "desc": "Adamantium plating. Hulls absorb 200% more damage.", "effects": { "armor": { "name": "Adamantium", "hpMult": 3.0 } }, "gnnHeadline": true },
{ "id": "autonomousrepairswarms","name": "Autonomous Repair Swarms", "field": "construction", "tier": 8, "prereqs": ["tritaniumarmor"], "cost": 14000, "iconFrame": 77, "desc": "Self-directing nanite swarms patch hull breaches faster than any yard crew. Ships repair 9% of their hull between combat rounds, though plating stays at Tritanium grade.", "effects": { "repairPerRound": 0.09 } },
{ "id": "neutroniumarmor", "name": "Neutronium Armour", "field": "construction", "tier": 9, "prereqs": ["adamantiumarmor"], "cost": 21000, "iconFrame": 19, "desc": "Degenerate-matter plating. Hulls absorb 260% more damage.", "effects": { "armor": { "name": "Neutronium", "hpMult": 3.6 } }, "gnnHeadline": true },
{ "id": "deflectori", "name": "Class I Deflector Shield","field": "forcefields","tier": 0, "prereqs": [], "cost": 80, "iconFrame": 26, "desc": "Absorbs 1 point from every incoming hit.", "effects": { "shield": 1 } },
{ "id": "personalshield", "name": "Personal Shield", "field": "forcefields", "tier": 1, "prereqs": ["deflectori"], "cost": 200, "iconFrame": 27, "desc": "Shielded infantry. +10 to ground combat on attack and defence.","effects": { "groundAttack": 10, "groundDefense": 10 } },
{ "id": "pointdefensegrid", "name": "Point-Defense Grid", "field": "forcefields", "tier": 1, "prereqs": ["deflectori"], "cost": 200, "iconFrame": 70, "desc": "Automated point-defense turrets. Fleets react a beat faster in combat.", "effects": { "initiative": 1 } },
{ "id": "deflectoriii", "name": "Class III Deflector", "field": "forcefields", "tier": 2, "prereqs": ["personalshield"], "cost": 500, "iconFrame": 28, "desc": "Absorbs 2 points from every incoming hit.", "effects": { "shield": 2 } },
{ "id": "modulardeflectorcoils","name": "Modular Deflector Coils","field": "forcefields", "tier": 2, "prereqs": ["personalshield"], "cost": 500, "iconFrame": 84, "desc": "Standardised shield generators drop into any hull. Fleets refit for 15% less, though the coils themselves absorb no extra damage.", "effects": { "refitCostMult": 0.85 } },
{ "id": "planetaryshield5", "name": "Planetary Shield V", "field": "forcefields", "tier": 3, "prereqs": ["deflectoriii"], "cost": 1100, "iconFrame": 29, "desc": "Colonies shrug off 5 points from every orbital bombardment.", "effects": { "planetaryShield": 5 } },
{ "id": "deflectorv", "name": "Class V Deflector", "field": "forcefields", "tier": 4, "prereqs": ["planetaryshield5"], "cost": 2100, "iconFrame": 30, "desc": "Absorbs 3 points from every incoming hit.", "effects": { "shield": 3 } },
{ "id": "stealthfield", "name": "Stealth Field", "field": "forcefields", "tier": 5, "prereqs": ["deflectorv"], "cost": 3600, "iconFrame": 31, "desc": "Cloaked hulls. Fleets move unseen and strike first.", "effects": { "cloaked": true, "initiative": 3 }, "gnnHeadline": true },
{ "id": "colonialdeflectorgrid","name": "Colonial Deflector Grid","field": "forcefields", "tier": 5, "prereqs": ["deflectorv"], "cost": 3600, "iconFrame": 85, "desc": "Extends deflector coverage down to the colony surface. Absorbs 8 points from every orbital bombardment, though fleets stay visible on enemy scopes.", "effects": { "planetaryShield": 8 } },
{ "id": "deflectorvii", "name": "Class VII Deflector", "field": "forcefields", "tier": 6, "prereqs": ["stealthfield"], "cost": 6000, "iconFrame": 32, "desc": "Absorbs 5 points from every incoming hit.", "effects": { "shield": 5 } },
{ "id": "planetaryshield15", "name": "Planetary Shield XV", "field": "forcefields", "tier": 7, "prereqs": ["deflectorvii"], "cost": 9500, "iconFrame": 33, "desc": "Colonies shrug off 15 points from every orbital bombardment.", "effects": { "planetaryShield": 15 } },
{ "id": "deepshieldharmonics","name": "Deep Shield Harmonics", "field": "forcefields", "tier": 7, "prereqs": ["deflectorvii"], "cost": 9500, "iconFrame": 71, "desc": "Ship-side shield regenerators. Absorbs 6 points from every incoming hit.", "effects": { "shield": 6 } },
{ "id": "deflectorx", "name": "Class X Deflector", "field": "forcefields", "tier": 8, "prereqs": ["planetaryshield15"], "cost": 15000, "iconFrame": 34, "desc": "Absorbs 7 points from every incoming hit.", "effects": { "shield": 7 }, "gnnHeadline": true },
{ "id": "harmonicdeflectorarray","name": "Harmonic Deflector Array","field": "forcefields","tier": 8, "prereqs": ["planetaryshield15"], "cost": 15000, "iconFrame": 86, "desc": "Shield harmonics tuned to scramble infiltration attempts. +18 counter-espionage empire-wide, though the array trades depth of coverage for breadth.", "effects": { "counterEspionage": 18 } },
{ "id": "blackholegenerator", "name": "Black Hole Generator", "field": "forcefields", "tier": 9, "prereqs": ["deflectorx"], "cost": 22000, "iconFrame": 35, "desc": "Folds enemy hulls into a singularity. Devastating in battle.", "effects": { "shield": 9, "singularity": true }, "gnnHeadline": true },
{ "id": "deflectori", "name": "Class I Deflector Shield","field": "forcefields","tier": 0, "prereqs": [], "cost": 80, "iconFrame": 20, "desc": "Absorbs 1 point from every incoming hit.", "effects": { "shield": 1 } },
{ "id": "personalshield", "name": "Personal Shield", "field": "forcefields", "tier": 1, "prereqs": ["deflectori"], "cost": 200, "iconFrame": 21, "desc": "Shielded infantry. +10 to ground combat on attack and defence.","effects": { "groundAttack": 10, "groundDefense": 10 } },
{ "id": "pointdefensegrid", "name": "Point-Defense Grid", "field": "forcefields", "tier": 1, "prereqs": ["deflectori"], "cost": 200, "iconFrame": 64, "desc": "Automated point-defense turrets. Fleets react a beat faster in combat.", "effects": { "initiative": 1 } },
{ "id": "deflectoriii", "name": "Class III Deflector", "field": "forcefields", "tier": 2, "prereqs": ["personalshield"], "cost": 500, "iconFrame": 22, "desc": "Absorbs 2 points from every incoming hit.", "effects": { "shield": 2 } },
{ "id": "modulardeflectorcoils","name": "Modular Deflector Coils","field": "forcefields", "tier": 2, "prereqs": ["personalshield"], "cost": 500, "iconFrame": 78, "desc": "Standardised shield generators drop into any hull. Fleets refit for 15% less, though the coils themselves absorb no extra damage.", "effects": { "refitCostMult": 0.85 } },
{ "id": "planetaryshield5", "name": "Planetary Shield V", "field": "forcefields", "tier": 3, "prereqs": ["deflectoriii"], "cost": 1100, "iconFrame": 23, "desc": "Colonies shrug off 5 points from every orbital bombardment.", "effects": { "planetaryShield": 5 } },
{ "id": "deflectorv", "name": "Class V Deflector", "field": "forcefields", "tier": 4, "prereqs": ["planetaryshield5"], "cost": 2100, "iconFrame": 24, "desc": "Absorbs 3 points from every incoming hit.", "effects": { "shield": 3 } },
{ "id": "stealthfield", "name": "Stealth Field", "field": "forcefields", "tier": 5, "prereqs": ["deflectorv"], "cost": 3600, "iconFrame": 25, "desc": "Cloaked hulls. Fleets move unseen and strike first.", "effects": { "cloaked": true, "initiative": 3 }, "gnnHeadline": true },
{ "id": "colonialdeflectorgrid","name": "Colonial Deflector Grid","field": "forcefields", "tier": 5, "prereqs": ["deflectorv"], "cost": 3600, "iconFrame": 79, "desc": "Extends deflector coverage down to the colony surface. Absorbs 8 points from every orbital bombardment, though fleets stay visible on enemy scopes.", "effects": { "planetaryShield": 8 } },
{ "id": "deflectorvii", "name": "Class VII Deflector", "field": "forcefields", "tier": 6, "prereqs": ["stealthfield"], "cost": 6000, "iconFrame": 26, "desc": "Absorbs 5 points from every incoming hit.", "effects": { "shield": 5 } },
{ "id": "planetaryshield15", "name": "Planetary Shield XV", "field": "forcefields", "tier": 7, "prereqs": ["deflectorvii"], "cost": 9500, "iconFrame": 27, "desc": "Colonies shrug off 15 points from every orbital bombardment.", "effects": { "planetaryShield": 15 } },
{ "id": "deepshieldharmonics","name": "Deep Shield Harmonics", "field": "forcefields", "tier": 7, "prereqs": ["deflectorvii"], "cost": 9500, "iconFrame": 65, "desc": "Ship-side shield regenerators. Absorbs 6 points from every incoming hit.", "effects": { "shield": 6 } },
{ "id": "deflectorx", "name": "Class X Deflector", "field": "forcefields", "tier": 8, "prereqs": ["planetaryshield15"], "cost": 15000, "iconFrame": 28, "desc": "Absorbs 7 points from every incoming hit.", "effects": { "shield": 7 }, "gnnHeadline": true },
{ "id": "harmonicdeflectorarray","name": "Harmonic Deflector Array","field": "forcefields","tier": 8, "prereqs": ["planetaryshield15"], "cost": 15000, "iconFrame": 80, "desc": "Shield harmonics tuned to scramble infiltration attempts. +18 counter-espionage empire-wide, though the array trades depth of coverage for breadth.", "effects": { "counterEspionage": 18 } },
{ "id": "blackholegenerator", "name": "Black Hole Generator", "field": "forcefields", "tier": 9, "prereqs": ["deflectorx"], "cost": 22000, "iconFrame": 29, "desc": "Folds enemy hulls into a singularity. Devastating in battle.", "effects": { "shield": 9, "singularity": true }, "gnnHeadline": true },
{ "id": "ecorestoration", "name": "Ecological Restoration","field": "planetology", "tier": 0, "prereqs": [], "cost": 70, "iconFrame": 36, "desc": "Industrial waste costs 25% less to clean up.", "effects": { "wasteMult": 0.75 } },
{ "id": "controlledbarren", "name": "Controlled Barren Environment","field": "planetology","tier": 1,"prereqs": ["ecorestoration"], "cost": 180, "iconFrame": 37, "desc": "Sealed habitats let you colonise barren worlds.", "effects": { "colonizeHostility": 1 } },
{ "id": "preemptivewastefiltration","name": "Preemptive Waste Filtration","field": "planetology","tier": 1,"prereqs": ["ecorestoration"], "cost": 180, "iconFrame": 87, "desc": "Filters industrial runoff before it ever reaches the surface. Waste cleanup costs another 15% less, though it does nothing to open up barren worlds.", "effects": { "wasteMult": 0.65 } },
{ "id": "enhancedeco", "name": "Enhanced Eco Restoration","field": "planetology","tier": 2, "prereqs": ["controlledbarren"], "cost": 460, "iconFrame": 38, "desc": "Industrial waste costs 50% less to clean up.", "effects": { "wasteMult": 0.5 } },
{ "id": "biosphereresearchinitiative","name": "Biosphere Research Initiative","field": "planetology","tier": 2,"prereqs": ["controlledbarren"], "cost": 460, "iconFrame": 88, "desc": "Terraforming survey data doubles as pure science. Research output up 8%, though colonies keep producing waste at the old rate.", "effects": { "researchMult": 1.08 } },
{ "id": "controlleddead", "name": "Controlled Dead Environment","field": "planetology","tier": 3,"prereqs": ["enhancedeco"], "cost": 1000, "iconFrame": 39, "desc": "Colonise dead worlds stripped of atmosphere.", "effects": { "colonizeHostility": 2 } },
{ "id": "soilenrichment", "name": "Soil Enrichment", "field": "planetology", "tier": 4, "prereqs": ["controlleddead"], "cost": 2000, "iconFrame": 40, "desc": "Every colony supports 15 more population.", "effects": { "maxPopBonus": 15 } },
{ "id": "efficientwastereclamation","name": "Efficient Waste Reclamation","field": "planetology","tier": 4,"prereqs": ["controlleddead"], "cost": 2000, "iconFrame": 72, "desc": "Industrial waste is recycled almost as fast as it's produced. Cleanup costs 40% less.", "effects": { "wasteMult": 0.6 } },
{ "id": "controlledinferno", "name": "Controlled Inferno Environment","field": "planetology","tier": 5,"prereqs": ["soilenrichment"], "cost": 3500, "iconFrame": 41, "desc": "Colonise inferno worlds.", "effects": { "colonizeHostility": 3 } },
{ "id": "atmosphericterraforming","name": "Atmospheric Terraforming","field": "planetology","tier": 6,"prereqs": ["controlledinferno"], "cost": 5800, "iconFrame": 42, "desc": "Every colony supports 25 more population.", "effects": { "maxPopBonus": 25 }, "gnnHeadline": true },
{ "id": "xenobotanicalresearchstations","name": "Xenobotanical Research Stations","field": "planetology","tier": 6,"prereqs": ["controlledinferno"], "cost": 5800, "iconFrame": 89, "desc": "Terraforming labs double as pure research outposts. Research output up 10%, though colonies grow no larger for it.", "effects": { "researchMult": 1.1 } },
{ "id": "controlledtoxic", "name": "Controlled Toxic Environment","field": "planetology","tier": 7,"prereqs": ["atmosphericterraforming"],"cost": 9200,"iconFrame": 43,"desc": "Colonise toxic worlds.", "effects": { "colonizeHostility": 4 } },
{ "id": "advancedsoil", "name": "Advanced Soil Enrichment","field": "planetology","tier": 8, "prereqs": ["controlledtoxic"], "cost": 14500, "iconFrame": 44, "desc": "Every colony supports 40 more population.", "effects": { "maxPopBonus": 40 } },
{ "id": "deepterraforming", "name": "Deep Terraforming", "field": "planetology", "tier": 8, "prereqs": ["controlledtoxic"], "cost": 14500, "iconFrame": 73, "desc": "Near-total reclamation. Industrial waste costs 65% less to clean up.", "effects": { "wasteMult": 0.35 }, "gnnHeadline": true },
{ "id": "controlledradiated", "name": "Controlled Radiated Environment","field": "planetology","tier": 9,"prereqs": ["advancedsoil"], "cost": 21500, "iconFrame": 45, "desc": "Colonise radiated worlds. Nowhere in the galaxy is closed.", "effects": { "colonizeHostility": 5, "maxPopBonus": 15 }, "gnnHeadline": true },
{ "id": "ecorestoration", "name": "Ecological Restoration","field": "planetology", "tier": 0, "prereqs": [], "cost": 70, "iconFrame": 30, "desc": "Industrial waste costs 25% less to clean up.", "effects": { "wasteMult": 0.75 } },
{ "id": "controlledbarren", "name": "Controlled Barren Environment","field": "planetology","tier": 1,"prereqs": ["ecorestoration"], "cost": 180, "iconFrame": 31, "desc": "Sealed habitats let you colonise barren worlds.", "effects": { "colonizeHostility": 1 } },
{ "id": "preemptivewastefiltration","name": "Preemptive Waste Filtration","field": "planetology","tier": 1,"prereqs": ["ecorestoration"], "cost": 180, "iconFrame": 81, "desc": "Filters industrial runoff before it ever reaches the surface. Waste cleanup costs another 15% less, though it does nothing to open up barren worlds.", "effects": { "wasteMult": 0.65 } },
{ "id": "enhancedeco", "name": "Enhanced Eco Restoration","field": "planetology","tier": 2, "prereqs": ["controlledbarren"], "cost": 460, "iconFrame": 32, "desc": "Industrial waste costs 50% less to clean up.", "effects": { "wasteMult": 0.5 } },
{ "id": "biosphereresearchinitiative","name": "Biosphere Research Initiative","field": "planetology","tier": 2,"prereqs": ["controlledbarren"], "cost": 460, "iconFrame": 82, "desc": "Terraforming survey data doubles as pure science. Research output up 8%, though colonies keep producing waste at the old rate.", "effects": { "researchMult": 1.08 } },
{ "id": "controlleddead", "name": "Controlled Dead Environment","field": "planetology","tier": 3,"prereqs": ["enhancedeco"], "cost": 1000, "iconFrame": 33, "desc": "Colonise dead worlds stripped of atmosphere.", "effects": { "colonizeHostility": 2 } },
{ "id": "soilenrichment", "name": "Soil Enrichment", "field": "planetology", "tier": 4, "prereqs": ["controlleddead"], "cost": 2000, "iconFrame": 34, "desc": "Every colony supports 15 more population.", "effects": { "maxPopBonus": 15 } },
{ "id": "efficientwastereclamation","name": "Efficient Waste Reclamation","field": "planetology","tier": 4,"prereqs": ["controlleddead"], "cost": 2000, "iconFrame": 66, "desc": "Industrial waste is recycled almost as fast as it's produced. Cleanup costs 40% less.", "effects": { "wasteMult": 0.6 } },
{ "id": "controlledinferno", "name": "Controlled Inferno Environment","field": "planetology","tier": 5,"prereqs": ["soilenrichment"], "cost": 3500, "iconFrame": 35, "desc": "Colonise inferno worlds.", "effects": { "colonizeHostility": 3 } },
{ "id": "atmosphericterraforming","name": "Atmospheric Terraforming","field": "planetology","tier": 6,"prereqs": ["controlledinferno"], "cost": 5800, "iconFrame": 36, "desc": "Every colony supports 25 more population.", "effects": { "maxPopBonus": 25 }, "gnnHeadline": true },
{ "id": "xenobotanicalresearchstations","name": "Xenobotanical Research Stations","field": "planetology","tier": 6,"prereqs": ["controlledinferno"], "cost": 5800, "iconFrame": 83, "desc": "Terraforming labs double as pure research outposts. Research output up 10%, though colonies grow no larger for it.", "effects": { "researchMult": 1.1 } },
{ "id": "controlledtoxic", "name": "Controlled Toxic Environment","field": "planetology","tier": 7,"prereqs": ["atmosphericterraforming"],"cost": 9200,"iconFrame": 37,"desc": "Colonise toxic worlds.", "effects": { "colonizeHostility": 4 } },
{ "id": "advancedsoil", "name": "Advanced Soil Enrichment","field": "planetology","tier": 8, "prereqs": ["controlledtoxic"], "cost": 14500, "iconFrame": 38, "desc": "Every colony supports 40 more population.", "effects": { "maxPopBonus": 40 } },
{ "id": "deepterraforming", "name": "Deep Terraforming", "field": "planetology", "tier": 8, "prereqs": ["controlledtoxic"], "cost": 14500, "iconFrame": 67, "desc": "Near-total reclamation. Industrial waste costs 65% less to clean up.", "effects": { "wasteMult": 0.35 }, "gnnHeadline": true },
{ "id": "controlledradiated", "name": "Controlled Radiated Environment","field": "planetology","tier": 9,"prereqs": ["advancedsoil"], "cost": 21500, "iconFrame": 39, "desc": "Colonise radiated worlds. Nowhere in the galaxy is closed.", "effects": { "colonizeHostility": 5, "maxPopBonus": 15 }, "gnnHeadline": true },
{ "id": "nuclearengines", "name": "Nuclear Engines", "field": "propulsion", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 46, "desc": "Warp 1 drives. Fleets crawl, but they cross the dark.", "effects": { "engine": { "name": "Nuclear", "speed": 1 } } },
{ "id": "deuteriumcells", "name": "Deuterium Fuel Cells", "field": "propulsion", "tier": 1, "prereqs": ["nuclearengines"], "cost": 170, "iconFrame": 47, "desc": "Fuel range 7 parsecs. The map opens a little.", "effects": { "fuelRange": 7 } },
{ "id": "reactionthrusters", "name": "Reaction Thrusters", "field": "propulsion", "tier": 1, "prereqs": ["nuclearengines"], "cost": 170, "iconFrame": 74, "desc": "Snap-fire manoeuvring jets. Fleets react a beat faster in combat, though range stays short.", "effects": { "initiative": 1 } },
{ "id": "fusiondrive", "name": "Fusion Drive", "field": "propulsion", "tier": 2, "prereqs": ["deuteriumcells"], "cost": 440, "iconFrame": 48, "desc": "Warp 2 drives.", "effects": { "engine": { "name": "Fusion", "speed": 2 } } },
{ "id": "auxiliaryfueltanks", "name": "Auxiliary Fuel Tanks", "field": "propulsion", "tier": 2, "prereqs": ["deuteriumcells"], "cost": 440, "iconFrame": 90, "desc": "Bolt-on tankage extends the envelope. Fuel range 8 parsecs, though the hull still tops out at Warp 1.", "effects": { "fuelRange": 8 } },
{ "id": "iridiumcells", "name": "Iridium Fuel Cells", "field": "propulsion", "tier": 3, "prereqs": ["fusiondrive"], "cost": 980, "iconFrame": 49, "desc": "Fuel range 9 parsecs.", "effects": { "fuelRange": 9 } },
{ "id": "sublightdrive", "name": "Sub-Light Drive", "field": "propulsion", "tier": 4, "prereqs": ["iridiumcells"], "cost": 2000, "iconFrame": 50, "desc": "Warp 3 drives.", "effects": { "engine": { "name": "Sub-Light", "speed": 3 } } },
{ "id": "uridiumcells", "name": "Uridium Fuel Cells", "field": "propulsion", "tier": 5, "prereqs": ["sublightdrive"], "cost": 3600, "iconFrame": 51, "desc": "Fuel range 12 parsecs. Half the galaxy lights up.", "effects": { "fuelRange": 12 } },
{ "id": "tachyondrivecoils", "name": "Tachyon Drive Coils", "field": "propulsion", "tier": 5, "prereqs": ["sublightdrive"], "cost": 3600, "iconFrame": 75, "desc": "Precognitive drive telemetry. A further edge in combat tempo, at the cost of range.", "effects": { "initiative": 2 } },
{ "id": "impulsedrive", "name": "Impulse Drive", "field": "propulsion", "tier": 6, "prereqs": ["uridiumcells"], "cost": 5900, "iconFrame": 52, "desc": "Warp 4 drives.", "effects": { "engine": { "name": "Impulse", "speed": 4 } } },
{ "id": "deepscanrelays", "name": "Deep Scan Relays", "field": "propulsion", "tier": 6, "prereqs": ["uridiumcells"], "cost": 5900, "iconFrame": 91, "desc": "Automated relay buoys dropped along your travel lanes extend detection well past the fleet itself. +2 scan range empire-wide, though the drive itself stays at Warp 3.", "effects": { "scanRange": 2 } },
{ "id": "thoriumcells", "name": "Thorium Fuel Cells", "field": "propulsion", "tier": 7, "prereqs": ["impulsedrive"], "cost": 9400, "iconFrame": 53, "desc": "Fuel range 18 parsecs. Range stops being a constraint.", "effects": { "fuelRange": 18 } },
{ "id": "interphaseddrive", "name": "Interphased Drive", "field": "propulsion", "tier": 8, "prereqs": ["thoriumcells"], "cost": 14800, "iconFrame": 54, "desc": "Warp 5 drives.", "effects": { "engine": { "name": "Interphased", "speed": 5 } }, "gnnHeadline": true },
{ "id": "deepspacefuelcells", "name": "Deep-Space Fuel Cells", "field": "propulsion", "tier": 8, "prereqs": ["thoriumcells"], "cost": 14800, "iconFrame": 92, "desc": "The last word in tankage before hyperspace renders range moot. Fuel range 22 parsecs, though the hull stays at Warp 4.", "effects": { "fuelRange": 22 } },
{ "id": "hyperspacecomms", "name": "Hyperspace Communications","field": "propulsion","tier": 9,"prereqs": ["interphaseddrive"], "cost": 21000, "iconFrame": 55, "desc": "Warp 6 drives and fleets redirected in mid-flight.", "effects": { "engine": { "name": "Hyperspace", "speed": 6 }, "redirectInFlight": true }, "gnnHeadline": true },
{ "id": "nuclearengines", "name": "Nuclear Engines", "field": "propulsion", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 40, "desc": "Warp 1 drives. Fleets crawl, but they cross the dark.", "effects": { "engine": { "name": "Nuclear", "speed": 1 } } },
{ "id": "deuteriumcells", "name": "Deuterium Fuel Cells", "field": "propulsion", "tier": 1, "prereqs": ["nuclearengines"], "cost": 170, "iconFrame": 41, "desc": "Fuel range 7 parsecs. The map opens a little.", "effects": { "fuelRange": 7 } },
{ "id": "reactionthrusters", "name": "Reaction Thrusters", "field": "propulsion", "tier": 1, "prereqs": ["nuclearengines"], "cost": 170, "iconFrame": 68, "desc": "Snap-fire manoeuvring jets. Fleets react a beat faster in combat, though range stays short.", "effects": { "initiative": 1 } },
{ "id": "fusiondrive", "name": "Fusion Drive", "field": "propulsion", "tier": 2, "prereqs": ["deuteriumcells"], "cost": 440, "iconFrame": 42, "desc": "Warp 2 drives.", "effects": { "engine": { "name": "Fusion", "speed": 2 } } },
{ "id": "auxiliaryfueltanks", "name": "Auxiliary Fuel Tanks", "field": "propulsion", "tier": 2, "prereqs": ["deuteriumcells"], "cost": 440, "iconFrame": 84, "desc": "Bolt-on tankage extends the envelope. Fuel range 8 parsecs, though the hull still tops out at Warp 1.", "effects": { "fuelRange": 8 } },
{ "id": "iridiumcells", "name": "Iridium Fuel Cells", "field": "propulsion", "tier": 3, "prereqs": ["fusiondrive"], "cost": 980, "iconFrame": 43, "desc": "Fuel range 9 parsecs.", "effects": { "fuelRange": 9 } },
{ "id": "sublightdrive", "name": "Sub-Light Drive", "field": "propulsion", "tier": 4, "prereqs": ["iridiumcells"], "cost": 2000, "iconFrame": 44, "desc": "Warp 3 drives.", "effects": { "engine": { "name": "Sub-Light", "speed": 3 } } },
{ "id": "uridiumcells", "name": "Uridium Fuel Cells", "field": "propulsion", "tier": 5, "prereqs": ["sublightdrive"], "cost": 3600, "iconFrame": 45, "desc": "Fuel range 12 parsecs. Half the galaxy lights up.", "effects": { "fuelRange": 12 } },
{ "id": "tachyondrivecoils", "name": "Tachyon Drive Coils", "field": "propulsion", "tier": 5, "prereqs": ["sublightdrive"], "cost": 3600, "iconFrame": 69, "desc": "Precognitive drive telemetry. A further edge in combat tempo, at the cost of range.", "effects": { "initiative": 2 } },
{ "id": "impulsedrive", "name": "Impulse Drive", "field": "propulsion", "tier": 6, "prereqs": ["uridiumcells"], "cost": 5900, "iconFrame": 46, "desc": "Warp 4 drives.", "effects": { "engine": { "name": "Impulse", "speed": 4 } } },
{ "id": "deepscanrelays", "name": "Deep Scan Relays", "field": "propulsion", "tier": 6, "prereqs": ["uridiumcells"], "cost": 5900, "iconFrame": 85, "desc": "Automated relay buoys dropped along your travel lanes extend detection well past the fleet itself. +2 scan range empire-wide, though the drive itself stays at Warp 3.", "effects": { "scanRange": 2 } },
{ "id": "thoriumcells", "name": "Thorium Fuel Cells", "field": "propulsion", "tier": 7, "prereqs": ["impulsedrive"], "cost": 9400, "iconFrame": 47, "desc": "Fuel range 18 parsecs. Range stops being a constraint.", "effects": { "fuelRange": 18 } },
{ "id": "interphaseddrive", "name": "Interphased Drive", "field": "propulsion", "tier": 8, "prereqs": ["thoriumcells"], "cost": 14800, "iconFrame": 48, "desc": "Warp 5 drives.", "effects": { "engine": { "name": "Interphased", "speed": 5 } }, "gnnHeadline": true },
{ "id": "deepspacefuelcells", "name": "Deep-Space Fuel Cells", "field": "propulsion", "tier": 8, "prereqs": ["thoriumcells"], "cost": 14800, "iconFrame": 86, "desc": "The last word in tankage before hyperspace renders range moot. Fuel range 22 parsecs, though the hull stays at Warp 4.", "effects": { "fuelRange": 22 } },
{ "id": "hyperspacecomms", "name": "Hyperspace Communications","field": "propulsion","tier": 9,"prereqs": ["interphaseddrive"], "cost": 21000, "iconFrame": 49, "desc": "Warp 6 drives and fleets redirected in mid-flight.", "effects": { "engine": { "name": "Hyperspace", "speed": 6 }, "redirectInFlight": true }, "gnnHeadline": true },
{ "id": "lasercannon", "name": "Laser Cannon", "field": "weapons", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 56, "desc": "The first real gun. 1-4 damage.", "effects": { "weapon": { "id": "laser", "name": "Laser Cannon", "kind": "beam", "min": 1, "max": 4, "shots": 1, "space": 2, "cost": 8 } }, "gnnHeadline": true },
{ "id": "hypervrockets", "name": "Hyper-V Rockets", "field": "weapons", "tier": 1, "prereqs": ["lasercannon"], "cost": 160, "iconFrame": 57, "desc": "Cheap standoff missiles. 6 damage, 2 salvoes.", "effects": { "weapon": { "id": "hyperv", "name": "Hyper-V Rocket", "kind": "missile", "min": 6, "max": 6, "shots": 4, "space": 4, "cost": 10 } } },
{ "id": "pulselaser", "name": "Pulse Laser", "field": "weapons", "tier": 1, "prereqs": ["lasercannon"], "cost": 160, "iconFrame": 93, "desc": "A faster-cycling beam. 2-5 damage every round, forever — no rack to run empty.", "effects": { "weapon": { "id": "pulselaser", "name": "Pulse Laser", "kind": "beam", "min": 2, "max": 5, "shots": 1, "space": 3, "cost": 9 } } },
{ "id": "neutronpellet", "name": "Neutron Pellet Gun", "field": "weapons", "tier": 2, "prereqs": ["hypervrockets"], "cost": 430, "iconFrame": 58, "desc": "Shield-piercing pellets. 2-6 damage.", "effects": { "weapon": { "id": "pellet", "name": "Neutron Pellet Gun", "kind": "beam", "min": 2, "max": 6, "shots": 1, "space": 3, "cost": 12, "shieldPierce": 2 } } },
{ "id": "protontorpedoes", "name": "Proton Torpedoes", "field": "weapons", "tier": 2, "prereqs": ["hypervrockets"], "cost": 430, "iconFrame": 76, "desc": "Slow, but heavier warheads than Hyper-V. 10 damage, 2 salvoes.", "effects": { "weapon": { "id": "protontorpedo", "name": "Proton Torpedo", "kind": "missile", "min": 10, "max": 10, "shots": 2, "space": 5, "cost": 14 } } },
{ "id": "ioncannon", "name": "Ion Cannon", "field": "weapons", "tier": 3, "prereqs": ["neutronpellet"], "cost": 960, "iconFrame": 59, "desc": "3-8 damage.", "effects": { "weapon": { "id": "ion", "name": "Ion Cannon", "kind": "beam", "min": 3, "max": 8, "shots": 1, "space": 4, "cost": 18 } } },
{ "id": "merculitemissiles", "name": "Merculite Missiles", "field": "weapons", "tier": 4, "prereqs": ["ioncannon"], "cost": 1950, "iconFrame": 60, "desc": "10 damage, 2 salvoes, long reach.", "effects": { "weapon": { "id": "merculite", "name": "Merculite Missile", "kind": "missile", "min": 10, "max": 10, "shots": 4, "space": 6, "cost": 20 } } },
{ "id": "disruptorcannon", "name": "Disruptor Cannon", "field": "weapons", "tier": 4, "prereqs": ["ioncannon"], "cost": 1950, "iconFrame": 94, "desc": "Phase-shifted beam bleeds straight through deflectors. 4-9 damage, ignores 1 point of shield.", "effects": { "weapon": { "id": "disruptor", "name": "Disruptor Cannon", "kind": "beam", "min": 4, "max": 9, "shots": 1, "space": 5, "cost": 22, "shieldPierce": 1 } } },
{ "id": "neutronblaster", "name": "Neutron Blaster", "field": "weapons", "tier": 5, "prereqs": ["merculitemissiles"], "cost": 3400, "iconFrame": 61, "desc": "5-12 damage and it kills crew through the hull.", "effects": { "weapon": { "id": "neutronblaster", "name": "Neutron Blaster", "kind": "beam", "min": 5, "max": 12, "shots": 1, "space": 5, "cost": 30, "shieldPierce": 1 } } },
{ "id": "hyperxrockets", "name": "Hyper-X Rockets", "field": "weapons", "tier": 6, "prereqs": ["neutronblaster"], "cost": 5700, "iconFrame": 62, "desc": "14 damage, 3 salvoes.", "effects": { "weapon": { "id": "hyperx", "name": "Hyper-X Rocket", "kind": "missile", "min": 16, "max": 16, "shots": 5, "space": 8, "cost": 32 } } },
{ "id": "gravitonbeam", "name": "Graviton Beam", "field": "weapons", "tier": 6, "prereqs": ["neutronblaster"], "cost": 5700, "iconFrame": 77, "desc": "Warps space directly onto the target. 12-20 damage, punches through shields.", "effects": { "weapon": { "id": "gravitonbeam", "name": "Graviton Beam", "kind": "beam", "min": 12, "max": 20, "shots": 1, "space": 7, "cost": 34, "shieldPierce": 2 } } },
{ "id": "fusionbeam", "name": "Fusion Beam", "field": "weapons", "tier": 7, "prereqs": ["hyperxrockets"], "cost": 9100, "iconFrame": 63, "desc": "8-16 damage.", "effects": { "weapon": { "id": "fusionbeam", "name": "Fusion Beam", "kind": "beam", "min": 8, "max": 16, "shots": 1, "space": 6, "cost": 45 } } },
{ "id": "deathray", "name": "Death Ray", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 64, "desc": "15-40 damage. Fleets evaporate.", "effects": { "weapon": { "id": "deathray", "name": "Death Ray", "kind": "beam", "min": 15, "max": 40, "shots": 1, "space": 12, "cost": 90 } }, "gnnHeadline": true },
{ "id": "antimattertorpedoes","name": "Antimatter Torpedoes", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 95, "desc": "Warhead payloads with a dozen years of R&D behind them. 22 damage, 4 salvoes, then the racks are empty.", "effects": { "weapon": { "id": "antimattertorpedo", "name": "Antimatter Torpedo", "kind": "missile", "min": 22, "max": 22, "shots": 4, "space": 10, "cost": 70 } } },
{ "id": "disintegratorbeam", "name": "Disintegrator Beam", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 96, "desc": "A narrower beam than Death Ray, but tuned to punch clean through deflector harmonics. 10-22 damage, ignores 3 points of shield.", "effects": { "weapon": { "id": "disintegratorbeam", "name": "Disintegrator Beam", "kind": "beam", "min": 10, "max": 22, "shots": 1, "space": 9, "cost": 60, "shieldPierce": 3 } } },
{ "id": "stellarconverter", "name": "Stellar Converter", "field": "weapons", "tier": 9, "prereqs": ["deathray"], "cost": 21000, "iconFrame": 65, "desc": "30-70 damage, and it can crack a colony from orbit.", "effects": { "weapon": { "id": "stellarconverter", "name": "Stellar Converter", "kind": "beam", "min": 30, "max": 70, "shots": 1, "space": 20, "cost": 150, "planetCracker": true } }, "gnnHeadline": true }
{ "id": "lasercannon", "name": "Laser Cannon", "field": "weapons", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 50, "desc": "The first real gun. 1-4 damage.", "effects": { "weapon": { "id": "laser", "name": "Laser Cannon", "kind": "beam", "min": 1, "max": 4, "shots": 1, "space": 2, "cost": 8 } }, "gnnHeadline": true },
{ "id": "hypervrockets", "name": "Hyper-V Rockets", "field": "weapons", "tier": 1, "prereqs": ["lasercannon"], "cost": 160, "iconFrame": 51, "desc": "Cheap standoff missiles. 6 damage, 2 salvoes.", "effects": { "weapon": { "id": "hyperv", "name": "Hyper-V Rocket", "kind": "missile", "min": 6, "max": 6, "shots": 4, "space": 4, "cost": 10 } } },
{ "id": "pulselaser", "name": "Pulse Laser", "field": "weapons", "tier": 1, "prereqs": ["lasercannon"], "cost": 160, "iconFrame": 87, "desc": "A faster-cycling beam. 2-5 damage every round, forever — no rack to run empty.", "effects": { "weapon": { "id": "pulselaser", "name": "Pulse Laser", "kind": "beam", "min": 2, "max": 5, "shots": 1, "space": 3, "cost": 9 } } },
{ "id": "neutronpellet", "name": "Neutron Pellet Gun", "field": "weapons", "tier": 2, "prereqs": ["hypervrockets"], "cost": 430, "iconFrame": 52, "desc": "Shield-piercing pellets. 2-6 damage.", "effects": { "weapon": { "id": "pellet", "name": "Neutron Pellet Gun", "kind": "beam", "min": 2, "max": 6, "shots": 1, "space": 3, "cost": 12, "shieldPierce": 2 } } },
{ "id": "protontorpedoes", "name": "Proton Torpedoes", "field": "weapons", "tier": 2, "prereqs": ["hypervrockets"], "cost": 430, "iconFrame": 70, "desc": "Slow, but heavier warheads than Hyper-V. 10 damage, 2 salvoes.", "effects": { "weapon": { "id": "protontorpedo", "name": "Proton Torpedo", "kind": "missile", "min": 10, "max": 10, "shots": 2, "space": 5, "cost": 14 } } },
{ "id": "ioncannon", "name": "Ion Cannon", "field": "weapons", "tier": 3, "prereqs": ["neutronpellet"], "cost": 960, "iconFrame": 53, "desc": "3-8 damage.", "effects": { "weapon": { "id": "ion", "name": "Ion Cannon", "kind": "beam", "min": 3, "max": 8, "shots": 1, "space": 4, "cost": 18 } } },
{ "id": "merculitemissiles", "name": "Merculite Missiles", "field": "weapons", "tier": 4, "prereqs": ["ioncannon"], "cost": 1950, "iconFrame": 54, "desc": "10 damage, 2 salvoes, long reach.", "effects": { "weapon": { "id": "merculite", "name": "Merculite Missile", "kind": "missile", "min": 10, "max": 10, "shots": 4, "space": 6, "cost": 20 } } },
{ "id": "disruptorcannon", "name": "Disruptor Cannon", "field": "weapons", "tier": 4, "prereqs": ["ioncannon"], "cost": 1950, "iconFrame": 88, "desc": "Phase-shifted beam bleeds straight through deflectors. 4-9 damage, ignores 1 point of shield.", "effects": { "weapon": { "id": "disruptor", "name": "Disruptor Cannon", "kind": "beam", "min": 4, "max": 9, "shots": 1, "space": 5, "cost": 22, "shieldPierce": 1 } } },
{ "id": "neutronblaster", "name": "Neutron Blaster", "field": "weapons", "tier": 5, "prereqs": ["merculitemissiles"], "cost": 3400, "iconFrame": 55, "desc": "5-12 damage and it kills crew through the hull.", "effects": { "weapon": { "id": "neutronblaster", "name": "Neutron Blaster", "kind": "beam", "min": 5, "max": 12, "shots": 1, "space": 5, "cost": 30, "shieldPierce": 1 } } },
{ "id": "hyperxrockets", "name": "Hyper-X Rockets", "field": "weapons", "tier": 6, "prereqs": ["neutronblaster"], "cost": 5700, "iconFrame": 56, "desc": "14 damage, 3 salvoes.", "effects": { "weapon": { "id": "hyperx", "name": "Hyper-X Rocket", "kind": "missile", "min": 16, "max": 16, "shots": 5, "space": 8, "cost": 32 } } },
{ "id": "gravitonbeam", "name": "Graviton Beam", "field": "weapons", "tier": 6, "prereqs": ["neutronblaster"], "cost": 5700, "iconFrame": 71, "desc": "Warps space directly onto the target. 12-20 damage, punches through shields.", "effects": { "weapon": { "id": "gravitonbeam", "name": "Graviton Beam", "kind": "beam", "min": 12, "max": 20, "shots": 1, "space": 7, "cost": 34, "shieldPierce": 2 } } },
{ "id": "fusionbeam", "name": "Fusion Beam", "field": "weapons", "tier": 7, "prereqs": ["hyperxrockets"], "cost": 9100, "iconFrame": 57, "desc": "8-16 damage.", "effects": { "weapon": { "id": "fusionbeam", "name": "Fusion Beam", "kind": "beam", "min": 8, "max": 16, "shots": 1, "space": 6, "cost": 45 } } },
{ "id": "deathray", "name": "Death Ray", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 58, "desc": "15-40 damage. Fleets evaporate.", "effects": { "weapon": { "id": "deathray", "name": "Death Ray", "kind": "beam", "min": 15, "max": 40, "shots": 1, "space": 12, "cost": 90 } }, "gnnHeadline": true },
{ "id": "antimattertorpedoes","name": "Antimatter Torpedoes", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 89, "desc": "Warhead payloads with a dozen years of R&D behind them. 22 damage, 4 salvoes, then the racks are empty.", "effects": { "weapon": { "id": "antimattertorpedo", "name": "Antimatter Torpedo", "kind": "missile", "min": 22, "max": 22, "shots": 4, "space": 10, "cost": 70 } } },
{ "id": "disintegratorbeam", "name": "Disintegrator Beam", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 90, "desc": "A narrower beam than Death Ray, but tuned to punch clean through deflector harmonics. 10-22 damage, ignores 3 points of shield.", "effects": { "weapon": { "id": "disintegratorbeam", "name": "Disintegrator Beam", "kind": "beam", "min": 10, "max": 22, "shots": 1, "space": 9, "cost": 60, "shieldPierce": 3 } } },
{ "id": "stellarconverter", "name": "Stellar Converter", "field": "weapons", "tier": 9, "prereqs": ["deathray"], "cost": 21000, "iconFrame": 59, "desc": "30-70 damage, and it can crack a colony from orbit.", "effects": { "weapon": { "id": "stellarconverter", "name": "Stellar Converter", "kind": "beam", "min": 30, "max": 70, "shots": 1, "space": 20, "cost": 150, "planetCracker": true } }, "gnnHeadline": true }
],
"_hullsReadme": "Preset ship classes — there is no ship designer. space is the weapon capacity a Mark refit fills with the best available weapon; VegaShips.js derives every other stat from the owner's researched tech. sizeScale/sizeSpeedMult/turnRateBase (degrees/round)/brakeSeconds are only consumed by the VegaCombatV2 per-ship prototype (behind ?movsim's Live/V2 toggle) — the live engine (VegaCombat.js) ignores them entirely. brakeSeconds is how long (seconds) a hull takes to kill its own top speed under max thrust — small relative to a hull's speed means it can actually decelerate and hold position within weapon range (battleship/cruiser); large relative to speed means its stopping distance exceeds beamRange and it physically cannot stop before reaching a target, so it blows through and loops back for another pass instead (frigate/scout) — see VegaCombatV2.js's computeShipMove.",
@ -433,11 +433,16 @@
"attitudeThreshold": 45,
"aiProposeChance": 0.25
},
"warRequest": {
"_readme": "\"Please make peace with them\" — a request about a THIRD PARTY's war (VegaDiplomacy.js's wouldAcceptPeaceRequest/requestPeace/offerPeaceRequest). friendThreshold is how much the one being asked has to like the requester to grant it as a favor when they wouldn't otherwise; refuseBelowAttitude is how bad their own war with the third party can be before even a friend's plea is waved off. aiProposeChance is per-friend-per-turn, checked only once the human is already at war with that friend.",
"friendThreshold": 40,
"refuseBelowAttitude": -70,
"aiProposeChance": 0.15
},
"espionage": {
"_readme": "Sabotage magnitude and its attitude sting when a runEspionage roll succeeds with mission 'sabotage' (VegaLogic.js). Targeting/frequency/odds are unchanged from the steal-tech path; this only controls what a sabotage success does.",
"_readme": "Sabotage magnitude when a runEspionage roll succeeds with mission 'sabotage' (VegaLogic.js). Targeting/frequency/odds are unchanged from the steal-tech path; this only controls what a sabotage success does. A successful mission (sabotage or tech theft) carries no attitude penalty — nobody publicly knows who did it, so there's nobody for the victim to be angry at; getting CAUGHT is the one outcome that still costs relations.",
"sabotageFactoriesFraction": 0.25,
"sabotageDefenseFraction": 0.25,
"sabotageAttitudePenalty": 14
"sabotageDefenseFraction": 0.25
},
"borderTension": {
"colonyRange": 6,

View File

@ -29,7 +29,7 @@ import { openSystemView } from './VegaSystemView.js';
import { openCombatViewV2 } from './VegaCombatViewV2.js';
import {
FONT, D, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
openSaveScreen, openLoadScreen, showVictoryOverlay,
openSaveScreen, openLoadScreen, showVictoryOverlay, openFormationPicker,
} from './VegaScreens.js';
import { openColoniesScreen } from './VegaColoniesScreen.js';
import { openResearchScreen } from './VegaResearchScreen.js';
@ -1167,23 +1167,29 @@ export default class MasterOfVegaGame extends Phaser.Scene {
const next = (i) => {
if (i >= pending.length) { this.refreshAll(); done(); return; }
const { starIdx, other } = pending[i];
const prepared = Logic.prepareBattleAt(this.rules, this.state, starIdx, me, other);
if (!prepared) { next(i + 1); return; }
this.map?.panToStar(starIdx, 260);
this.modalOpen = true;
this.music?.setCategory('combat');
openCombatViewV2(this, this.rules, prepared.battle, this.art, {
attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId,
defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId,
playerSide: prepared.attackerIdx === me ? 'attacker' : 'defender',
onDone: (result) => {
Logic.applyBattleOutcome(this.rules, this.state, prepared, result);
this.modalOpen = false;
this.music?.setCategory('peace');
this.refreshAll();
next(i + 1);
},
});
// Forced (closable: false) — there's no sensible "cancel" once fleets
// are already committed to this fight. Only the human's own side is
// ever asked; the AI opponent always picks silently (Logic
// .prepareBattleAt's own comment).
openFormationPicker(this, (humanFormation) => {
const prepared = Logic.prepareBattleAt(this.rules, this.state, starIdx, me, other, { humanFormation });
if (!prepared) { this.modalOpen = false; next(i + 1); return; }
this.music?.setCategory('combat');
openCombatViewV2(this, this.rules, prepared.battle, this.art, {
attackerSpecies: this.state.empires[prepared.attackerIdx].speciesId,
defenderSpecies: this.state.empires[prepared.defenderIdx].speciesId,
playerSide: prepared.attackerIdx === me ? 'attacker' : 'defender',
onDone: (result) => {
Logic.applyBattleOutcome(this.rules, this.state, prepared, result);
this.modalOpen = false;
this.music?.setCategory('peace');
this.refreshAll();
next(i + 1);
},
});
}, { closable: false });
};
next(0);
}

View File

@ -45,6 +45,7 @@ import {
import { atWar, grantTech } from './VegaLogic.js';
import {
attitudeOf, moodOf, videoMood, canNegotiate, declareWar, proposeTreaty, respondToOffer, tradeTech, giveGift,
requestPeace,
} from './VegaDiplomacy.js';
import { CHAT, pickLine } from './VegaChat.js';
@ -535,10 +536,14 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
}
const openingOffer = state.empires[me].pendingOffers[otherIdx];
if (openingOffer) {
const key = openingOffer.kind === 'peace' ? chat.offerPeaceOpener
: openingOffer.kind === 'alliance' ? chat.offerAllianceOpener
: chat.offerTradeAgreementOpener;
pushChat('o', pickLine(key, vars));
if (openingOffer.kind === 'peaceRequest') {
pushChat('o', pickLine(chat.offerPeaceRequestOpener, { ...vars, third: state.empires[openingOffer.thirdParty]?.name }));
} else {
const key = openingOffer.kind === 'peace' ? chat.offerPeaceOpener
: openingOffer.kind === 'alliance' ? chat.offerAllianceOpener
: chat.offerTradeAgreementOpener;
pushChat('o', pickLine(key, vars));
}
}
// A fresh (this-turn-or-last) fleet complaint gets its own opener line,
@ -568,9 +573,29 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
}
function doPending(accept) {
pushChat('p', pickLine(accept ? chat.acceptOffer : chat.rejectOffer, vars));
const offer = state.empires[me].pendingOffers[otherIdx];
const isPeaceRequest = offer?.kind === 'peaceRequest';
const thirdVars = isPeaceRequest ? { ...vars, third: state.empires[offer.thirdParty]?.name } : vars;
pushChat('p', pickLine(
isPeaceRequest ? (accept ? chat.acceptPeaceRequest : chat.rejectPeaceRequest)
: (accept ? chat.acceptOffer : chat.rejectOffer),
thirdVars,
));
respondToOffer(rules, state, me, otherIdx, accept);
say(pickLine(accept ? chat.afterAccepted : chat.afterRejected, vars));
if (isPeaceRequest) {
// respondToOffer's own true/false means "was there an offer to
// answer," not "did the treaty succeed" (existing callers depend on
// that contract) — the actual outcome is read back off the treaty
// state instead, only when accepted.
const madePeace = accept && !atWar(state, me, offer.thirdParty);
say(pickLine(
!accept ? chat.afterPeaceRequestRejected
: madePeace ? chat.afterPeaceRequestAcceptedSuccess : chat.afterPeaceRequestAcceptedTried,
thirdVars,
));
} else {
say(pickLine(accept ? chat.afterAccepted : chat.afterRejected, thirdVars));
}
afterAction();
}
@ -628,6 +653,18 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
afterAction();
}
// Asking `otherIdx` to end a war with a THIRD empire — see
// VegaDiplomacy.js's requestPeace for why this is kept separate from
// proposeTreaty/wouldAccept (it's a favor about someone else's war, not a
// treaty between the two people actually talking).
function doRequestPeace(thirdPartyIdx) {
const reqVars = { ...vars, third: state.empires[thirdPartyIdx].name };
pushChat('p', pickLine(chat.requestPeaceThird, reqVars));
const accepted = requestPeace(rules, state, me, otherIdx, thirdPartyIdx);
say(pickLine(accepted ? chat.replyRequestPeaceAccept : chat.replyRequestPeaceReject, reqVars));
afterAction();
}
function doTechTrade(giveId, wantId) {
pushChat('p', pickLine(chat.tradeTechOffer, { ...vars, tech: rules.techs[wantId].name }));
const ok = tradeTech(rules, state, me, otherIdx, giveId, wantId);
@ -668,7 +705,10 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
say(pickLine(chat.storyLore, vars));
}
function pickFromList(title, ids, onPick) {
// `labelFn` defaults to the tech-name lookup every existing caller wants;
// doRequestWhichWar (below) is the one caller that isn't picking a tech,
// so it passes an empire-name lookup instead.
function pickFromList(title, ids, onPick, labelFn = (id) => rules.techs[id].name) {
const box = scene.add.container(0, 0);
root.add(box);
const bx = GAME_WIDTH / 2;
@ -678,7 +718,7 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
box.add(scene.add.rectangle(bx, by, 420, h, PANEL, 0.98).setStrokeStyle(2, ACCENT));
box.add(scene.add.text(bx, by - h / 2 + 26, title, { fontFamily: FONT, fontSize: '19px', color: '#ffd88a' }).setOrigin(0.5));
ids.slice(0, 12).forEach((id, i) => {
const t = scene.add.text(bx, by - h / 2 + 66 + i * 34, rules.techs[id].name, {
const t = scene.add.text(bx, by - h / 2 + 66 + i * 34, labelFn(id), {
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
t.on('pointerover', () => t.setColor('#ffd88a'));
@ -691,6 +731,12 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
box.add(cancel);
}
/** Opens the "which war?" picker for Demand Peace, listing every empire
* `otherIdx` is at war with (that a request could ever succeed against). */
function openWarPicker(warTargets) {
pickFromList('END WHICH WAR?', warTargets, doRequestPeace, (idx) => state.empires[idx].name);
}
function openTechPicker(giveOptions, wantOptions) {
pickFromList('OFFER WHICH TECHNOLOGY?', giveOptions, (giveId) => {
pickFromList('ASK FOR WHICH TECHNOLOGY?', wantOptions, (wantId) => doTechTrade(giveId, wantId));
@ -768,6 +814,14 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
const want = Object.keys(state.empires[otherIdx].known).filter((t) => !state.empires[me].known[t]);
if (give.length && want.length) items.push(['Trade Tech', () => openTechPicker(give, want)]);
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
// Wars otherIdx is fighting against a THIRD empire — one the request
// could ever actually reach (canNegotiate both ways), never our own
// war with them (that's Sue for Peace, above).
const theirWars = state.empires
.filter((o) => o.alive && o.idx !== me && o.idx !== otherIdx
&& atWar(state, otherIdx, o.idx) && canNegotiate(rules, state, otherIdx, o.idx))
.map((o) => o.idx);
if (theirWars.length) items.push(['Demand Peace', () => openWarPicker(theirWars)]);
}
const treatyRows = layoutRow(items, BTN_Y);
let nextY = BTN_Y + treatyRows * (BTN_H + BTN_GAP);

View File

@ -13,6 +13,8 @@
// {you} — the human empire's display name
// {me} — this species' empire name
// {tech} — a technology name, for the tech-trade situations
// {third} — a third empire's display name, for the "make peace with them"
// request situations
// Resolved (already-substituted) text is what VegaAudience.js pushes into
// state.chatLog, not the templates.
//
@ -36,6 +38,23 @@
// offer from this species.
// afterAccepted / afterRejected — this species' reaction to the player's
// answer to their own offer.
// requestPeaceThird / replyRequestPeaceAccept / replyRequestPeaceReject —
// the PLAYER asking THIS species to make peace with a third empire
// (VegaDiplomacy.js's requestPeace), and their reply. Uses a third
// token, {third}, alongside the usual {me}/{you}.
// offerPeaceRequestOpener — this species' own opening line when THEY ask
// the PLAYER to make peace with a third empire (offerPeaceRequest,
// held exactly like offerPeaceOpener/offerAllianceOpener above).
// acceptPeaceRequest / rejectPeaceRequest — the PLAYER's line answering
// that request (same Accept/Reject buttons a treaty offer uses —
// respondToOffer's 'peaceRequest' branch is what makes accepting mean
// "I'll propose peace to the third party," not a treaty with the asker).
// afterPeaceRequestAcceptedSuccess / afterPeaceRequestAcceptedTried —
// this species' reaction once the player agreed to try: Success if the
// third party actually accepted the resulting peace proposal, Tried if
// the third party turned it down anyway (not the player's fault).
// afterPeaceRequestRejected — this species' reaction to the player
// declining to even try.
//
// Additionally, three pure-flavor "conversation" topics, present for every
// diplomacy-capable species, with ZERO effect on attitude — gating lives in
@ -189,6 +208,42 @@ export const CHAT = {
'I\'ve got a standing proposal for you, {you} — a trade agreement, ongoing, good for us both.',
'Let\'s formalize something, {you}: regular trade, on the record.',
],
requestPeaceThird: [
'Do us both a favor, {me} — make peace with {third}. A war on your border is bad for trade on mine.',
'I\'d rather see you and {third} at peace, {me}. Fighting\'s bad for everyone\'s bottom line.',
],
replyRequestPeaceAccept: [
'You\'ve got a point, {you}. I\'ll open talks with {third}.',
'Fine, {you} — for you, I\'ll make the offer to {third}.',
],
replyRequestPeaceReject: [
'Not this time, {you}. My war with {third} isn\'t yours to settle.',
'I appreciate the thought, {you}, but {third} hasn\'t earned peace yet.',
],
offerPeaceRequestOpener: [
'A favor, {you}: make peace with {third}. It\'s bad business watching a friend bleed money on a war.',
'I\'m asking you to end things with {third}, {you}. Consider it a personal request.',
],
acceptPeaceRequest: [
'Fair enough, {me}. I\'ll make the offer to {third}.',
'For you, {me} — I\'ll try to settle things with {third}.',
],
rejectPeaceRequest: [
'Not this time, {me}. That war is mine to end, on my terms.',
'I\'ll pass, {me}. {third} hasn\'t earned it from me yet.',
],
afterPeaceRequestAcceptedSuccess: [
'Good business, {you}. Peace with {third} suits everyone\'s ledger.',
'Well done, {you} — {third} and I can both stop paying for this war now.',
],
afterPeaceRequestAcceptedTried: [
'Appreciated, {you}, even if {third} wasn\'t ready to listen.',
'You tried, {you} — that\'s worth something, even though {third} said no.',
],
afterPeaceRequestRejected: [
'Disappointing, {you}. I thought we understood each other better.',
'Noted, {you}. I won\'t forget you turned down a simple favor.',
],
fleetComplaintOpener: [
'Your fleet\'s been sitting over my colony a little too long, {you}. Care to explain, or just move it?',
'I\'ll be direct, {you}: that fleet parked over my world is bad for business. Move it.',
@ -357,6 +412,42 @@ export const CHAT = {
'The aerie proposes something lasting, {you} — a standing trade agreement, not merely a single exchange.',
'Let us formalize our commerce, {you}. I offer an ongoing arrangement.',
],
requestPeaceThird: [
'Fold your wings toward {third}, {me}. This war does not become you.',
'I ask a favor of the aerie, {me}: make peace with {third}.',
],
replyRequestPeaceAccept: [
'For you, {you}, we will offer {third} our terms.',
'A reasonable request. The aerie will approach {third}.',
],
replyRequestPeaceReject: [
'No, {you}. {third} has not yet earned our mercy.',
'That quarrel is ours to end, {you}, not yours to arrange.',
],
offerPeaceRequestOpener: [
'A request, {you}: make your peace with {third}. It troubles the aerie to watch a friend circle so low.',
'I ask this of you, {you} — end your war with {third}. Do it for us, if not for yourself.',
],
acceptPeaceRequest: [
'Since you ask it, {me}, I will approach {third}.',
'For the aerie\'s sake, {me}, I will try.',
],
rejectPeaceRequest: [
'No, {me}. That sky is mine to clear.',
'I decline, {me}. {third} has not yet fallen far enough.',
],
afterPeaceRequestAcceptedSuccess: [
'Well flown, {you}. The aerie rests easier with {third} at peace.',
'Gracefully done, {you}. Two wars end today.',
],
afterPeaceRequestAcceptedTried: [
'You tried, {you}, even if {third}\'s wings proved too proud to land.',
'The effort is noted, {you}, though {third} was not yet ready to listen.',
],
afterPeaceRequestRejected: [
'A groundling\'s answer, {you}. The aerie remembers who refuses a simple favor.',
'Then the sky between us grows a little colder, {you}.',
],
fleetComplaintOpener: [
'Your ships circle our nest longer than courtesy allows, {you}. Explain yourself, or withdraw them.',
'A fleet that lingers over our aerie is no longer a guest, {you}. Move it.',
@ -524,6 +615,42 @@ export const CHAT = {
'We offer something that holds, {you} — not a single exchange, but a standing arrangement.',
'Let our trade stand steady, {you}. We propose an agreement.',
],
requestPeaceThird: [
'Stop fighting {third}, {me}. Enough blood spilled there.',
'Make peace with {third}, {me}. I ask it plainly.',
],
replyRequestPeaceAccept: [
'Fine, {you}. I will offer {third} an end to it.',
'For you, {you} — I\'ll put down the axe against {third}.',
],
replyRequestPeaceReject: [
'No, {you}. That fight is not finished.',
'{third} has not bled enough yet, {you}. Not your call to make.',
],
offerPeaceRequestOpener: [
'Make peace with {third}, {you}. I ask it as one who stands beside you.',
'End it with {third}, {you}. A war you carry hurts us both.',
],
acceptPeaceRequest: [
'For you, {me}, I\'ll offer {third} peace.',
'Fine, {me}. I\'ll try with {third}.',
],
rejectPeaceRequest: [
'No, {me}. {third} has not earned it.',
'That fight stays mine, {me}.',
],
afterPeaceRequestAcceptedSuccess: [
'Good. {third} and you, at peace — stronger for it, {you}.',
'Well done, {you}. One less war weighs on us both.',
],
afterPeaceRequestAcceptedTried: [
'You tried, {you}. {third} refused — not your failing.',
'Effort noted, {you}, even though {third} would not yield.',
],
afterPeaceRequestRejected: [
'Weak answer, {you}. I will remember it.',
'Then we remember, {you}, who refuses a simple ask.',
],
fleetComplaintOpener: [
'Your ships sit over our ground too long, {you}. Explain the weight of that, or lift it.',
'A fleet that does not move becomes an occupation, {you}. Move it.',
@ -692,6 +819,42 @@ export const CHAT = {
'We propose something lasting, {you} — a trade agreement. Sincerely, for whatever that\'s worth from us.',
'A standing arrangement, {you}. We\'d like to make this formal, believe it or not.',
],
requestPeaceThird: [
'Wear a kinder face for {third}, {me}. Make peace.',
'I ask you to end it with {third}, {me} — even we tire of watching, sometimes.',
],
replyRequestPeaceAccept: [
'How generous of me, {you}. I\'ll offer {third} peace — this once.',
'Very well, {you}. I\'ll play the peacemaker with {third}. It\'s a good look on me.',
],
replyRequestPeaceReject: [
'No, {you}. That mask doesn\'t suit me yet.',
'I\'ll keep the war with {third}, {you}. It amuses me still.',
],
offerPeaceRequestOpener: [
'A strange request from me, {you}, I know — make peace with {third}. Even we grow tired of some faces.',
'I ask a favor, {you}: end your war with {third}. Trust me, if you can manage it.',
],
acceptPeaceRequest: [
'If it pleases you, {me}, I\'ll offer {third} peace.',
'Fine, {me}. I\'ll wear the face of a peacemaker with {third}.',
],
rejectPeaceRequest: [
'No, {me}. I like the war with {third} exactly as it is.',
'I\'ll decline, {me}. Not every mask fits.',
],
afterPeaceRequestAcceptedSuccess: [
'How refreshing, {you} — peace with {third}, and you actually meant it.',
'Well played, {you}. {third} never saw it coming, and neither did I.',
],
afterPeaceRequestAcceptedTried: [
'You tried, {you}. {third} wasn\'t buying it — I almost respect that.',
'A convincing effort, {you}, even if {third} saw through it.',
],
afterPeaceRequestRejected: [
'How predictable, {you}. I\'ll remember the face you showed me.',
'Refused, {you}? I\'ll wear that against you later.',
],
fleetComplaintOpener: [
'Your fleet has worn out its welcome over our world, {you}. Move it, or explain why it stays.',
'We\'ve watched your ships linger long enough, {you}. That\'s not a face we appreciate.',
@ -860,6 +1023,42 @@ export const CHAT = {
'Continuous exchange protocol calculated as favorable, {you}. Proposed.',
'Standing trade function available, {you}. Proposing.',
],
requestPeaceThird: [
'Cease hostilities with {third}. We request it.',
'Terminate conflict-state with {third}, {me}. Request logged.',
],
replyRequestPeaceAccept: [
'Request accepted. Peace-protocol will be offered to {third}.',
'Acknowledged. We will approach {third}.',
],
replyRequestPeaceReject: [
'Request denied. Conflict-state with {third} continues.',
'Negative. {third} has not met termination-conditions.',
],
offerPeaceRequestOpener: [
'Request: terminate your conflict-state with {third}. Reason: mutual inefficiency.',
'We require this of you: cease hostilities with {third}.',
],
acceptPeaceRequest: [
'Acknowledged. Peace-protocol will be offered to {third}.',
'Request accepted. We will attempt termination with {third}.',
],
rejectPeaceRequest: [
'Request denied. Conflict-state with {third} continues.',
'Negative. Termination not authorized.',
],
afterPeaceRequestAcceptedSuccess: [
'Termination confirmed. Efficiency restored.',
'Conflict-state with {third} resolved. Function approves.',
],
afterPeaceRequestAcceptedTried: [
'Attempt logged. {third} rejected termination. Compliance noted regardless.',
'Request processed. Outcome with {third} negative. Effort acknowledged.',
],
afterPeaceRequestRejected: [
'Request denial logged. Cooperation-rating reduced.',
'Non-compliance recorded.',
],
fleetComplaintOpener: [
'Foreign vessel detected over colony function beyond acceptable duration, {you}. Withdraw it.',
'Occupation pattern logged at colony coordinates, {you}. Explain function or withdraw.',
@ -1027,6 +1226,42 @@ export const CHAT = {
'A recurring exchange has been calculated as favorable, {you}. Proposing a standing agreement.',
'Continuous trade improves both systems, {you}. Proposal offered.',
],
requestPeaceThird: [
'Decommission your conflict with {third}, {me}. It is an inefficient allocation of resources.',
'End the war with {third}, {me}. I recommend it as an upgrade to your situation.',
],
replyRequestPeaceAccept: [
'Optimization accepted, {you}. I will offer {third} peace.',
'Logical. I will initiate peace with {third}, {you}.',
],
replyRequestPeaceReject: [
'Rejected, {you}. The war with {third} is not yet obsolete.',
'No. {third} has not yet reached the threshold for peace.',
],
offerPeaceRequestOpener: [
'A recommendation, {you}: decommission your war with {third}. It is wasted output.',
'I propose an upgrade to your foreign policy, {you} — peace with {third}.',
],
acceptPeaceRequest: [
'Recommendation accepted, {me}. I will offer {third} peace.',
'Logical. I will attempt peace with {third}.',
],
rejectPeaceRequest: [
'Rejected, {me}. That conflict remains operational.',
'No. The war with {third} has not reached obsolescence.',
],
afterPeaceRequestAcceptedSuccess: [
'Optimization complete, {you}. {third} at peace — efficiency restored.',
'Confirmed. Your foreign policy is now upgraded, {you}.',
],
afterPeaceRequestAcceptedTried: [
'Attempt logged, {you}. {third} rejected the upgrade. Compliance noted.',
'Process completed, output negative — {third} declined. Effort registered.',
],
afterPeaceRequestRejected: [
'Rejection logged, {you}. Cooperation index lowered.',
'Non-compliance noted, {you}. Recalculating trust.',
],
fleetComplaintOpener: [
'Your fleet occupies orbital space above my colony beyond acceptable parameters, {you}. Relocate it.',
'Unauthorized presence logged at my colony, {you}. Justify it or withdraw.',
@ -1194,6 +1429,42 @@ export const CHAT = {
'I offer something that holds, {you} — not one trade, but a standing arrangement.',
'Let this go beyond a single exchange, {you}. A trade agreement, proposed.',
],
requestPeaceThird: [
'Sheathe your claws against {third}, {me}. I ask it.',
'End the hunt with {third}, {me}. Enough blood.',
],
replyRequestPeaceAccept: [
'Fine, {you}. I\'ll offer {third} its life.',
'For you, {you} — I\'ll call off the hunt against {third}.',
],
replyRequestPeaceReject: [
'No, {you}. {third} has not yet earned mercy.',
'That hunt is mine, {you}. Not yours to end.',
],
offerPeaceRequestOpener: [
'Sheathe your claws against {third}, {you}. I ask this of you.',
'End your hunt with {third}, {you}. Do it, and I\'ll remember it.',
],
acceptPeaceRequest: [
'Fine, {me}. I\'ll offer {third} its life.',
'For you, {me} — I\'ll call off my hunt.',
],
rejectPeaceRequest: [
'No, {me}. {third} has not earned mercy.',
'That hunt is mine to finish.',
],
afterPeaceRequestAcceptedSuccess: [
'Good. {third} lives, and you\'ve proven you keep your word, {you}.',
'Well done, {you}. A clean kill would have been simpler — this was harder.',
],
afterPeaceRequestAcceptedTried: [
'You bared your throat and asked, {you}. {third} refused. Not your failure.',
'You tried, {you}. {third} wasn\'t ready to yield. I respect the attempt.',
],
afterPeaceRequestRejected: [
'A coward\'s answer, {you}. I\'ll remember it.',
'Refused? Then remember I asked, {you}.',
],
fleetComplaintOpener: [
'Your ships have circled my territory too long, {you}. Move them, or give me a reason not to treat them as prey.',
'A fleet that lingers this long isn\'t visiting, {you}. It\'s hunting. Explain, or withdraw.',
@ -1361,6 +1632,42 @@ export const CHAT = {
'I have modeled the benefit of a continuous arrangement, {you}. It exceeds a single exchange. Proposed.',
'A logical next step, {you}: a standing trade agreement, rather than isolated trades.',
],
requestPeaceThird: [
'The optimal solution, {me}, is peace with {third}. I recommend you see it.',
'Consider the equation, {me}: your war with {third} solves nothing. End it.',
],
replyRequestPeaceAccept: [
'A logical request, {you}. I will offer {third} the same solution.',
'Correctly reasoned, {you}. I will approach {third}.',
],
replyRequestPeaceReject: [
'An incomplete argument, {you}. The variables with {third} have not yet resolved.',
'No, {you}. The equation is not yet solvable.',
],
offerPeaceRequestOpener: [
'A simple proof, {you}: your war with {third} has no winning solution. End it.',
'I offer you the answer, {you} — make peace with {third}. The logic is plain.',
],
acceptPeaceRequest: [
'The proof holds, {me}. I will offer {third} peace.',
'Correctly reasoned, {me}. I will attempt it.',
],
rejectPeaceRequest: [
'An incomplete proof, {me}. I decline.',
'No, {me}. The variables have not resolved.',
],
afterPeaceRequestAcceptedSuccess: [
'Solved elegantly, {you}. {third} at peace — the correct answer, reached.',
'As predicted, {you}. The logical outcome, achieved.',
],
afterPeaceRequestAcceptedTried: [
'The proof was sound, {you}, even if {third} could not follow it. Noted regardless.',
'You reasoned correctly, {you}. {third} simply lacked the capacity to agree.',
],
afterPeaceRequestRejected: [
'A flawed conclusion, {you}. I will remember the error.',
'Disappointing, {you}. I expected better logic from you.',
],
fleetComplaintOpener: [
'Your fleet\'s continued presence over my colony no longer registers as accidental, {you}. Explain, or withdraw it.',
'I have recalculated your intentions given the duration of that fleet\'s presence, {you}, and the results are not favorable. Move it.',
@ -1528,6 +1835,42 @@ export const CHAT = {
'The brood proposes something lasting, {you} — a standing trade agreement, not a single exchange.',
'Let our clutches trade as neighbors do, {you}. We offer an ongoing arrangement.',
],
requestPeaceThird: [
'Withdraw your claws from {third}\'s brood, {me}. Make peace.',
'I ask you to end your war with {third}, {me}, for the good of both clutches.',
],
replyRequestPeaceAccept: [
'For you, {you}, I will offer {third}\'s brood peace.',
'Very well, {you}. I will spare {third}\'s clutch, this once.',
],
replyRequestPeaceReject: [
'No, {you}. {third}\'s brood has not yet paid enough.',
'That territory is not yet settled, {you}. Not yours to decide.',
],
offerPeaceRequestOpener: [
'A request from one clutch to another, {you}: make peace with {third}.',
'I ask you to spare {third}\'s brood, {you}, and end this war.',
],
acceptPeaceRequest: [
'For the clutch\'s sake, {me}, I will offer {third} peace.',
'Very well, {me}. I will spare {third}, this once.',
],
rejectPeaceRequest: [
'No, {me}. {third}\'s brood has not paid enough yet.',
'That territory remains contested, {me}.',
],
afterPeaceRequestAcceptedSuccess: [
'Good. {third}\'s brood lives, and our clutches both grow stronger for it, {you}.',
'Wisely done, {you}. Peace with {third} serves every brood better than war did.',
],
afterPeaceRequestAcceptedTried: [
'You asked on our behalf, {you}. {third} refused — the fault is theirs, not yours.',
'The attempt is noted, {you}, though {third}\'s brood was not yet ready to yield.',
],
afterPeaceRequestRejected: [
'A poor answer, {you}. The clutch remembers who refuses a simple request.',
'Then remember, {you}, that we asked, and you said no.',
],
fleetComplaintOpener: [
'Your fleet crowds our territory too long, {you}. The brood does not tolerate lingering guests. Withdraw it.',
'That fleet sits over our clutch longer than courtesy allows, {you}. Explain yourself, or move it.',

View File

@ -30,9 +30,9 @@ import { createBattle, runBattle } from './VegaCombat.js';
import { openCombatView } from './VegaCombatView.js';
import * as CombatV2 from './VegaCombatV2.js';
import { openCombatViewV2 } from './VegaCombatViewV2.js';
import { FORMATION_STRATEGIES, formationName } from './VegaFormations.js';
import { formationName } from './VegaFormations.js';
import { VegaMusic } from './VegaMusic.js';
import { FONT, uiClick, modalShell } from './VegaScreens.js';
import { FONT, uiClick, openFormationPicker } from './VegaScreens.js';
const PANEL_BG = 0x0b1220;
const PANEL_LINE = 0x2c435f;
@ -238,7 +238,7 @@ export default class VegaCombatSim extends Phaser.Scene {
// strategy. The defender ("AI") gets no prompt at all — it picks
// silently, which CombatV2.createBattle already does on its own for
// any side that doesn't supply a valid choice.
this.openFormationPicker((formationStrategy) => this.launchV2Battle(formationStrategy));
openFormationPicker(this, (formationStrategy) => this.launchV2Battle(formationStrategy));
return;
}
const { attacker, defender, colony } = this.buildBattleOpts();
@ -269,38 +269,6 @@ export default class VegaCombatSim extends Phaser.Scene {
});
}
// A simple two-card picker: click a strategy to commit it immediately and
// start the battle (no separate confirm step — this is a "real quick" dev
// tool, not a game screen). Shown fresh every Watch Battle press in V2
// mode; nothing is remembered between battles.
openFormationPicker(onPick) {
const shell = modalShell(this, 'Choose Formation Strategy', null, { width: 980, height: 420 });
const { body } = shell;
shell.add(this.add.text(body.x + body.w / 2, body.y, 'Your fleets tactical doctrine for this battle. The enemy fleet picks its own, silently.', {
fontFamily: FONT, fontSize: '16px', color: '#6f8aa8', align: 'center',
}).setOrigin(0.5, 0));
const cardW = (body.w - 40) / 2;
const cardY = body.y + body.h / 2 + 20;
FORMATION_STRATEGIES.forEach((f, i) => {
const cx = body.x + cardW / 2 + i * (cardW + 40);
const card = this.add.rectangle(cx, cardY, cardW, 160, PANEL_BG, 0.7)
.setStrokeStyle(2, PANEL_LINE, 0.9);
shell.add(card);
shell.add(this.add.text(cx, cardY - 48, f.name, {
fontFamily: FONT, fontSize: '22px', color: '#cfe8ff',
}).setOrigin(0.5));
shell.add(this.add.text(cx, cardY - 4, f.desc, {
fontFamily: FONT, fontSize: '15px', color: '#9fb6cc', align: 'center', wordWrap: { width: cardW - 40 },
}).setOrigin(0.5, 0));
const btn = new Button(this, cx, cardY + 55, 'Choose', uiClick(this, () => {
shell.destroy();
onPick(f.id);
}), { width: 160, height: 44, fontSize: 16 });
shell.add(btn);
});
}
runTrials(n) {
if (!this.battleReady()) return;
const { attacker, defender, colony } = this.buildBattleOpts();

View File

@ -448,10 +448,9 @@ function layoutFormation(rules, ships) {
// frame closely, and a big one spans proportionally more of the (now much
// bigger) world.
// Returns the layout too (not just a side effect on `ships`) — createBattle
// uses `defenderEndX` to anchor a defended planet just past the defender's
// own fleet, rather than at a fixed world-edge position that would now sit
// far off in empty space, disconnected from a fleet placed near the world's
// center.
// reads `centerY` to keep a defended planet vertically level with the
// fleets (the planet's own X is fixed independently of this layout — see
// createBattle's own comment on why).
//
// The proportional gap is clamped to never push the total span past the
// world's own width. Without this, a large mixed-hull fleet (many ships,
@ -576,10 +575,15 @@ export function createBattle(rules, opts) {
const layout = placeFleets(rules, aShips, dShips);
// A defended colony fights as an extra immobile entity that cannot be
// boarded — killing it is what clears the way for an invasion. Anchored
// just past the defender fleet's own footprint (not a fixed world-edge
// position) so it stays visually coherent with where the defender's
// ships actually ended up, and inside the camera's initial framing.
// boarded — killing it is what clears the way for an invasion. Fixed at
// the midpoint between the world's centre and the defender-side edge
// (Brian: "about equal distance from the edge of the screen and the
// center on its side") rather than anchored off the defender fleet's own
// footprint — a formation-relative anchor put the planet at very
// different depths depending on fleet size/spread, which read as the
// planet drifting around from battle to battle instead of holding a
// stable "home world" position. Defender is always placed on the +X side
// (placeFleets), so worldWidth is that side's edge.
let planet = null;
if (colony && colony.defenseHp > 0) {
planet = {
@ -588,10 +592,11 @@ export function createBattle(rules, opts) {
side: 'defender',
isPlanet: true,
name: 'Planetary Defences',
typeId: colony.typeId,
hp: colony.defenseHp,
hpMax: colony.defenseHp,
shield: colony.shieldBonus ?? 0,
x: layout.defenderEndX + 220,
x: C2.worldWidth * 0.75,
y: layout.centerY,
facing: Math.PI,
angularVelocity: 0,

View File

@ -27,7 +27,7 @@ import VegaFx from './VegaFx.js';
import {
advance, runBattle, battleResult, shipBounds, SIM_DT,
} from './VegaCombatV2.js';
import { shipFrame } from './VegaArt.js';
import { shipFrame, planetFrame } from './VegaArt.js';
import { buildParallax, bindZoomPan } from './VegaCombatCamera.js';
import { formationName } from './VegaFormations.js';
import { makeCommanderPortrait } from './VegaShipMedia.js';
@ -36,6 +36,11 @@ import { makeCommanderPortrait } from './VegaShipMedia.js';
// multiplied by the firing ship's hull.sizeScale instead.
const SHIP_BASE_SIZE = 58;
// A defended planet reads as bigger than any single ship (even a maxed-out
// battleship at SHIP_BASE_SIZE * 2.5 ≈ 145) without dominating the frame.
const PLANET_MARKER_SIZE = 110;
const SHIELD_RING_COLOR = 0x4fd8ff;
// Fire events now arrive as a steady trickle (each ship's own ~2s cooldown,
// staggered by whenever it entered range) rather than one big per-round
// batch, so every event gets both its visual FX AND its sound cue — no
@ -343,13 +348,25 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
markers.clear();
for (const s of battle.ships) {
const c = scene.add.container(s.x, s.y);
let shieldRing = null;
if (s.isPlanet) {
const g = scene.add.graphics();
g.fillStyle(0x8a6a3a, 1);
g.fillCircle(0, 0, 26);
g.lineStyle(2, 0xffd88a, 0.8);
g.strokeCircle(0, 0, 32);
c.add(g);
const img = scene.add.image(0, 0, art.planets, planetFrame(rules, s.typeId))
.setDisplaySize(PLANET_MARKER_SIZE, PLANET_MARKER_SIZE);
c.add(img);
// A planetary shield's magnitude (from the Planetary Shield building,
// rules.buildings.planetaryshield's shieldBonus effect) is fixed for
// the whole battle — only WHETHER it's still up changes, tracked by
// toggling this ring's visibility in syncMarkers as the planet's own
// hp falls, rather than redrawing it every frame.
if (s.shield > 0) {
shieldRing = scene.add.graphics();
const r = PLANET_MARKER_SIZE / 2 + 14;
shieldRing.fillStyle(SHIELD_RING_COLOR, 0.18);
shieldRing.fillCircle(0, 0, r);
shieldRing.lineStyle(2.5, SHIELD_RING_COLOR, 0.75);
shieldRing.strokeCircle(0, 0, r);
c.add(shieldRing);
}
} else {
const species = s.side === 'attacker' ? attackerSpecies : defenderSpecies;
const size = SHIP_BASE_SIZE * (s.design.hull.sizeScale ?? 1);
@ -367,7 +384,7 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
c.add(img);
}
shipLayer.add(c);
markers.set(s.uid, { ship: s, container: c });
markers.set(s.uid, { ship: s, container: c, shieldRing });
}
refreshSummaries();
}
@ -383,6 +400,9 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
m.container.setAlpha(alive ? 1 : 0.25);
m.container.setPosition(m.ship.x, m.ship.y);
if (!m.ship.isPlanet) m.container.setRotation(m.ship.facing + Math.PI / 2);
// Hide the shield the instant the planet's own hp is gone — "unless
// it's destroyed" (Brian's ask).
m.shieldRing?.setVisible(m.ship.hp > 0);
}
if (aliveCount !== lastSummaryAlive) {
lastSummaryAlive = aliveCount;

View File

@ -139,6 +139,61 @@ export function proposeTreaty(rules, state, a, b, kind) {
return true;
}
// --------------------------------------------------------------------------
// "Please make peace with them" — a request about a THIRD PARTY's war, not a
// bilateral treaty between the two empires actually negotiating. Kept
// separate from wouldAccept/proposeTreaty/proposeOrOffer above since their
// shape (an offer resolves into a treaty between exactly the two parties
// talking) doesn't fit a favor that's about someone else's relationship.
// Only ever runs in one of two directions — the player asking an AI
// (requestPeace, resolved immediately, same as every other player-initiated
// audience action) or an AI asking the human (offerPeaceRequest, always
// held — mirrors proposeOrOffer's own reasoning: resolving it unilaterally
// would give the Audience screen's Accept/Reject buttons nothing to decide).
// AI-to-AI never happens; nothing in this file needs it, so it's not built.
// Would `target` agree to make peace with `thirdParty`, asked by `requester`?
// Baseline is whatever `target` would decide on the war's own merits (the
// same read wouldAccept('peace') already uses) — a friend's request tips the
// balance for a target that WOULDN'T otherwise sue for peace, but only if
// they like the requester enough and the war isn't going so badly that even
// a friend's plea gets waved off.
export function wouldAcceptPeaceRequest(rules, state, requester, target, thirdParty) {
if (!atWar(state, target, thirdParty)) return false;
if (!canNegotiate(rules, state, target, thirdParty)) return false;
if (wouldAccept(rules, state, thirdParty, target, 'peace')) return true;
const cfg = rules.diplomacy.warRequest;
return attitudeOf(state, target, requester) >= cfg.friendThreshold
&& attitudeOf(state, target, thirdParty) > cfg.refuseBelowAttitude;
}
// The player asking `target` (always an AI) to make peace with `thirdParty`.
// Refusal costs the same small sting proposeTreaty's own refusal already
// costs elsewhere in this file — being told no by someone you leaned on is a
// real, if minor, friction point. Success also warms `target` toward the
// player a little on top of makePeace's own bilateral bump: gratitude for
// having been asked nicely rather than just left to fight.
export function requestPeace(rules, state, requester, target, thirdParty) {
if (!wouldAcceptPeaceRequest(rules, state, requester, target, thirdParty)) {
adjust(state, target, requester, -3);
return false;
}
makePeace(rules, state, target, thirdParty);
adjust(state, target, requester, 6);
return true;
}
// An AI (`a`) asking the HUMAN (`b`) to make peace with `thirdParty`. Held
// exactly like proposeOrOffer's human-targeted branch; answered through
// respondToOffer, which special-cases offer.kind === 'peaceRequest' since
// accepting it doesn't create a treaty between `a` and `b` at all.
export function offerPeaceRequest(rules, state, a, b, thirdParty) {
if (state.empires[b].pendingOffers[a]) return 'pending';
state.empires[b].pendingOffers[a] = { kind: 'peaceRequest', thirdParty, turn: state.turn };
state.events.push({ type: 'offerReceived', empire: a, other: b, kind: 'peaceRequest', turn: state.turn });
return 'pending';
}
// When an AI proposes peace or an alliance TO THE HUMAN, resolving it
// unilaterally (like AI-vs-AI) would give the Audience screen's Accept/Reject
// buttons nothing to actually decide. So a proposal aimed at the human is
@ -166,6 +221,21 @@ export function respondToOffer(rules, state, b, a, accept) {
if (offer.kind === 'peace') makePeace(rules, state, a, b);
else if (offer.kind === 'alliance') formAlliance(rules, state, a, b);
else if (offer.kind === 'tradeAgreement') formTradeAgreement(rules, state, a, b);
else if (offer.kind === 'peaceRequest') {
// Agreeing means `b` (the human) commits to actually proposing peace to
// the third party — same proposeTreaty check a personal Sue for Peace
// visit would run, so this can still fail to land if the third party
// itself isn't willing. Either way `a` is pleased the human tried;
// more so if it actually worked. madePeace rides on the event (not the
// return value here — respondToOffer's own true/false already means
// "was there an offer to answer", not "did the treaty succeed", and
// existing callers depend on that).
const madePeace = proposeTreaty(rules, state, b, offer.thirdParty, 'peace');
adjust(state, a, b, madePeace ? 15 : 4);
state.events.push({
type: 'peaceRequestResolved', empire: b, other: a, thirdParty: offer.thirdParty, madePeace, turn: state.turn,
});
}
} else {
adjust(state, a, b, -8); // the proposer stings at being refused
adjust(state, b, a, -2); // refusing is itself a small friction cost
@ -334,6 +404,22 @@ export function runDiplomacyTurn(rules, state, e) {
&& rand(state) < allianceCfg.aiProposeChance) {
proposeOrOffer(rules, state, e, other.idx, 'alliance');
}
// Ask the human to make peace with a friend of ours, if the human is at
// war with them — a favor request about someone ELSE's war, not about
// e's own relationship with the human (see VegaDiplomacy.js's
// offerPeaceRequest, above). `other` here is guaranteed not at war with
// e (the atWar(e, other) branch above already `continue`d) and liked
// enough by e (`att`) to count as "a friend."
const warCfg = rules.diplomacy.warRequest;
if (other.idx !== state.humanIndex && emp.contacted[state.humanIndex]
&& canNegotiate(rules, state, e, state.humanIndex)
&& atWar(state, state.humanIndex, other.idx)
&& att > warCfg.friendThreshold
&& !human?.pendingOffers?.[e]
&& rand(state) < warCfg.aiProposeChance) {
offerPeaceRequest(rules, state, e, state.humanIndex, other.idx);
}
}
}

View File

@ -11,11 +11,13 @@
import { empireColonies, colonyProduction, empireFleetPower } from './VegaLogic.js';
import { pickLine } from './VegaChat.js';
import { attitudeOf, moodOf } from './VegaDiplomacy.js';
// The seven event types GNN ever turns into a story page. Council/election
// The nine event types GNN ever turns into a story page. Council/election
// events are deliberately excluded — Brian is handling that separately.
export const GNN_STORY_TYPES = new Set([
'warDeclared', 'peace', 'alliance', 'techDone', 'lastColony', 'eliminated', 'spyCaught',
'sabotage', 'techStolen',
]);
/**
@ -23,22 +25,35 @@ export const GNN_STORY_TYPES = new Set([
* only research-sourced completions (never espionage-stolen or traded techs
* nobody can headline a tech nobody publicly knows was taken) count, and
* only techs hand-flagged `gnnHeadline` in the rules data count as
* "breakthrough" news; the other ~75 techs stay ordinary. `sabotage`/
* `techStolen` are deliberately NOT in GNN_STORY_TYPES at all a spy
* mission that *succeeds* leaves nobody publicly able to name the culprit;
* only `spyCaught` ("exposed") is ever public.
* "breakthrough" news; the other ~75 techs stay ordinary.
*
* `sabotage`/`techStolen` are visibility-gated on the VIEWER (the human),
* since a successful mission never reveals who did it (see
* describeGnnStory's `attributed` flag) but who gets to hear about it at all
* still depends on who's watching: the perpetrator and the victim always
* know (it happened to/because of them), but a true third party only picks
* up the rumor if they're in contact with the victim AND on decent terms
* with them (Brian's ask — hostile/cold empires don't share gossip). This is
* why isGnnStory needs `state`, unlike every other story type here.
*/
export function isGnnStory(rules, ev) {
export function isGnnStory(rules, state, ev) {
if (!GNN_STORY_TYPES.has(ev.type)) return false;
if (ev.type === 'techDone') {
return ev.source === 'research' && !!rules.techs[ev.techId]?.gnnHeadline;
}
if (ev.type === 'sabotage' || ev.type === 'techStolen') {
const me = state.humanIndex;
if (ev.empire === me || ev.target === me) return true;
if (!state.empires[me]?.contacted?.[ev.target]) return false;
const mood = moodOf(attitudeOf(state, me, ev.target));
return mood !== 'hostile' && mood !== 'cold';
}
return true;
}
/** Every GNN-worthy event not yet turned into a story, in chronological order. */
export function pendingGnnStories(rules, state) {
return state.events.filter((ev) => !ev.gnnAnnounced && isGnnStory(rules, ev));
return state.events.filter((ev) => !ev.gnnAnnounced && isGnnStory(rules, state, ev));
}
// Replay buffer for the on-demand HUD button when there's nothing new to
@ -112,6 +127,34 @@ export function describeGnnStory(rules, state, ev) {
headline: `${name(ev.empire)} agents caught red-handed targeting ${name(ev.target)}.`,
sub: ev.mission === 'sabotage' ? 'Mission: sabotage.' : 'Mission: technology theft.',
};
// A SUCCESSFUL mission, unlike spyCaught above, never names a culprit —
// `attributed` is only true when the viewing human IS the perpetrator
// (isGnnStory's own contact/attitude gate is what decides whether a true
// third party ever sees this at all; the victim always does, same
// anonymous copy as a bystander since even the victim's own GNN never
// outs the thief — VegaTurnReport.js's ticker log is where the victim's
// *personal* briefing already names them in full).
case 'sabotage': {
const attributed = ev.empire === state.humanIndex;
return {
type: ev.type, kind: 'espionageResult', accent: 'espionage', attributed,
perpetrator: ev.empire, victim: ev.target,
headline: attributed
? `${name(ev.empire)} agents sabotage ${name(ev.target)}'s colony, destroying ${ev.factoriesLost} factories and ${ev.defenseLost} defense.`
: `${name(ev.target)} report a colony crippled by an unknown saboteur.`,
};
}
case 'techStolen': {
const attributed = ev.empire === state.humanIndex;
const techName = rules.techs[ev.techId]?.name ?? 'a technology';
return {
type: ev.type, kind: 'espionageResult', accent: 'espionage', attributed,
perpetrator: ev.empire, victim: ev.target, techId: ev.techId,
headline: attributed
? `${name(ev.empire)} agents successfully steal ${techName} from ${name(ev.target)}.`
: `${name(ev.target)} believe their technology '${techName}' has been stolen.`,
};
}
default: return { type: ev.type, kind: 'unknown', accent: 'tech', headline: ev.type };
}
}
@ -247,6 +290,16 @@ const ESPIONAGE_LINES = [
'Sources confirm {perp} spies caught attempting {mission} against {victim}.',
'Word from Galactic Security: {perp} agents apprehended mid-{mission} against {victim}.',
];
const ESPIONAGE_SUCCESS_LINES = [
'This just in, {perp} agents strike a covert blow against {victim}.',
'Breaking: {victim} left reeling after a covert operation by {perp}.',
'Sources confirm {perp} intelligence services scored a hit against {victim} tonight.',
];
const ESPIONAGE_RUMOR_LINES = [
'Rumors swirl tonight of an unexplained security breach in {victim} space.',
'Word on the street: {victim} suffered a covert attack of unknown origin.',
'Sources close to {victim} report foul play, though no culprit has been named.',
];
const RANKING_LINES = {
population: [
'This just in, {leader} population reaches new galactic heights at {value}.',
@ -315,6 +368,10 @@ function anchorLineForStory(rules, state, desc) {
const mission = desc.mission === 'sabotage' ? 'sabotage' : 'technology theft';
return pickLine(ESPIONAGE_LINES, { perp: empName(desc.perpetrator), victim: empName(desc.victim), mission });
}
case 'espionageResult': {
const vars = { perp: empName(desc.perpetrator), victim: empName(desc.victim) };
return pickLine(desc.attributed ? ESPIONAGE_SUCCESS_LINES : ESPIONAGE_RUMOR_LINES, vars);
}
default: return desc.headline ?? '';
}
}

View File

@ -250,6 +250,54 @@ function renderEspionageStory(scene, rules, state, art, container, desc) {
addHeadline(scene, container, cy + size / 2 + 116, desc.sub, '#ff9fd8', '20px');
}
// A SUCCESSFUL sabotage/theft — unlike renderEspionageStory above (a CAUGHT
// spy, perpetrator named and shamed), the culprit is never shown here except
// when the viewing human IS the culprit (desc.attributed). Anonymous case
// reuses `renderTerritoryStory`'s single-portrait-plus-vignette read but with
// a "?" mark standing in for the unknown hand behind it, rather than an
// attacker portrait.
function renderEspionageResultStory(scene, rules, state, art, container, desc) {
const color = STORY_COLORS.espionage;
const victim = state.empires[desc.victim];
if (desc.attributed) {
const perp = state.empires[desc.perpetrator];
const size = 380;
const cy = 70 + size / 2;
const leftCx = 60 + size / 2;
const rightCx = RIGHT_W - 60 - size / 2;
addPortraitBlock(scene, rules, art, container, leftCx, cy, size, color, perp.speciesId, perp.name, perp.color);
addPortraitBlock(scene, rules, art, container, rightCx, cy, size, color, victim.speciesId, victim.name, victim.color);
const g = scene.add.graphics();
g.lineStyle(4, color, 0.85);
g.lineBetween(leftCx + size / 2, cy, rightCx - size / 2, cy);
container.add(g);
const verb = scene.add.text((leftCx + rightCx) / 2, cy, desc.type === 'sabotage' ? 'SABOTAGED' : 'STOLEN', {
fontFamily: FONT, fontSize: '24px', color: hex(color), fontStyle: 'bold',
}).setOrigin(0.5);
container.add(verb);
addHeadline(scene, container, cy + size / 2 + 70, desc.headline);
return;
}
const size = 420;
const cx = RIGHT_W / 2;
const cy = 60 + size / 2;
storyFrame(scene, container, cx - size / 2, cy - size / 2, size, size, color);
const portrait = makeSpeciesPortrait(scene, rules, art, victim.speciesId, cx, cy, size - 20);
container.add(portrait);
container.add(scene.add.rectangle(cx, cy, size - 20, size - 20, 0x000000, 0.35));
container.add(scene.add.text(cx, cy + size / 2 + 16, victim.name, {
fontFamily: FONT, fontSize: '22px', color: victim.color,
}).setOrigin(0.5, 0));
// The unknown culprit — a stark "?" in the corner rather than an attacker
// portrait, since there's nobody to name.
container.add(scene.add.text(cx + size / 2 - 34, cy - size / 2 + 34, '?', {
fontFamily: FONT, fontSize: '52px', color: hex(color), fontStyle: 'bold',
}).setOrigin(0.5));
addHeadline(scene, container, cy + size / 2 + 70, desc.headline, '#c7d3e0');
}
function renderUnknownStory(scene, rules, state, art, container, desc) {
addHeadline(scene, container, RIGHT_H / 2 - 20, desc.headline ?? 'Unknown event.');
}
@ -259,6 +307,7 @@ const STORY_RENDERERS = {
tech: renderTechStory,
territory: renderTerritoryStory,
espionage: renderEspionageStory,
espionageResult: renderEspionageResultStory,
unknown: renderUnknownStory,
};
@ -534,11 +583,20 @@ export function openGnnScreen(scene, rules, state, art, opts = {}) {
const storyEvents = usingHistory ? (state.gnn?.history ?? []) : pending;
const storyPages = storyEvents.map((ev) => ({ kind: 'story', desc: Gnn.describeGnnStory(rules, state, ev) }));
const rankingPages = Gnn.RANKING_METRICS.map((metric) => ({ kind: 'ranking', metric }));
// Always-accessible: present every time GNN opens, independent of
// whether there's a pending story — same footing as the ranking pages.
const relationsPage = { kind: 'relations' };
const pages = [...storyPages, ...rankingPages, relationsPage];
// A spying/sabotage notification is a targeted alert, not a newscast —
// skip the ranking/relations pages so it reads as "here's what just
// happened," not "here's what just happened, now flip through five
// unrelated charts" (Brian's ask). Only suppresses on a FRESH pending
// notification; reopening GNN on demand with nothing pending
// (usingHistory) always gets the full experience back — that reopen IS
// the escape hatch for a player who does want the charts.
const isEspionageAlert = !usingHistory
&& storyPages.some((p) => p.desc.kind === 'espionage' || p.desc.kind === 'espionageResult');
const rankingPages = isEspionageAlert ? [] : Gnn.RANKING_METRICS.map((metric) => ({ kind: 'ranking', metric }));
// Always-accessible otherwise: present every time GNN opens, independent
// of whether there's a pending story — same footing as the ranking pages.
const relationsPage = isEspionageAlert ? null : { kind: 'relations' };
const pages = [...storyPages, ...rankingPages, ...(relationsPage ? [relationsPage] : [])];
const root = scene.add.container(0, 0).setDepth(D.gnn);
root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x030509, 0.99)

View File

@ -1016,6 +1016,10 @@ function runEspionage(rules, state, e) {
return;
}
// A SUCCESSFUL mission carries no attitude penalty (Brian's ask) — nobody
// publicly knows who did it (see VegaGnn.js's `attributed` flag), so there
// is nobody for the victim to be angry AT. Getting CAUGHT above is the one
// espionage outcome that still costs relations, since that one names names.
if (mission === 'sabotage') {
const targetColonies = empireColonies(state, target.idx);
if (!targetColonies.length) return;
@ -1029,7 +1033,6 @@ function runEspionage(rules, state, e) {
type: 'sabotage', empire: e, target: target.idx, starIdx: colony.starIdx,
factoriesLost, defenseLost, turn: state.turn,
});
target.attitude[e] = Math.max(-100, Math.min(100, (target.attitude[e] ?? 0) - cfg.sabotageAttitudePenalty));
return;
}
@ -1038,7 +1041,6 @@ function runEspionage(rules, state, e) {
const tech = stealable[randInt(state, stealable.length)];
grantTech(rules, state, e, tech.id, 'espionage');
pushEvent(state, { type: 'techStolen', empire: e, target: target.idx, techId: tech.id, turn: state.turn });
target.attitude[e] = Math.max(-100, Math.min(100, (target.attitude[e] ?? 0) - 8));
}
function recomputeTotals(rules, state) {
@ -1332,7 +1334,13 @@ function applyBattleLosses(rules, state, starIdx, e, survivors) {
// Split out of fightAt so the player can drive a battle tick by tick through
// VegaCombatViewV2 and then hand the outcome back — the interactive battle
// and auto-resolve therefore run the same engine and cannot diverge.
export function prepareBattleAt(rules, state, starIdx, a, b) {
// `humanFormation` (VegaFormations.js id) is stamped onto whichever side is
// state.humanIndex, so a human-participated battle can carry the tactic the
// player actually chose (MasterOfVegaGame.js's playPlayerBattles, via its
// pre-battle formation picker) instead of createBattle's own silent-random
// fallback for an unset side. AI-vs-AI battles (fightAt, below) never pass
// this and are unaffected — both sides stay silently random as before.
export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation = null } = {}) {
const colony = colonyAt(state, starIdx);
const defenderIdx = colony && (colony.empireIdx === a || colony.empireIdx === b) ? colony.empireIdx : b;
const attackerIdx = defenderIdx === a ? b : a;
@ -1348,14 +1356,29 @@ export function prepareBattleAt(rules, state, starIdx, a, b) {
};
const attacker = mkSide(attackerIdx);
const defender = mkSide(defenderIdx);
if (humanFormation) {
if (attackerIdx === state.humanIndex) attacker.formationStrategy = humanFormation;
else if (defenderIdx === state.humanIndex) defender.formationStrategy = humanFormation;
}
const defColony = colony && colony.empireIdx === defenderIdx ? colony : null;
if (!attacker.ships.length) return null;
if (!defender.ships.length && !(defColony && defColony.defenseHp > 0)) return null;
// The planet's typeId rides along purely for the tactical view's art (the
// real planets spritesheet instead of a generic icon) — the combat engine
// itself never reads it. shieldBonus was hard-coded to 0 here until now,
// which meant the Planetary Shield building's own effect (rules.buildings
// .planetaryshield's shieldBonus:5) was silently never applied to a real
// battle, only ever exercised through the standalone ?movsim simulator's
// manual stepper — found while wiring the shield ring into the view.
const battle = createBattle(rules, {
attacker,
defender,
colony: defColony ? { defenseHp: defColony.defenseHp, shieldBonus: 0 } : null,
colony: defColony ? {
defenseHp: defColony.defenseHp,
shieldBonus: buildingEffect(rules, defColony, 'shieldBonus'),
typeId: state.galaxy.stars[starIdx]?.planets[defColony.orbit]?.typeId,
} : null,
starIdx,
rnd: () => rand(state),
});

View File

@ -217,7 +217,10 @@ export function openResearchScreen(scene, rules, state, e, art, onClose, { initi
gridLayer.add(bg);
const lvl = fieldTechLevel(rules, state, e, field.id);
gridLayer.add(scene.add.text(bx + 12, by + 8, `${field.name.toUpperCase()} · Lv ${lvl}`, {
const fieldIconSize = 26;
gridLayer.add(scene.add.image(bx + 12 + fieldIconSize / 2, by + 8 + 10, art.techfields, field.iconFrame)
.setDisplaySize(fieldIconSize, fieldIconSize));
gridLayer.add(scene.add.text(bx + 12 + fieldIconSize + 8, by + 8, `${field.name.toUpperCase()} · Lv ${lvl}`, {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));
@ -312,7 +315,10 @@ export function openResearchScreen(scene, rules, state, e, art, onClose, { initi
const field = rules.techFields[selectedField];
const detailLvl = fieldTechLevel(rules, state, e, selectedField);
detailLayer.add(scene.add.text(detailX, headingY, `${field.name.toUpperCase()} — TECH TREE (Level ${detailLvl})`, {
const detailIconSize = 22;
detailLayer.add(scene.add.image(detailX + detailIconSize / 2, headingY + 9, art.techfields, field.iconFrame)
.setDisplaySize(detailIconSize, detailIconSize));
detailLayer.add(scene.add.text(detailX + detailIconSize + 8, headingY, `${field.name.toUpperCase()} — TECH TREE (Level ${detailLvl})`, {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));

View File

@ -33,6 +33,12 @@ export function compileRules(json) {
};
const techFields = byId(json.techFields, 'techField');
// Field icons live on their own sheet (data/mastervega-artwork.json's
// "techfields", split from the individual-tech "techicons" sheet), so
// validated the same way techs' own iconFrame is below.
for (const f of json.techFields) {
need(Number.isInteger(f.iconFrame) && f.iconFrame >= 0, `techField ${f.id} bad iconFrame`);
}
const techs = byId(json.techs, 'tech');
const hulls = byId(json.hulls, 'hull');
const buildings = byId(json.buildings, 'building');

View File

@ -18,6 +18,7 @@ import { leaderOffers } from './VegaLeaders.js';
import { makeSpeciesPortrait } from './VegaArt.js';
import { openAudienceScreen } from './VegaAudience.js';
import { FORMATION_STRATEGIES } from './VegaFormations.js';
export const FONT = '"Julius Sans One"';
// `colony` sits above `modal` so the colony screen can stack over the system
@ -157,6 +158,47 @@ export function slider(scene, x, y, w, label, value, onChange, colour = ACCENT)
};
}
// --------------------------------------------------------------------------
/**
* A simple two-card picker for the player's pre-battle formation strategy
* (VegaFormations.js). The opposing side always picks silently an unset
* side falls back to VegaCombatV2.createBattle's own seeded-random choice
* this screen only ever asks about the player's own side. Shared by
* VegaCombatSim.js's dev tool (closable, a "real quick" testing aid) and the
* real pre-battle flow (MasterOfVegaGame.js's playPlayerBattles, forced
* `closable: false`, since there is no sensible "cancel" once fleets are
* already committed to a fight this turn).
*/
export function openFormationPicker(scene, onPick, { closable = true } = {}) {
const shell = modalShell(scene, 'Choose Formation Strategy', null, { width: 980, height: 420, closable });
const { body } = shell;
shell.add(scene.add.text(body.x + body.w / 2, body.y,
"Your fleet's tactical doctrine for this battle. The enemy fleet picks its own, silently.", {
fontFamily: FONT, fontSize: '16px', color: '#8fa8c0', align: 'center',
}).setOrigin(0.5, 0));
const cardW = (body.w - 40) / 2;
const cardY = body.y + body.h / 2 + 20;
FORMATION_STRATEGIES.forEach((f, i) => {
const cx = body.x + cardW / 2 + i * (cardW + 40);
const card = scene.add.rectangle(cx, cardY, cardW, 160, PANEL, 0.7).setStrokeStyle(2, ACCENT, 0.9);
shell.add(card);
shell.add(scene.add.text(cx, cardY - 48, f.name, {
fontFamily: FONT, fontSize: '22px', color: '#cfe8ff',
}).setOrigin(0.5));
shell.add(scene.add.text(cx, cardY - 4, f.desc, {
fontFamily: FONT, fontSize: '15px', color: '#9fb6cc', align: 'center', wordWrap: { width: cardW - 40 },
}).setOrigin(0.5, 0));
const btn = new Button(scene, cx, cardY + 55, 'Choose', uiClick(scene, () => {
shell.destroy();
onPick(f.id);
}), { width: 160, height: 44, fontSize: 16 });
shell.add(btn);
});
return shell;
}
// --------------------------------------------------------------------------
// Research has its own file, VegaResearchScreen.js — it grew past this
// module's shared factory-function pattern the same way Audience and the

View File

@ -295,19 +295,39 @@ Enrichment Facility.
---
## 7. `techicons` — ⬜ `assets/images/vega/techicons.png`
## 7a. `techfields` — ⬜ `assets/images/vega/techfields.png`
| | |
|---|---|
| Sheet size | **480 × 336** |
| Sheet size | **144 × 96** |
| Frame | **48 × 48** |
| Grid | 10 cols × 7 rows = 70 frames |
| Grid | 3 cols × 2 rows = 6 frames |
- Frames **05**: the six tech *fields* — Computers, Construction, Force Fields,
Planetology, Propulsion, Weapons.
- Frames **665**: the sixty individual techs, in the order they appear in
`data/mastervega-rules.json` (`techs[].iconFrame`).
- Frames 6669: spare.
One frame per tech *field* (`techFields[].iconFrame`), rules order: Computers,
Construction, Force Fields, Planetology, Propulsion, Weapons. Exact fit, no
spares.
Split out of the old combined `techicons` sheet (§7b) so the six field icons
— larger, simpler glyphs meant to read at a glance on the research screen —
can be painted and sized independently of the ~90 individual tech icons.
Nothing in the game draws a field icon yet; this sheet exists for whenever
that lands.
---
## 7b. `techicons` — ⬜ `assets/images/vega/techicons.png`
| | |
|---|---|
| Sheet size | **480 × 480** |
| Frame | **48 × 48** |
| Grid | 10 cols × 10 rows = 100 frames |
One frame per individual tech (`techs[].iconFrame`), 090, in the order techs
appear in `data/mastervega-rules.json` — the original 60 plus two later
MOO1-branching passes. 9199 spare (the tech list has grown once already;
expect it to again). Drawn today by `VegaGnnScreen.js`'s tech-breakthrough
story page.
---

View File

@ -103,6 +103,15 @@ section('1. Rules integrity');
const techCap = techSheet.cols * techSheet.rows;
check('tech icon frames fit the sheet', Math.max(...techFrames) < techCap,
`max ${Math.max(...techFrames)} of ${techCap}`);
// Field icons split onto their own sheet (data/mastervega-artwork.json's
// "techfields") — same shape checks as the tech icons above.
const fieldFrames = RULES.techFieldList.map((f) => f.iconFrame);
check('tech field icon frames unique', new Set(fieldFrames).size === fieldFrames.length);
const fieldSheet = artJson.sheets.techfields;
const fieldCap = fieldSheet.cols * fieldSheet.rows;
check('tech field icon frames fit their own sheet', Math.max(...fieldFrames) < fieldCap,
`max ${Math.max(...fieldFrames)} of ${fieldCap}`);
check('tech field icons and tech icons are on separate sheets', fieldSheet.key !== techSheet.key);
const buildFrames = RULES.buildingList.map((b) => b.frame);
check('building frames unique', new Set(buildFrames).size === buildFrames.length);
check('building frames fit the sheet',
@ -2335,6 +2344,122 @@ section('7. Diplomacy and the Galactic Council');
st3.events.some((e) => e.type === 'offerExpired' && e.empire === 1 && e.other === 0));
}
// --- "make peace with them" third-party requests (VegaDiplomacy.js's
// wouldAcceptPeaceRequest/requestPeace/offerPeaceRequest) — a favor about
// someone ELSE's war, not a treaty between the two people actually
// talking. Brian's ask: the player can ask an AI to end a war with a
// third empire, an AI can ask the player the same, and refusing either
// direction costs the relationship.
{
const stP = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'cluster', seed: 7373, difficultyId: 'normal',
speciesIds: ['human', 'kkrix', 'ursaal'], humanIndex: 0,
});
stP.rules = RULES;
const player = 0;
const target = 1;
const third = 2;
for (const e of stP.empires) {
e.totalPop = 100;
for (const o of stP.empires) if (o.idx !== e.idx) { e.contacted[o.idx] = true; e.attitude[o.idx] = 0; }
}
Diplo.declareWar(RULES, stP, target, third);
check('fixture: target is at war with third', Logic.atWar(stP, target, third));
stP.empires[target].attitude[third] = -50; // not losing badly, not devoted either
stP.empires[target].attitude[player] = 0; // not yet a friend
check('a stranger\'s request is refused when the target has no independent reason to accept',
!Diplo.wouldAcceptPeaceRequest(RULES, stP, player, target, third));
stP.empires[target].attitude[player] = 60;
check('a friend\'s request succeeds as a favor even when the target would not sue for peace unprompted',
Diplo.wouldAcceptPeaceRequest(RULES, stP, player, target, third));
stP.empires[target].attitude[third] = -90;
check('even a friend\'s request is refused when the target\'s own war attitude is bad enough',
!Diplo.wouldAcceptPeaceRequest(RULES, stP, player, target, third));
// requestPeace: success actually makes peace and warms target toward the
// requester beyond makePeace's own bilateral bump; failure stings instead.
stP.empires[target].attitude[third] = -50;
stP.empires[target].attitude[player] = 60;
const beforeAtt = Diplo.attitudeOf(stP, target, player);
const ok = Diplo.requestPeace(RULES, stP, player, target, third);
check('requestPeace succeeds when wouldAcceptPeaceRequest is true', ok === true);
check('a successful request actually makes peace', !Logic.atWar(stP, target, third));
check('a successful request warms the target toward the requester',
Diplo.attitudeOf(stP, target, player) > beforeAtt);
Diplo.declareWar(RULES, stP, target, third); // reset the war for the failure case
stP.empires[target].attitude[player] = -50; // no longer a friend
stP.empires[target].attitude[third] = -90; // and losing badly
const beforeAtt2 = Diplo.attitudeOf(stP, target, player);
const failed = Diplo.requestPeace(RULES, stP, player, target, third);
check('requestPeace fails when wouldAcceptPeaceRequest is false', failed === false);
check('a failed request still leaves them at war', Logic.atWar(stP, target, third));
check('a failed request costs the requester a small sting',
Diplo.attitudeOf(stP, target, player) < beforeAtt2);
// offerPeaceRequest: held exactly like proposeOrOffer's human-targeted
// branch, and respondToOffer's peaceRequest case resolves acceptance as
// "try to make peace with the third party," not a treaty with the asker.
Diplo.declareWar(RULES, stP, player, third); // now the HUMAN is at war with third
const heldReq = Diplo.offerPeaceRequest(RULES, stP, target, player, third);
check('offerPeaceRequest holds, same contract as proposeOrOffer', heldReq === 'pending');
check('the held peaceRequest offer carries its thirdParty',
stP.empires[player].pendingOffers[target]?.thirdParty === third);
const heldAgain = Diplo.offerPeaceRequest(RULES, stP, target, player, third);
check('a second peaceRequest from the same asker does not clobber the first', heldAgain === 'pending');
stP.empires[third].attitude[player] = 50; // so the resulting proposeTreaty to third can land
const beforeAskerAtt = Diplo.attitudeOf(stP, target, player);
const respAccept = Diplo.respondToOffer(RULES, stP, player, target, true);
check('respondToOffer resolves a held peaceRequest (true, same contract as any offer)',
respAccept === true);
check('accepting a peaceRequest attempts peace with the third party',
!Logic.atWar(stP, player, third));
check('accepting clears the pending offer', !stP.empires[player].pendingOffers[target]);
check('the asker is pleased the human tried',
Diplo.attitudeOf(stP, target, player) > beforeAskerAtt);
check('accepting a peaceRequest pushes a peaceRequestResolved event',
stP.events.some((e) => e.type === 'peaceRequestResolved' && e.empire === player && e.other === target
&& e.thirdParty === third && e.madePeace === true));
// Rejecting reuses the exact generic refusal path every other offer kind
// already uses — refusing costs the relationship, Brian's explicit ask.
Diplo.declareWar(RULES, stP, player, third);
Diplo.offerPeaceRequest(RULES, stP, target, player, third);
const beforeReject = Diplo.attitudeOf(stP, target, player);
Diplo.respondToOffer(RULES, stP, player, target, false);
check('rejecting a peaceRequest costs the relationship, same as any other refused offer',
Diplo.attitudeOf(stP, target, player) < beforeReject);
check('rejecting a peaceRequest never touches the third party\'s treaty',
Logic.atWar(stP, player, third));
// AI-initiation: an AI that likes a friend enough, with the human at war
// with that friend, eventually offers the human a peaceRequest.
const stAI = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'cluster', seed: 8181, difficultyId: 'normal',
speciesIds: ['human', 'kkrix', 'ursaal'], humanIndex: 0,
});
stAI.rules = RULES;
for (const e of stAI.empires) {
e.totalPop = 100;
for (const o of stAI.empires) if (o.idx !== e.idx) { e.contacted[o.idx] = true; e.attitude[o.idx] = 0; }
}
const asker = 1;
const friend = 2;
Diplo.declareWar(RULES, stAI, stAI.humanIndex, friend);
let anyOffered = false;
for (let i = 0; i < 100 && !anyOffered; i += 1) {
stAI.empires[asker].attitude[friend] = 80; // pin above friendThreshold — isolate the dice roll
Diplo.runDiplomacyTurn(RULES, stAI, asker);
anyOffered = !!stAI.empires[stAI.humanIndex].pendingOffers[asker];
}
check('runDiplomacyTurn eventually offers the human a peaceRequest for a war against a liked empire',
anyOffered, `pendingOffers=${JSON.stringify(stAI.empires[stAI.humanIndex].pendingOffers)}`);
}
// --- colonize() must trigger contact even when nobody's fleet just moved.
{
const stC = Logic.createGame(RULES, {
@ -2705,6 +2830,9 @@ section('7. Diplomacy and the Galactic Council');
'offerTradeAgreementOpener',
'fleetComplaintOpener', 'acknowledgeComplaintWithdraw', 'acknowledgeComplaintDefy',
'afterComplaintWithdrawPromise', 'afterComplaintDefy',
'requestPeaceThird', 'replyRequestPeaceAccept', 'replyRequestPeaceReject',
'offerPeaceRequestOpener', 'acceptPeaceRequest', 'rejectPeaceRequest',
'afterPeaceRequestAcceptedSuccess', 'afterPeaceRequestAcceptedTried', 'afterPeaceRequestRejected',
];
// Lore reply pools are meant to carry real variety — enforce a minimum
// beyond "non-empty" since pickLine's empty-pool fallback is silent and
@ -2736,44 +2864,70 @@ section('7b. Galactic News Network');
{
// --- classification: isGnnStory / pendingGnnStories, hand-built events so
// every branch (research+headline, espionage-sourced, non-headline,
// silent-sabotage, spyCaught, already-announced) is exercised directly
// rather than waiting for a soak to happen to produce one of each.
// attributed/anonymous sabotage+theft, spyCaught, already-announced) is
// exercised directly rather than waiting for a soak to happen to produce
// one of each.
{
const headlineTech = 'lasercannon'; // gnnHeadline: true, tier 0 weapons
const plainTech = 'battlescanner'; // not flagged
check('fixture headline tech is actually flagged', RULES.techs[headlineTech]?.gnnHeadline === true);
check('fixture plain tech is not flagged', !RULES.techs[plainTech]?.gnnHeadline);
// humanIndex 0. Empire 3 is a bystander the human knows and likes, 4 a
// bystander the human knows and despises, 5 a bystander never met at
// all — exercises isGnnStory's contact+attitude gate for a successful
// mission that doesn't touch the human directly.
const fakeState = {
humanIndex: 0,
empires: [{ idx: 0, contacted: { 3: true, 4: true }, attitude: { 3: 10, 4: -80 } }],
events: [],
};
const evResearchHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'research' };
const evEspionageHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'espionage' };
const evResearchPlain = { type: 'techDone', empire: 0, techId: plainTech, source: 'research' };
const evSabotage = { type: 'sabotage', empire: 0, target: 1, starIdx: 0 };
const evTechStolen = { type: 'techStolen', empire: 0, target: 1, techId: headlineTech };
const evSabotageMine = { type: 'sabotage', empire: 0, target: 1, starIdx: 0, factoriesLost: 2, defenseLost: 3 };
const evTechStolenMine = { type: 'techStolen', empire: 0, target: 1, techId: headlineTech };
const evTechStolenVictimIsMe = { type: 'techStolen', empire: 1, target: 0, techId: headlineTech };
const evTechStolenBystanderFriendly = { type: 'techStolen', empire: 1, target: 3, techId: headlineTech };
const evTechStolenBystanderHostile = { type: 'techStolen', empire: 1, target: 4, techId: headlineTech };
const evTechStolenBystanderUnknown = { type: 'techStolen', empire: 1, target: 5, techId: headlineTech };
const evSpyCaught = { type: 'spyCaught', empire: 0, target: 1, mission: 'sabotage' };
const evPeace = { type: 'peace', empire: 0, other: 1 };
const evWarAlreadyAnnounced = { type: 'warDeclared', empire: 0, other: 1, gnnAnnounced: true };
check('research-sourced headline tech is a GNN story', Gnn.isGnnStory(RULES, evResearchHeadline));
check('research-sourced headline tech is a GNN story', Gnn.isGnnStory(RULES, fakeState, evResearchHeadline));
check('espionage-sourced headline tech is NOT a GNN story (nobody can publicly name the thief\'s target)',
!Gnn.isGnnStory(RULES, evEspionageHeadline));
check('research-sourced non-headline tech is NOT a GNN story', !Gnn.isGnnStory(RULES, evResearchPlain));
check('a successful sabotage is never a GNN story (silent theft is unattributable)',
!Gnn.isGnnStory(RULES, evSabotage));
check('a successful tech theft is never a GNN story', !Gnn.isGnnStory(RULES, evTechStolen));
check('a caught spy ("exposed") is a GNN story', Gnn.isGnnStory(RULES, evSpyCaught));
check('peace is a GNN story', Gnn.isGnnStory(RULES, evPeace));
!Gnn.isGnnStory(RULES, fakeState, evEspionageHeadline));
check('research-sourced non-headline tech is NOT a GNN story', !Gnn.isGnnStory(RULES, fakeState, evResearchPlain));
check('the player\'s own successful sabotage IS a GNN story, attributed to them',
Gnn.isGnnStory(RULES, fakeState, evSabotageMine));
check('the player\'s own successful tech theft IS a GNN story, attributed to them',
Gnn.isGnnStory(RULES, fakeState, evTechStolenMine));
check('the player being victimized IS a GNN story regardless of contact/attitude',
Gnn.isGnnStory(RULES, fakeState, evTechStolenVictimIsMe));
check('a bystander in contact and on decent terms with the victim hears the rumor',
Gnn.isGnnStory(RULES, fakeState, evTechStolenBystanderFriendly));
check('a bystander hostile/cold toward the victim does not hear the rumor',
!Gnn.isGnnStory(RULES, fakeState, evTechStolenBystanderHostile));
check('a bystander with no contact with the victim does not hear the rumor',
!Gnn.isGnnStory(RULES, fakeState, evTechStolenBystanderUnknown));
check('a caught spy ("exposed") is a GNN story', Gnn.isGnnStory(RULES, fakeState, evSpyCaught));
check('peace is a GNN story', Gnn.isGnnStory(RULES, fakeState, evPeace));
const fakeState = {
events: [
evResearchHeadline, evEspionageHeadline, evResearchPlain, evSabotage,
evTechStolen, evSpyCaught, evPeace, evWarAlreadyAnnounced,
],
};
fakeState.events = [
evResearchHeadline, evEspionageHeadline, evResearchPlain, evSabotageMine, evTechStolenMine,
evTechStolenVictimIsMe, evTechStolenBystanderFriendly, evTechStolenBystanderHostile,
evTechStolenBystanderUnknown, evSpyCaught, evPeace, evWarAlreadyAnnounced,
];
const pending = Gnn.pendingGnnStories(RULES, fakeState);
check('pendingGnnStories excludes an already-gnnAnnounced event', !pending.includes(evWarAlreadyAnnounced));
const expectedPending = [
evResearchHeadline, evSabotageMine, evTechStolenMine, evTechStolenVictimIsMe,
evTechStolenBystanderFriendly, evSpyCaught, evPeace,
];
check('pendingGnnStories returns exactly the expected set',
pending.length === 3 && pending.includes(evResearchHeadline)
&& pending.includes(evSpyCaught) && pending.includes(evPeace),
pending.length === expectedPending.length && expectedPending.every((e) => pending.includes(e)),
`got ${pending.length}: ${pending.map((e) => e.type).join(',')}`);
}
@ -2819,6 +2973,10 @@ section('7b. Galactic News Network');
{ type: 'eliminated', empire: 1, attacker: 0 },
{ type: 'eliminated', empire: 1, attacker: -1 },
{ type: 'spyCaught', empire: 0, target: 1, mission: 'sabotage' },
{ type: 'sabotage', empire: 0, target: 1, starIdx: 0, factoriesLost: 2, defenseLost: 3 },
{ type: 'sabotage', empire: 1, target: 0, starIdx: 0, factoriesLost: 2, defenseLost: 3 },
{ type: 'techStolen', empire: 0, target: 1, techId: 'lasercannon' },
{ type: 'techStolen', empire: 1, target: 0, techId: 'lasercannon' },
];
for (const ev of sampleEvents) {
const desc = Gnn.describeGnnStory(RULES, stD, ev);
@ -2830,6 +2988,18 @@ section('7b. Galactic News Network');
check('describeGnnStory names the attacker when known',
Gnn.describeGnnStory(RULES, stD, { type: 'eliminated', empire: 1, attacker: 0 })
.headline.includes(stD.empires[0].name));
// A successful mission's headline names the actor ONLY when it was
// attributed to the viewing human — never the actual perpetrator when it
// wasn't (Brian's ask: "no information on who was responsible").
const mineTheft = Gnn.describeGnnStory(RULES, stD, { type: 'techStolen', empire: 0, target: 1, techId: 'lasercannon' });
const othersTheft = Gnn.describeGnnStory(RULES, stD, { type: 'techStolen', empire: 1, target: 0, techId: 'lasercannon' });
check('a mission the player carried out is marked attributed', mineTheft.attributed === true);
check('a mission the player carried out names the player as the actor',
mineTheft.headline.includes(stD.empires[0].name));
check('a mission carried out against the player is NOT marked attributed', othersTheft.attributed === false);
check('a mission carried out against the player never names the actual perpetrator',
!othersTheft.headline.includes(stD.empires[1].name));
}
// --- anchorLine: the green-screen ticker narration under GNN's anchor
@ -2854,6 +3024,10 @@ section('7b. Galactic News Network');
{ type: 'eliminated', empire: 1, attacker: -1 },
{ type: 'spyCaught', empire: 0, target: 1, mission: 'sabotage' },
{ type: 'spyCaught', empire: 0, target: 1, mission: 'steal' },
{ type: 'sabotage', empire: 0, target: 1, starIdx: 0, factoriesLost: 2, defenseLost: 3 },
{ type: 'sabotage', empire: 1, target: 0, starIdx: 0, factoriesLost: 2, defenseLost: 3 },
{ type: 'techStolen', empire: 0, target: 1, techId: 'lasercannon' },
{ type: 'techStolen', empire: 1, target: 0, techId: 'lasercannon' },
];
for (const ev of sampleEvents) {
const desc = Gnn.describeGnnStory(RULES, stD, ev);
@ -3359,7 +3533,7 @@ section('10. AI self-play soak');
for (const g of games) {
for (const ev of g.st.events) {
if (ev.type === 'lastColony') lastColonyCount += 1;
if (!Gnn.isGnnStory(RULES, ev)) continue;
if (!Gnn.isGnnStory(RULES, g.st, ev)) continue;
storyEventCount += 1;
try {
const desc = Gnn.describeGnnStory(RULES, g.st, ev);
@ -3399,10 +3573,11 @@ section('11. Combat V2 (per-ship prototype)');
{
// Headless like everything else here — VegaCombatV2.js is Phaser-free, so
// this whole section is a plain Node exercise, same pattern as section 5.
// This engine is NOT used by real player battles (wired only into
// VegaCombatSim.js's ?movsim Live/V2 toggle) — see
// docs/mastervega-build-plan.md for why it's a parallel prototype rather
// than a rewrite of VegaCombat.js in place.
// This IS the resolver real player battles use (VegaLogic.js imports
// createBattle/runBattle from here, not from VegaCombat.js — V1 is kept
// only for resolveInvasion and the ?movsim Live/V2 comparison toggle) —
// see docs/mastervega-build-plan.md for the history of why it started as
// a parallel prototype.
const techsUpToV2 = (tier) => {
const k = {};
for (const t of RULES.techList) if (t.tier <= tier) k[t.id] = true;
@ -3490,6 +3665,64 @@ section('11. Combat V2 (per-ship prototype)');
both1.attackerFormation === both2.attackerFormation && both1.defenderFormation === both2.defenderFormation);
}
// Logic.prepareBattleAt — the real-game seam between the engine and a
// player battle. Scripted through a real state (not a hand-built fixture)
// since it reads fleets/colonies/buildings/galaxy data no synthetic object
// could cheaply fake correctly. Covers three things fixed together in one
// pass: the player's own pre-battle formation choice actually reaching the
// battle, the Planetary Shield building's shieldBonus actually reaching
// the defended planet (it was hard-coded to 0 before, so the building's
// effect was silently never applied to a real battle), and the planet's
// typeId riding along for the tactical view's real planet art.
{
const st = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'cluster', seed: 5151, difficultyId: 'normal',
speciesIds: ['human', 'kkrix'], humanIndex: 0,
});
st.rules = RULES;
const attackerIdx = st.humanIndex;
const defenderIdx = attackerIdx === 0 ? 1 : 0;
const defColony = st.colonies.find((c) => c.empireIdx === defenderIdx);
const starIdx = defColony.starIdx;
defColony.defenseHp = 200;
defColony.buildings.push('planetaryshield');
Diplo.declareWar(RULES, st, attackerIdx, defenderIdx);
Logic.addFleet(RULES, st, attackerIdx, starIdx, [{ hullId: 'frigate', mark: 1, count: 2 }]);
const prepared = Logic.prepareBattleAt(RULES, st, starIdx, attackerIdx, defenderIdx, { humanFormation: 'speed_swarm' });
check('prepareBattleAt finds a valid battle for the scripted attack-a-defended-colony scenario', !!prepared);
const humanIsAttacker = prepared.attackerIdx === st.humanIndex;
const humanFormationOnBattle = humanIsAttacker ? prepared.battle.attackerFormation : prepared.battle.defenderFormation;
check("the human's chosen formation reaches battle.*Formation on the human's own side",
humanFormationOnBattle === 'speed_swarm', humanFormationOnBattle);
const validIds = FORMATION_STRATEGIES.map((f) => f.id);
const aiFormationOnBattle = humanIsAttacker ? prepared.battle.defenderFormation : prepared.battle.attackerFormation;
check("the AI opponent's formation is still a silent (but valid) pick, not forced",
validIds.includes(aiFormationOnBattle));
const planetEntity = prepared.battle.ships.find((s) => s.isPlanet);
check('the defended planet entity exists in the prepared battle', !!planetEntity);
check("the Planetary Shield building's shieldBonus (5) reaches the planet entity's shield — was hard-coded to 0 before",
planetEntity?.shield === RULES.buildings.planetaryshield.effects.shieldBonus,
`${planetEntity?.shield}`);
const expectedTypeId = st.galaxy.stars[starIdx].planets[defColony.orbit].typeId;
check("the planet entity's typeId matches the actual colonised planet",
planetEntity?.typeId === expectedTypeId, `${planetEntity?.typeId} vs ${expectedTypeId}`);
check('the planet sits at the fixed midpoint between world centre and its side\'s edge (worldWidth * 0.75)',
Math.abs(planetEntity.x - RULES.combatV2.worldWidth * 0.75) < 1e-9, `${planetEntity.x}`);
// A colony with no Planetary Shield still fights, with no shield at all
// (not a stale nonzero default) — the ring the view draws is entirely
// conditional on this being > 0.
const unshieldedColony = st.colonies.find((c) => c.empireIdx === defenderIdx);
unshieldedColony.buildings = unshieldedColony.buildings.filter((b) => b !== 'planetaryshield');
const preparedNoShield = Logic.prepareBattleAt(RULES, st, starIdx, attackerIdx, defenderIdx, {});
const planetNoShield = preparedNoShield.battle.ships.find((s) => s.isPlanet);
check('a colony with no Planetary Shield building reaches battle with shield 0',
planetNoShield.shield === 0, `${planetNoShield.shield}`);
}
// Determinism, and auto-resolve agreeing with a played-out battle — same
// structural guarantee as the live engine's equivalent check.
{