Compare commits

..

No commits in common. "8f0b079fa4b990b53e1a0ebc271821dcc3a4f013" and "b145beb28d0c87ad095e00a3ab5c30cdd0e8de00" have entirely different histories.

19 changed files with 222 additions and 1164 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

View File

@ -298,16 +298,6 @@
"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,
@ -316,7 +306,7 @@
"frameHeight": 48,
"cols": 10,
"rows": 10,
"_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."
"_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."
}
}
}

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": 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": "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": "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": "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": "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": "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": "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": "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": "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": "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": "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 }
{ "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 }
],
"_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,16 +433,11 @@
"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 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.",
"_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.",
"sabotageFactoriesFraction": 0.25,
"sabotageDefenseFraction": 0.25
"sabotageDefenseFraction": 0.25,
"sabotageAttitudePenalty": 14
},
"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, openFormationPicker,
openSaveScreen, openLoadScreen, showVictoryOverlay,
} from './VegaScreens.js';
import { openColoniesScreen } from './VegaColoniesScreen.js';
import { openResearchScreen } from './VegaResearchScreen.js';
@ -1167,29 +1167,23 @@ 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;
// 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 });
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);
},
});
};
next(0);
}

View File

@ -45,7 +45,6 @@ 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';
@ -536,14 +535,10 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
}
const openingOffer = state.empires[me].pendingOffers[otherIdx];
if (openingOffer) {
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));
}
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,
@ -573,29 +568,9 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
}
function doPending(accept) {
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,
));
pushChat('p', pickLine(accept ? chat.acceptOffer : chat.rejectOffer, vars));
respondToOffer(rules, state, me, otherIdx, accept);
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));
}
say(pickLine(accept ? chat.afterAccepted : chat.afterRejected, vars));
afterAction();
}
@ -653,18 +628,6 @@ 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);
@ -705,10 +668,7 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
say(pickLine(chat.storyLore, vars));
}
// `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) {
function pickFromList(title, ids, onPick) {
const box = scene.add.container(0, 0);
root.add(box);
const bx = GAME_WIDTH / 2;
@ -718,7 +678,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, labelFn(id), {
const t = scene.add.text(bx, by - h / 2 + 66 + i * 34, rules.techs[id].name, {
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
t.on('pointerover', () => t.setColor('#ffd88a'));
@ -731,12 +691,6 @@ 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));
@ -814,14 +768,6 @@ 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,8 +13,6 @@
// {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.
//
@ -38,23 +36,6 @@
// 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
@ -208,42 +189,6 @@ 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.',
@ -412,42 +357,6 @@ 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.',
@ -615,42 +524,6 @@ 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.',
@ -819,42 +692,6 @@ 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.',
@ -1023,42 +860,6 @@ 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.',
@ -1226,42 +1027,6 @@ 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.',
@ -1429,42 +1194,6 @@ 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.',
@ -1632,42 +1361,6 @@ 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.',
@ -1835,42 +1528,6 @@ 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 { formationName } from './VegaFormations.js';
import { FORMATION_STRATEGIES, formationName } from './VegaFormations.js';
import { VegaMusic } from './VegaMusic.js';
import { FONT, uiClick, openFormationPicker } from './VegaScreens.js';
import { FONT, uiClick, modalShell } 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.
openFormationPicker(this, (formationStrategy) => this.launchV2Battle(formationStrategy));
this.openFormationPicker((formationStrategy) => this.launchV2Battle(formationStrategy));
return;
}
const { attacker, defender, colony } = this.buildBattleOpts();
@ -269,6 +269,38 @@ 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 fleet’s 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,9 +448,10 @@ 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
// 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).
// 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.
//
// 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,
@ -575,15 +576,10 @@ 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. 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.
// 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.
let planet = null;
if (colony && colony.defenseHp > 0) {
planet = {
@ -592,11 +588,10 @@ 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: C2.worldWidth * 0.75,
x: layout.defenderEndX + 220,
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, planetFrame } from './VegaArt.js';
import { shipFrame } from './VegaArt.js';
import { buildParallax, bindZoomPan } from './VegaCombatCamera.js';
import { formationName } from './VegaFormations.js';
import { makeCommanderPortrait } from './VegaShipMedia.js';
@ -36,11 +36,6 @@ 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
@ -348,25 +343,13 @@ 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 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);
}
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);
} else {
const species = s.side === 'attacker' ? attackerSpecies : defenderSpecies;
const size = SHIP_BASE_SIZE * (s.design.hull.sizeScale ?? 1);
@ -384,7 +367,7 @@ export function openCombatViewV2(scene, rules, battle, art, opts = {}) {
c.add(img);
}
shipLayer.add(c);
markers.set(s.uid, { ship: s, container: c, shieldRing });
markers.set(s.uid, { ship: s, container: c });
}
refreshSummaries();
}
@ -400,9 +383,6 @@ 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,61 +139,6 @@ 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
@ -221,21 +166,6 @@ 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
@ -404,22 +334,6 @@ 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,13 +11,11 @@
import { empireColonies, colonyProduction, empireFleetPower } from './VegaLogic.js';
import { pickLine } from './VegaChat.js';
import { attitudeOf, moodOf } from './VegaDiplomacy.js';
// The nine event types GNN ever turns into a story page. Council/election
// The seven 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',
]);
/**
@ -25,35 +23,22 @@ 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 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.
* "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.
*/
export function isGnnStory(rules, state, ev) {
export function isGnnStory(rules, 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, state, ev));
return state.events.filter((ev) => !ev.gnnAnnounced && isGnnStory(rules, ev));
}
// Replay buffer for the on-demand HUD button when there's nothing new to
@ -127,34 +112,6 @@ 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 };
}
}
@ -290,16 +247,6 @@ 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}.',
@ -368,10 +315,6 @@ 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,54 +250,6 @@ 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.');
}
@ -307,7 +259,6 @@ const STORY_RENDERERS = {
tech: renderTechStory,
territory: renderTerritoryStory,
espionage: renderEspionageStory,
espionageResult: renderEspionageResultStory,
unknown: renderUnknownStory,
};
@ -583,20 +534,11 @@ 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) }));
// 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 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];
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,10 +1016,6 @@ 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;
@ -1033,6 +1029,7 @@ 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;
}
@ -1041,6 +1038,7 @@ 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) {
@ -1334,13 +1332,7 @@ 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.
// `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 } = {}) {
export function prepareBattleAt(rules, state, starIdx, a, b) {
const colony = colonyAt(state, starIdx);
const defenderIdx = colony && (colony.empireIdx === a || colony.empireIdx === b) ? colony.empireIdx : b;
const attackerIdx = defenderIdx === a ? b : a;
@ -1356,29 +1348,14 @@ export function prepareBattleAt(rules, state, starIdx, a, b, { humanFormation =
};
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: buildingEffect(rules, defColony, 'shieldBonus'),
typeId: state.galaxy.stars[starIdx]?.planets[defColony.orbit]?.typeId,
} : null,
colony: defColony ? { defenseHp: defColony.defenseHp, shieldBonus: 0 } : null,
starIdx,
rnd: () => rand(state),
});

View File

@ -217,10 +217,7 @@ export function openResearchScreen(scene, rules, state, e, art, onClose, { initi
gridLayer.add(bg);
const lvl = fieldTechLevel(rules, state, e, field.id);
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}`, {
gridLayer.add(scene.add.text(bx + 12, by + 8, `${field.name.toUpperCase()} · Lv ${lvl}`, {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));
@ -315,10 +312,7 @@ export function openResearchScreen(scene, rules, state, e, art, onClose, { initi
const field = rules.techFields[selectedField];
const detailLvl = fieldTechLevel(rules, state, e, selectedField);
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})`, {
detailLayer.add(scene.add.text(detailX, headingY, `${field.name.toUpperCase()} — TECH TREE (Level ${detailLvl})`, {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));

View File

@ -33,12 +33,6 @@ 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,7 +18,6 @@ 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
@ -158,47 +157,6 @@ 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,39 +295,19 @@ Enrichment Facility.
---
## 7a. `techfields` — ⬜ `assets/images/vega/techfields.png`
## 7. `techicons` — ⬜ `assets/images/vega/techicons.png`
| | |
|---|---|
| Sheet size | **144 × 96** |
| Sheet size | **480 × 336** |
| Frame | **48 × 48** |
| Grid | 3 cols × 2 rows = 6 frames |
| Grid | 10 cols × 7 rows = 70 frames |
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`), 0–90, in the order techs
appear in `data/mastervega-rules.json` — the original 60 plus two later
MOO1-branching passes. 91–99 spare (the tech list has grown once already;
expect it to again). Drawn today by `VegaGnnScreen.js`'s tech-breakthrough
story page.
- Frames **0–5**: the six tech *fields* — Computers, Construction, Force Fields,
Planetology, Propulsion, Weapons.
- Frames **6–65**: the sixty individual techs, in the order they appear in
`data/mastervega-rules.json` (`techs[].iconFrame`).
- Frames 66–69: spare.
---

View File

@ -103,15 +103,6 @@ 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',
@ -2344,122 +2335,6 @@ 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, {
@ -2830,9 +2705,6 @@ 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
@ -2864,70 +2736,44 @@ section('7b. Galactic News Network');
{
// --- classification: isGnnStory / pendingGnnStories, hand-built events so
// every branch (research+headline, espionage-sourced, non-headline,
// attributed/anonymous sabotage+theft, spyCaught, already-announced) is
// exercised directly rather than waiting for a soak to happen to produce
// one of each.
// silent-sabotage, 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 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 evSabotage = { type: 'sabotage', empire: 0, target: 1, starIdx: 0 };
const evTechStolen = { type: 'techStolen', empire: 0, target: 1, 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, fakeState, evResearchHeadline));
check('research-sourced headline tech is a GNN story', Gnn.isGnnStory(RULES, evResearchHeadline));
check('espionage-sourced headline tech is NOT a GNN story (nobody can publicly name the thief\'s target)',
!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));
!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));
fakeState.events = [
evResearchHeadline, evEspionageHeadline, evResearchPlain, evSabotageMine, evTechStolenMine,
evTechStolenVictimIsMe, evTechStolenBystanderFriendly, evTechStolenBystanderHostile,
evTechStolenBystanderUnknown, evSpyCaught, evPeace, evWarAlreadyAnnounced,
];
const fakeState = {
events: [
evResearchHeadline, evEspionageHeadline, evResearchPlain, evSabotage,
evTechStolen, 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 === expectedPending.length && expectedPending.every((e) => pending.includes(e)),
pending.length === 3 && pending.includes(evResearchHeadline)
&& pending.includes(evSpyCaught) && pending.includes(evPeace),
`got ${pending.length}: ${pending.map((e) => e.type).join(',')}`);
}
@ -2973,10 +2819,6 @@ 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);
@ -2988,18 +2830,6 @@ 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
@ -3024,10 +2854,6 @@ 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);
@ -3533,7 +3359,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, g.st, ev)) continue;
if (!Gnn.isGnnStory(RULES, ev)) continue;
storyEventCount += 1;
try {
const desc = Gnn.describeGnnStory(RULES, g.st, ev);
@ -3573,11 +3399,10 @@ 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 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.
// 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.
const techsUpToV2 = (tier) => {
const k = {};
for (const t of RULES.techList) if (t.tier <= tier) k[t.id] = true;
@ -3665,64 +3490,6 @@ 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.
{