feat: add Master of Vega (Master of Orion clone)

Introduces Master of Vega, a turn-based 4X strategy game built on MOO1 rules with MOO2 conveniences. The implementation features a fully headless, deterministic engine (VegaLogic) with zero Phaser dependencies, enabling complete Node-based verification (809 checks) and reproducible galaxy generation via mulberry32 RNG.

Key features:
- Preset ship hulls with an auto-refitting Mark system and provably monotonic knapsack loadouts.
- Tactical grid combat with banked damage resolution to eliminate first-strike bias.
- Galactic Council diplomacy, stalemate-breaking, and invasion forecasting.
- Drop-in artwork compatibility with procedural stand-ins for all spritesheets.
- Hireable leaders (admin/captain) and colony building queues.

Architecture splits cleanly into headless modules (GalaxyGen, Logic, AI, Combat, Diplomacy, Ships, Leaders) and a render tier (StarMap, SystemView, CombatView, Screens, Nebula, Art, Fx). A comprehensive build plan documents 19 architectural traps discovered and resolved during development.

Registers the game in the registry, asset manifest, preload scene, and soundtrack service. Includes tools/verifyMasterOfVega.js for automated rule validation, art verification, economy balancing, and AI self-play soaking.
This commit is contained in:
Brian Fertig 2026-08-01 22:17:31 -06:00
parent 390dadbc14
commit 91f63b76a2
36 changed files with 8086 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,77 @@
{
"_readme": "Master of Vega — drop-in artwork manifest. Every sheet starts with path:null, which means VegaArt.js paints a procedural stand-in at exactly the same frame geometry. To use real art, drop the PNG in assets/images/mastervega/ and set its path here — no code changes. Frame layouts are documented in src/games/mastervega/sprites.md and are APPEND-ONLY.",
"_kindReadme": "kind selects the procedural painter in VegaArt.PROC_PAINTERS. Painters are keyed by kind, never by sheet name, so adding a sheet later needs no code change.",
"sheets": {
"ships": {
"key": "vega-ships",
"path": null,
"kind": "ship",
"frameWidth": 96,
"frameHeight": 96,
"cols": 8,
"rows": 10,
"_layout": "One row per species (species.shipFrame), one column per hull (hull.frame). Frame = shipFrame * 8 + hull.frame. Ships face UP at frame rest."
},
"planets": {
"key": "vega-planets",
"path": null,
"kind": "planet",
"frameWidth": 192,
"frameHeight": 192,
"cols": 5,
"rows": 3,
"_layout": "One frame per planetType.frame, 0-14, in the order planetTypes appears in the rules file."
},
"stars": {
"key": "vega-stars",
"path": null,
"kind": "star",
"frameWidth": 192,
"frameHeight": 192,
"cols": 3,
"rows": 3,
"_layout": "One frame per starClass, in rules order: blue, white, yellow, orange, red, brown, binary, pulsar, blackhole."
},
"portraits": {
"key": "vega-portraits",
"path": null,
"kind": "portrait",
"frameWidth": 256,
"frameHeight": 256,
"cols": 5,
"rows": 2,
"_layout": "One frame per species.portraitFrame, 0-9, in rules order."
},
"leaders": {
"key": "vega-leaders",
"path": null,
"kind": "portrait",
"frameWidth": 160,
"frameHeight": 160,
"cols": 4,
"rows": 4,
"_layout": "One frame per leader.portraitFrame, 0-15, in rules order."
},
"buildings": {
"key": "vega-buildings",
"path": null,
"kind": "icon",
"frameWidth": 64,
"frameHeight": 64,
"cols": 8,
"rows": 2,
"_layout": "One frame per building.frame, 0-15, in rules order."
},
"techicons": {
"key": "vega-techicons",
"path": null,
"kind": "icon",
"frameWidth": 48,
"frameHeight": 48,
"cols": 10,
"rows": 7,
"_layout": "Frames 0-5 are the six tech FIELDS (techFields[].iconFrame); frames 6-65 are the individual techs (techs[].iconFrame). 66-69 spare."
}
}
}

423
data/mastervega-rules.json Normal file
View File

@ -0,0 +1,423 @@
{
"_readme": "Master of Vega — rule set. Compiled and validated by src/games/mastervega/VegaRules.js; every number here is tunable without touching code. Techs form six independent linear chains (one per field); tier doubles as the topological rank. Frame indexes are APPEND-ONLY: never renumber, only add at the end, or existing drop-in artwork silently shifts.",
"version": 1,
"techFields": [
{ "id": "computers", "name": "Computers", "desc": "Targeting, scanners, espionage and research throughput.", "iconFrame": 0 },
{ "id": "construction","name": "Construction", "desc": "Hull armour, damage control and industrial efficiency.", "iconFrame": 1 },
{ "id": "forcefields", "name": "Force Fields", "desc": "Deflector shields, planetary shields and cloaking.", "iconFrame": 2 },
{ "id": "planetology", "name": "Planetology", "desc": "Terraforming, ecology and colonising hostile worlds.", "iconFrame": 3 },
{ "id": "propulsion", "name": "Propulsion", "desc": "Engine speed and the fuel range that lights up the map.", "iconFrame": 4 },
{ "id": "weapons", "name": "Weapons", "desc": "Beams, missiles and planet-cracking bombs.", "iconFrame": 5 }
],
"_techsReadme": "prereqs is always 0 or 1 entries, pointing at the previous tech in the same field. tier must equal the topological rank. effects is a free-form bag read by VegaShips (weapon/armor/shield/engine/fuelRange/targeting) and VegaLogic (everything else).",
"techs": [
{ "id": "electroniccomputer", "name": "Electronic Computer", "field": "computers", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 6, "desc": "Mark I battle computer. +1 to hit.", "effects": { "targeting": 1 } },
{ "id": "battlescanner", "name": "Battle Scanner", "field": "computers", "tier": 1, "prereqs": ["electroniccomputer"], "cost": 150, "iconFrame": 7, "desc": "Reveals enemy fleet composition and adds combat initiative.", "effects": { "initiative": 2, "scanRange": 1 } },
{ "id": "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": "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 } },
{ "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": "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": "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 } },
{ "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 } },
{ "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": "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": "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": "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": "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 } } },
{ "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 } } },
{ "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": "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": "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 } },
{ "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": "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 } },
{ "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 } },
{ "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": "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": "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": "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 } },
{ "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": "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 } },
{ "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": "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": "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": "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": "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 } } },
{ "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 } },
{ "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 } } },
{ "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": "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": "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": "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": "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 } } },
{ "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 } } }
],
"_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.",
"hulls": [
{ "id": "scout", "name": "Scout", "role": "recon", "baseCost": 18, "baseHp": 10, "space": 0, "speedBonus": 2, "rangeBonus": 3, "frame": 0, "desc": "Unarmed, fast and long-legged. Peels back the dark." },
{ "id": "colonyship", "name": "Colony Ship", "role": "colony", "baseCost": 100, "baseHp": 18, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 1, "desc": "Carries a founding population. Consumed on arrival." },
{ "id": "transport", "name": "Troop Transport","role": "troops","baseCost": 40, "baseHp": 20, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 2, "troops": 4, "desc": "Four divisions of marines for boarding and invasion." },
{ "id": "frigate", "name": "Frigate", "role": "warship", "baseCost": 35, "baseHp": 18, "space": 6, "speedBonus": 1, "rangeBonus": 1, "frame": 3, "desc": "Cheap escort. Screens the line and hunts scouts." },
{ "id": "destroyer", "name": "Destroyer", "role": "warship", "baseCost": 90, "baseHp": 40, "space": 14, "speedBonus": 1, "rangeBonus": 0, "frame": 4, "desc": "The workhorse combatant of any mid-game fleet." },
{ "id": "cruiser", "name": "Cruiser", "role": "warship", "baseCost": 240, "baseHp": 100, "space": 32, "speedBonus": 0, "rangeBonus": 0, "frame": 5, "desc": "Heavy line ship. Expensive enough to hurt when it dies." },
{ "id": "battleship", "name": "Battleship", "role": "warship", "baseCost": 640, "baseHp": 260, "space": 72, "speedBonus": 0, "rangeBonus": 0, "frame": 6, "desc": "A mobile fortress. Whole economies are built to field these." },
{ "id": "starbase", "name": "Star Base", "role": "base", "baseCost": 200, "baseHp": 160, "space": 40, "speedBonus": 0, "rangeBonus": 0, "frame": 7, "immobile": true, "desc": "Orbital fortress. Never moves, and extends fuel range." }
],
"_buildingsReadme": "Colony structures. channel names the slider a building multiplies (industry/research/defense/ecology/ships), or is null for a flat effect. Buildings are built from the colony queue; effects stack multiplicatively within a channel.",
"buildings": [
{ "id": "automatedfactory", "name": "Automated Factory", "prereq": null, "cost": 60, "upkeep": 1, "channel": "industry", "mult": 1.5, "frame": 0, "desc": "Half again as much industrial output." },
{ "id": "researchlab", "name": "Research Laboratory","prereq": "electroniccomputer", "cost": 90, "upkeep": 2, "channel": "research", "mult": 1.5, "frame": 1, "desc": "Half again as much research output." },
{ "id": "missilebase", "name": "Missile Base", "prereq": "hypervrockets", "cost": 80, "upkeep": 2, "channel": "defense", "mult": 1.6, "frame": 2, "desc": "Ground-launched interceptors bite into orbiting fleets." },
{ "id": "pollutionprocessor","name": "Pollution Processor","prereq": "ecorestoration", "cost": 70, "upkeep": 1, "channel": "ecology", "mult": 0.6, "frame": 3, "desc": "Waste cleanup costs 40% less on this world." },
{ "id": "spaceport", "name": "Space Port", "prereq": "deuteriumcells", "cost": 110, "upkeep": 2, "channel": null, "mult": 1, "frame": 4, "effects": { "tradeBonus": 4 }, "desc": "Civilian traffic. +4 BC per turn." },
{ "id": "cloningcenter", "name": "Cloning Center", "prereq": "controlledbarren", "cost": 120, "upkeep": 2, "channel": null, "mult": 1, "frame": 5, "effects": { "growthMult": 1.5 }, "desc": "Population grows 50% faster here." },
{ "id": "groundbattery", "name": "Ground Battery", "prereq": "personalshield", "cost": 95, "upkeep": 2, "channel": null, "mult": 1, "frame": 6, "effects": { "groundDefense": 25 }, "desc": "+25 to ground defence against invasion." },
{ "id": "spycenter", "name": "Spy Center", "prereq": "neuralscanner", "cost": 140, "upkeep": 3, "channel": null, "mult": 1, "frame": 7, "effects": { "espionage": 20 }, "desc": "+20 to espionage and counter-espionage empire-wide." },
{ "id": "planetaryshield", "name": "Planetary Shield", "prereq": "planetaryshield5", "cost": 180, "upkeep": 3, "channel": "defense", "mult": 1.4, "frame": 8, "effects": { "shieldBonus": 5 }, "desc": "A hard shell over the whole colony." },
{ "id": "terraformingplant","name": "Terraforming Plant", "prereq": "soilenrichment", "cost": 200, "upkeep": 3, "channel": null, "mult": 1, "frame": 9, "effects": { "maxPopBonus": 20 }, "desc": "+20 maximum population on this world." },
{ "id": "stockexchange", "name": "Stock Exchange", "prereq": "improvedindustrial", "cost": 160, "upkeep": 2, "channel": null, "mult": 1, "frame": 10, "effects": { "tradeMult": 1.5 }, "desc": "Colony trade income up 50%." },
{ "id": "holosimulator", "name": "Holo Simulator", "prereq": "optroniccomputer", "cost": 150, "upkeep": 3, "channel": null, "mult": 1, "frame": 11, "effects": { "moraleBonus": 10, "tradeBonus": 3 }, "desc": "Contented citizens. +10 morale, +3 BC." },
{ "id": "robominers", "name": "Robotic Miners", "prereq": "zortriumarmor", "cost": 210, "upkeep": 3, "channel": "industry", "mult": 1.35, "frame": 12, "desc": "Deep-crust extraction. Another 35% industry." },
{ "id": "supercomputer", "name": "Super Computer", "prereq": "positroniccomputer", "cost": 260, "upkeep": 4, "channel": "research", "mult": 1.4, "frame": 13, "desc": "Another 40% research on this world." },
{ "id": "artemisnet", "name": "Artemis System Net", "prereq": "deflectorv", "cost": 300, "upkeep": 4, "channel": "defense", "mult": 1.5, "frame": 14, "effects": { "mineDamage": 12 }, "desc": "Automated mines damage every hostile fleet that enters." },
{ "id": "soilenrichmentfac","name": "Enrichment Facility","prereq": "atmosphericterraforming","cost": 340,"upkeep": 4,"channel": null, "mult": 1, "frame": 15, "effects": { "maxPopBonus": 35 }, "desc": "+35 maximum population on this world." }
],
"_planetTypesReadme": "hostility is the colonizeHostility level a species needs before it can settle here; 0 is open from turn one. habitability multiplies the size-derived population cap. Gas giants and asteroid belts are never colonisable.",
"planetTypes": [
{ "id": "terran", "name": "Terran", "hostility": 0, "habitability": 1.0, "colonizable": true, "frame": 0, "color": "#3f9d5a", "desc": "Blue skies and deep oceans. The prize of any system." },
{ "id": "ocean", "name": "Ocean", "hostility": 0, "habitability": 0.9, "colonizable": true, "frame": 1, "color": "#2a6fb0", "desc": "A drowned world with scattered archipelagos." },
{ "id": "jungle", "name": "Jungle", "hostility": 0, "habitability": 0.9, "colonizable": true, "frame": 2, "color": "#2f7a34", "desc": "Riotous biomass under permanent cloud." },
{ "id": "steppe", "name": "Steppe", "hostility": 0, "habitability": 0.8, "colonizable": true, "frame": 3, "color": "#8a9c4a", "desc": "Endless grassland and a thin, dry atmosphere." },
{ "id": "arid", "name": "Arid", "hostility": 0, "habitability": 0.7, "colonizable": true, "frame": 4, "color": "#b08a45", "desc": "Cracked riverbeds and a shrinking water table." },
{ "id": "desert", "name": "Desert", "hostility": 0, "habitability": 0.6, "colonizable": true, "frame": 5, "color": "#d0a353", "desc": "Sand seas from pole to pole." },
{ "id": "tundra", "name": "Tundra", "hostility": 0, "habitability": 0.5, "colonizable": true, "frame": 6, "color": "#8fb6c4", "desc": "Permafrost and a sun too far away." },
{ "id": "minimal", "name": "Minimal", "hostility": 0, "habitability": 0.4, "colonizable": true, "frame": 7, "color": "#9a9a86", "desc": "Barely an atmosphere, but it is breathable at noon." },
{ "id": "barren", "name": "Barren", "hostility": 1, "habitability": 0.3, "colonizable": true, "frame": 8, "color": "#8a7f70", "desc": "Airless rock. Habitats only." },
{ "id": "dead", "name": "Dead", "hostility": 2, "habitability": 0.25, "colonizable": true, "frame": 9, "color": "#6d6a63", "desc": "Something lived here once. Nothing does now." },
{ "id": "inferno", "name": "Inferno", "hostility": 3, "habitability": 0.25, "colonizable": true, "frame": 10, "color": "#c2502a", "desc": "Molten surface under a crushing greenhouse." },
{ "id": "toxic", "name": "Toxic", "hostility": 4, "habitability": 0.2, "colonizable": true, "frame": 11, "color": "#7fae3a", "desc": "Corrosive fog that eats through hab domes." },
{ "id": "radiated", "name": "Radiated", "hostility": 5, "habitability": 0.2, "colonizable": true, "frame": 12, "color": "#b6c23a", "desc": "Sterilised by its own star. Shielded colonies only." },
{ "id": "gasgiant", "name": "Gas Giant", "hostility": 9, "habitability": 0, "colonizable": false, "frame": 13, "color": "#c99a5b", "desc": "A banded giant. No surface to stand on." },
{ "id": "asteroids","name": "Asteroid Belt","hostility": 9, "habitability": 0, "colonizable": false, "frame": 14, "color": "#7a7268", "desc": "Shattered rubble. Rich, and impossible to settle." }
],
"planetSizes": [
{ "id": "tiny", "name": "Tiny", "basePop": 20, "weight": 15 },
{ "id": "small", "name": "Small", "basePop": 40, "weight": 25 },
{ "id": "medium", "name": "Medium", "basePop": 60, "weight": 30 },
{ "id": "large", "name": "Large", "basePop": 80, "weight": 20 },
{ "id": "huge", "name": "Huge", "basePop": 100, "weight": 10 }
],
"mineralRichness": [
{ "id": "ultrapoor", "name": "Ultra Poor", "industryMult": 0.5, "weight": 5 },
{ "id": "poor", "name": "Poor", "industryMult": 0.75,"weight": 15 },
{ "id": "normal", "name": "Abundant", "industryMult": 1.0, "weight": 55 },
{ "id": "rich", "name": "Rich", "industryMult": 1.5, "weight": 20 },
{ "id": "ultrarich", "name": "Ultra Rich", "industryMult": 2.0, "weight": 5 }
],
"gravity": [
{ "id": "low", "name": "Low Gravity", "combatMod": -10, "weight": 20 },
{ "id": "normal", "name": "Normal Gravity", "combatMod": 0, "weight": 60 },
{ "id": "high", "name": "High Gravity", "combatMod": -25, "weight": 20 }
],
"_starClassesReadme": "weight drives generation frequency; planetBias shifts the planet-type roll toward the good end (positive) or the hostile end (negative). special marks the flavour objects the star map renders with bespoke effects.",
"starClasses": [
{ "id": "blue", "name": "Blue", "color": "#9fc4ff", "coreColor": "#ffffff", "radius": 15, "weight": 8, "planetBias": -1, "richBias": 2, "special": null, "desc": "A furnace. Rich worlds, and most of them lethal." },
{ "id": "white", "name": "White", "color": "#e8eeff", "coreColor": "#ffffff", "radius": 13, "weight": 12, "planetBias": 0, "richBias": 1, "special": null, "desc": "Hot and steady." },
{ "id": "yellow", "name": "Yellow", "color": "#ffe9a3", "coreColor": "#fffdf0", "radius": 12, "weight": 22, "planetBias": 2, "richBias": 0, "special": null, "desc": "The kind of star that grows people." },
{ "id": "orange", "name": "Orange", "color": "#ffbf7a", "coreColor": "#fff0d8", "radius": 11, "weight": 20, "planetBias": 1, "richBias": 0, "special": null, "desc": "Long-lived and mild." },
{ "id": "red", "name": "Red", "color": "#ff8a6a", "coreColor": "#ffd8c8", "radius": 9, "weight": 22, "planetBias": -1, "richBias": -1, "special": null, "desc": "A dim ember. Cold worlds, poor ore." },
{ "id": "brown", "name": "Brown Dwarf","color": "#a16a55", "coreColor": "#d8a892", "radius": 7, "weight": 8, "planetBias": -2, "richBias": -1, "special": null, "desc": "Barely a star at all." },
{ "id": "binary", "name": "Binary", "color": "#ffd9a0", "coreColor": "#fff6e0", "radius": 11, "weight": 5, "planetBias": 0, "richBias": 1, "special": "binary", "desc": "Two suns locked around a common centre." },
{ "id": "pulsar", "name": "Pulsar", "color": "#c9e6ff", "coreColor": "#ffffff", "radius": 8, "weight": 2, "planetBias": -3, "richBias": 3, "special": "pulsar", "desc": "A lighthouse sweeping the void. Nothing lives nearby." },
{ "id": "blackhole","name": "Black Hole", "color": "#3a2b52", "coreColor": "#050308", "radius": 10, "weight": 1, "planetBias": -9, "richBias": 0, "special": "blackhole", "desc": "Light bends around it. Fleets that linger do not return." }
],
"_speciesReadme": "MOO1's ten races with everything but Humans renamed. Multipliers are relative to 1.0; flat modifiers are additive percentage points. techAffinity biases which techs a species can research at all (see VegaLogic.rollTechAvailability).",
"species": [
{
"id": "human", "name": "Human", "adjective": "Human", "plural": "Humans",
"color": "#4f8fe0", "portraitFrame": 0, "shipFrame": 0, "homeworld": "terran",
"desc": "Traders and talkers. Humans win the votes other empires assume they can ignore.",
"strengths": ["Finest diplomats in the galaxy", "+50% trade income"],
"weaknesses": ["No industrial or scientific edge"],
"traits": {
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.5, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 10, "groundDefense": 10,
"espionage": 0, "counterEspionage": 0, "diplomacy": 40,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
},
"techAffinity": { "computers": 1.0, "construction": 1.0, "forcefields": 1.0, "planetology": 1.0, "propulsion": 1.0, "weapons": 1.0 }
},
{
"id": "kestrelli", "name": "Kestrelli", "adjective": "Kestrelli", "plural": "Kestrelli",
"color": "#e0c04f", "portraitFrame": 1, "shipFrame": 1, "homeworld": "terran",
"desc": "Avian aristocrats bred for flight. Their pilots simply do not get hit.",
"strengths": ["Enormous ship defence bonus", "Skilled at propulsion"],
"weaknesses": ["Poor ground troops", "Weak industry"],
"traits": {
"industryMult": 0.9, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 50, "groundAttack": -10, "groundDefense": -10,
"espionage": 0, "counterEspionage": 0, "diplomacy": 10,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
},
"techAffinity": { "computers": 1.0, "construction": 0.9, "forcefields": 1.1, "planetology": 0.9, "propulsion": 1.3, "weapons": 1.0 }
},
{
"id": "ursaal", "name": "Ursaal", "adjective": "Ursaal", "plural": "Ursaal",
"color": "#8a5a34", "portraitFrame": 2, "shipFrame": 2, "homeworld": "terran",
"desc": "Heavy-worlders. An Ursaal division walks through defences that would stop an army.",
"strengths": ["+50 ground combat", "Settles high-gravity worlds without penalty"],
"weaknesses": ["Poor research", "Clumsy diplomats"],
"traits": {
"industryMult": 1.0, "researchMult": 0.8, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 50, "groundDefense": 50,
"espionage": 0, "counterEspionage": 0, "diplomacy": -20,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": true, "hostileImmune": false, "colonizeAnything": false
},
"techAffinity": { "computers": 0.8, "construction": 1.2, "forcefields": 1.0, "planetology": 1.0, "propulsion": 0.9, "weapons": 1.1 }
},
{
"id": "umbrix", "name": "Umbrix", "adjective": "Umbrix", "plural": "Umbrix",
"color": "#6b4a8a", "portraitFrame": 3, "shipFrame": 3, "homeworld": "terran",
"desc": "Shapeshifters who wear other species like coats. Nobody trusts them, and everybody is right not to.",
"strengths": ["Unmatched espionage and sabotage", "Steal technology others cannot"],
"weaknesses": ["Every empire starts hostile toward them"],
"traits": {
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
"espionage": 60, "counterEspionage": 30, "diplomacy": -50,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
},
"techAffinity": { "computers": 1.2, "construction": 1.0, "forcefields": 1.1, "planetology": 0.9, "propulsion": 1.0, "weapons": 1.0 }
},
{
"id": "kkrix", "name": "Kkrix", "adjective": "Kkrix", "plural": "Kkrix",
"color": "#c46a2a", "portraitFrame": 4, "shipFrame": 4, "homeworld": "terran",
"desc": "A hive that builds without pause, argues with nobody, and understands negotiation not at all.",
"strengths": ["+50% industry on every world"],
"weaknesses": ["Poor research", "Cannot conduct meaningful diplomacy"],
"traits": {
"industryMult": 1.5, "researchMult": 0.8, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
"espionage": -20, "counterEspionage": 0, "diplomacy": -40,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
},
"techAffinity": { "computers": 0.8, "construction": 1.3, "forcefields": 0.9, "planetology": 1.1, "propulsion": 1.0, "weapons": 1.0 }
},
{
"id": "mekhan", "name": "Mekhan", "adjective": "Mekhan", "plural": "Mekhan",
"color": "#7f8fa0", "portraitFrame": 5, "shipFrame": 5, "homeworld": "terran",
"desc": "Cyborgs who treat a planet as a chassis. Every citizen runs more machines than anyone else can.",
"strengths": ["+2 factories per population", "Strong construction research"],
"weaknesses": ["Filthy — waste piles up twice as fast"],
"traits": {
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 2.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
"espionage": 0, "counterEspionage": 0, "diplomacy": -10,
"maxPopMult": 1.0, "factoriesPerPop": 4, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
},
"techAffinity": { "computers": 1.2, "construction": 1.3, "forcefields": 1.0, "planetology": 0.8, "propulsion": 1.0, "weapons": 1.0 }
},
{
"id": "rrashaa", "name": "Rrashaa", "adjective": "Rrashaa", "plural": "Rrashaa",
"color": "#d4913f", "portraitFrame": 6, "shipFrame": 6, "homeworld": "terran",
"desc": "Feline gunners with reflexes no computer improves on. Their beams land when nobody else's would.",
"strengths": ["Enormous ship attack bonus", "Strong weapons research"],
"weaknesses": ["Poor ground defence", "Distrusted"],
"traits": {
"industryMult": 1.0, "researchMult": 0.9, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 50, "shipDefense": 0, "groundAttack": 0, "groundDefense": -25,
"espionage": 0, "counterEspionage": 0, "diplomacy": -25,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
},
"techAffinity": { "computers": 1.0, "construction": 1.0, "forcefields": 0.9, "planetology": 0.9, "propulsion": 1.0, "weapons": 1.3 }
},
{
"id": "cerebrai", "name": "Cerebrai", "adjective": "Cerebrai", "plural": "Cerebrai",
"color": "#4fb8a8", "portraitFrame": 7, "shipFrame": 7, "homeworld": "terran",
"desc": "Vast fragile intellects. They will out-think the galaxy if they survive long enough to finish thinking.",
"strengths": ["Double research output", "Can research anything"],
"weaknesses": ["Hopeless in a ground fight"],
"traits": {
"industryMult": 1.0, "researchMult": 2.0, "tradeMult": 1.0, "growthMult": 1.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": -50, "groundDefense": -50,
"espionage": 0, "counterEspionage": 0, "diplomacy": 0,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
},
"techAffinity": { "computers": 1.4, "construction": 1.2, "forcefields": 1.2, "planetology": 1.2, "propulsion": 1.2, "weapons": 1.2 }
},
{
"id": "ssakar", "name": "Ssakar", "adjective": "Ssakar", "plural": "Ssakar",
"color": "#5aa04a", "portraitFrame": 8, "shipFrame": 8, "homeworld": "jungle",
"desc": "Reptilian broodmothers. Ssakar colonies fill to capacity while rivals are still unpacking.",
"strengths": ["Population grows twice as fast"],
"weaknesses": ["Poor research", "Untrusted by the older species"],
"traits": {
"industryMult": 1.0, "researchMult": 0.85, "tradeMult": 1.0, "growthMult": 2.0, "ecologyMult": 1.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 10, "groundDefense": 0,
"espionage": 0, "counterEspionage": 0, "diplomacy": -20,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": false, "hostileImmune": false, "colonizeAnything": false
},
"techAffinity": { "computers": 0.9, "construction": 1.0, "forcefields": 0.9, "planetology": 1.2, "propulsion": 1.0, "weapons": 1.0 }
},
{
"id": "lithox", "name": "Lithox", "adjective": "Lithox", "plural": "Lithox",
"color": "#9a86c4", "portraitFrame": 9, "shipFrame": 9, "homeworld": "barren",
"desc": "Crystalline life that breathes nothing and fears no atmosphere. They settle where the map says nobody can.",
"strengths": ["Colonise any world from turn one", "Immune to hostile environments and pollution"],
"weaknesses": ["Population grows at half speed", "Cannot negotiate at all"],
"traits": {
"industryMult": 1.0, "researchMult": 1.0, "tradeMult": 1.0, "growthMult": 0.5, "ecologyMult": 0.0,
"shipAttack": 0, "shipDefense": 0, "groundAttack": 0, "groundDefense": 0,
"espionage": -20, "counterEspionage": 20, "diplomacy": -100,
"maxPopMult": 1.0, "factoriesPerPop": 2, "highGravityOk": true, "hostileImmune": true, "colonizeAnything": true
},
"techAffinity": { "computers": 0.9, "construction": 1.2, "forcefields": 1.1, "planetology": 0.7, "propulsion": 0.9, "weapons": 1.0 }
}
],
"_leadersReadme": "Hireable leaders (the MOO2 convenience). kind admin binds to a colony, kind captain binds to a fleet. skills are additive with species traits. Portrait frames are procedural until artwork lands.",
"leaders": [
{ "id": "vexlarr", "name": "Vex Larr", "kind": "admin", "hireCost": 120, "upkeep": 3, "portraitFrame": 0, "skills": { "industryMult": 1.25 }, "bio": "Yard foreman turned governor. Builds fast, explains later." },
{ "id": "sennaquil", "name": "Senna Quil", "kind": "admin", "hireCost": 150, "upkeep": 4, "portraitFrame": 1, "skills": { "researchMult": 1.3 }, "bio": "Ran three universities into brilliance and bankruptcy." },
{ "id": "orrimtal", "name": "Orrim Tal", "kind": "admin", "hireCost": 110, "upkeep": 3, "portraitFrame": 2, "skills": { "tradeMult": 1.4 }, "bio": "Never lost a negotiation he was allowed to finish." },
{ "id": "hessk", "name": "Hessk", "kind": "admin", "hireCost": 130, "upkeep": 3, "portraitFrame": 3, "skills": { "growthMult": 1.4 }, "bio": "Settlement specialist. Knows exactly how many people a rock will hold." },
{ "id": "duvaine", "name": "Duvaine Roth", "kind": "admin", "hireCost": 160, "upkeep": 4, "portraitFrame": 4, "skills": { "ecologyMult": 0.5, "industryMult": 1.15 }, "bio": "Cleans up after industry without slowing it down." },
{ "id": "callarn", "name": "Cal Larn", "kind": "admin", "hireCost": 180, "upkeep": 5, "portraitFrame": 5, "skills": { "industryMult": 1.2, "researchMult": 1.2 }, "bio": "Expensive, and worth it on your capital." },
{ "id": "yrsakane", "name": "Yrsa Kane", "kind": "admin", "hireCost": 140, "upkeep": 4, "portraitFrame": 6, "skills": { "groundDefense": 40 }, "bio": "Fortifies a colony until invading it stops being worth the ships." },
{ "id": "thelnwar", "name": "Theln War", "kind": "admin", "hireCost": 145, "upkeep": 4, "portraitFrame": 7, "skills": { "espionage": 25, "counterEspionage": 25 }, "bio": "Runs the quiet half of your empire." },
{ "id": "rakkurvane","name": "Rakkur Vane", "kind": "captain", "hireCost": 160, "upkeep": 4, "portraitFrame": 8, "skills": { "shipAttack": 30 }, "bio": "Fires on the roll, not the order. It works." },
{ "id": "isolabrey", "name": "Isola Brey", "kind": "captain", "hireCost": 160, "upkeep": 4, "portraitFrame": 9, "skills": { "shipDefense": 30 }, "bio": "Has never lost a hull she was given time to manoeuvre." },
{ "id": "gorvekmul", "name": "Gorvek Mul", "kind": "captain", "hireCost": 190, "upkeep": 5, "portraitFrame": 10, "skills": { "shipAttack": 20, "initiative": 3 }, "bio": "Opens every engagement before the enemy finishes arriving." },
{ "id": "sableorr", "name": "Sable Orr", "kind": "captain", "hireCost": 150, "upkeep": 4, "portraitFrame": 11, "skills": { "repairPerRound": 0.05 }, "bio": "Her damage-control crews are legendary and badly paid." },
{ "id": "kavehsun", "name": "Kaveh Sun", "kind": "captain", "hireCost": 175, "upkeep": 4, "portraitFrame": 12, "skills": { "groundAttack": 45 }, "bio": "Boarding specialist. Takes colonies with the marines already aboard." },
{ "id": "nyxholt", "name": "Nyx Holt", "kind": "captain", "hireCost": 200, "upkeep": 5, "portraitFrame": 13, "skills": { "shipAttack": 20, "shipDefense": 20 }, "bio": "Balanced, reliable, and quietly the best officer available." },
{ "id": "brannok", "name": "Brannok", "kind": "captain", "hireCost": 130, "upkeep": 3, "portraitFrame": 14, "skills": { "speedBonus": 1, "rangeBonus": 2 }, "bio": "Navigator who finds range where the charts say there is none." },
{ "id": "elissedra", "name": "Elis Sedra", "kind": "captain", "hireCost": 210, "upkeep": 6, "portraitFrame": 15, "skills": { "shipAttack": 25, "shipDefense": 15, "initiative": 2 }, "bio": "Flag officer. Give her a battleship and stay out of the way." }
],
"galaxySizes": [
{ "id": "small", "name": "Small", "stars": 24, "width": 2600, "height": 1700, "maxEmpires": 4, "desc": "Cramped. You will meet your neighbours early and often." },
{ "id": "medium", "name": "Medium", "stars": 36, "width": 3400, "height": 2200, "maxEmpires": 5, "desc": "The standard galaxy." },
{ "id": "large", "name": "Large", "stars": 54, "width": 4400, "height": 2800, "maxEmpires": 6, "desc": "Room to expand before anyone objects." },
{ "id": "huge", "name": "Huge", "stars": 70, "width": 5400, "height": 3400, "maxEmpires": 6, "desc": "A long game. Propulsion tech matters more than anything." }
],
"galaxyShapes": [
{ "id": "spiral", "name": "Spiral", "desc": "Two sweeping arms. Long borders, natural chokepoints.", "arms": 2 },
{ "id": "elliptical", "name": "Elliptical", "desc": "A dense even cloud. Everyone is everyone's neighbour.", "arms": 0 },
{ "id": "cluster", "name": "Cluster", "desc": "Knots of stars separated by empty dark.", "arms": 0, "clusters": 5 },
{ "id": "ring", "name": "Ring", "desc": "A torus around a hollow core. Expansion runs two ways.", "arms": 0 }
],
"difficulties": [
{ "id": "simple", "name": "Simple", "aiProdMult": 0.75, "aiResearchMult": 0.75, "aiAggression": 0.5, "aiStartBonus": 0, "humanResearchMult": 1.2, "desc": "The AI will not press an advantage." },
{ "id": "easy", "name": "Easy", "aiProdMult": 0.9, "aiResearchMult": 0.9, "aiAggression": 0.7, "aiStartBonus": 0, "humanResearchMult": 1.1, "desc": "Forgiving." },
{ "id": "normal", "name": "Normal", "aiProdMult": 1.0, "aiResearchMult": 1.0, "aiAggression": 1.0, "aiStartBonus": 0, "humanResearchMult": 1.0, "desc": "An even galaxy." },
{ "id": "hard", "name": "Hard", "aiProdMult": 1.25, "aiResearchMult": 1.25, "aiAggression": 1.3, "aiStartBonus": 1, "humanResearchMult": 1.0, "desc": "The AI starts ahead and stays there if you let it." },
{ "id": "impossible", "name": "Impossible", "aiProdMult": 1.6, "aiResearchMult": 1.6, "aiAggression": 1.6, "aiStartBonus": 2, "humanResearchMult": 1.0, "desc": "You are meant to lose this one." }
],
"council": {
"_readme": "Galactic Council. Convenes once enough of the galaxy is settled, then every interval turns. An empire wins outright at winFraction of total votes; votes are proportional to population.",
"firstTurn": 110,
"interval": 30,
"minColonizedFraction": 0.45,
"winFraction": 0.6667,
"abstainAttitude": -25,
"submitAttitude": -25
},
"economy": {
"_readme": "Core economic constants. Sliders are the five MOO1 allocation channels; buildings multiply the channel they name.",
"channels": ["ships", "defense", "industry", "ecology", "research"],
"channelNames": { "ships": "Construction", "defense": "Defence", "industry": "Industry", "ecology": "Ecology", "research": "Research" },
"startingBC": 100,
"startingPop": 50,
"startingFactories": 30,
"growthRateBase": 0.04,
"factoryCost": 10,
"factoryOutput": 1,
"popOutput": 0.5,
"wastePerFactory": 0.2,
"wasteCleanupCost": 2,
"tradePerPop": 0.3,
"shipUpkeepFraction": 0.02,
"colonyBaseCost": 100,
"reserveTransferLoss": 0.5,
"contactRange": 12,
"baseFuelRange": 5,
"starbaseRangeBonus": 4
},
"combat": {
"_readme": "Tactical grid battle. The grid is small on purpose — MOO1 battles are decided by fleet composition, not manoeuvre.",
"gridCols": 12,
"gridRows": 8,
"maxRounds": 60,
"beamRounds": 5,
"baseHitChance": 0.5,
"hitPerTargeting": 0.06,
"hitPerDefense": 0.004,
"hitPerAttack": 0.004,
"beamRange": 1,
"missileRange": 6,
"retreatAfterRound": 1,
"disengageRound": 25,
"planetDefenseBase": 20,
"bombardPopKill": 0.22,
"groundOddsScale": 0.01
},
"victory": {
"_readme": "Two win conditions, matching MOO1: kill everyone, or be voted High Guardian by the Council.",
"conquest": true,
"council": true,
"turnCap": 800,
"stalemateTurn": 150
},
"starNames": [
"Vega", "Altair", "Rigel", "Deneb", "Antares", "Mizar", "Alcor", "Spica", "Procyon", "Regulus",
"Bellatrix", "Castor", "Pollux", "Capella", "Arcturus", "Aldebaran", "Fomalhaut", "Achernar", "Canopus", "Hadar",
"Alnair", "Alphard", "Alnilam", "Saiph", "Mintaka", "Elnath", "Sadr", "Izar", "Rasalhague", "Nunki",
"Kaus", "Menkar", "Algol", "Zubeneschamali", "Unukalhai", "Tarazed", "Alshain", "Sheratan", "Hamal", "Diphda",
"Markab", "Scheat", "Enif", "Sadalsuud", "Skat", "Ancha", "Kitalpha", "Biham", "Homam", "Matar",
"Talitha", "Merak", "Phecda", "Megrez", "Alioth", "Dubhe", "Cor Caroli", "Chara", "Thuban", "Edasich",
"Kochab", "Pherkad", "Yildun", "Errai", "Alfirk", "Alderamin", "Kurhah", "Segin", "Ruchbah", "Caph"
]
}

View File

@ -0,0 +1,183 @@
# Master of Vega — build plan and findings
**Read this before touching `src/games/mastervega/`.** It records the traps
found while building it; several were only discovered by instrumenting the soak
and would be very easy to reintroduce.
Master of Orion clone. Registered as `mastervega`, category `arcade-console-pc`
("Video Games"), iconFrame **92**. MOO1 rules with three MOO2 conveniences
(colony building queue, colony detail view, hireable leaders). Preset ship
hulls with an auto-refitting **Mark**, no ship designer. Tactical grid combat.
Empires are the ten **species** themselves — this game deliberately does *not*
use `data/opponents.json` or `ui/Portrait.js`.
## Architecture
Split enforced by import discipline, modelled on `totalannihilation/` rather
than `civilization/` (whose scene grew to 1792 lines).
**Headless — zero Phaser imports, importable by Node:**
| File | Role |
|---|---|
| `VegaRules.js` | `compileRules(json)` — validate + index the rules file |
| `VegaGalaxyGen.js` | Deterministic galaxy: shapes, stars, planets, Gabriel-graph starlanes, homeworlds |
| `VegaLogic.js` | The engine: colonies, sliders, buildings, research, fleets, combat dispatch, council, victory |
| `VegaShips.js` | Preset hulls + Mark auto-refit; the knapsack loadout |
| `VegaCombat.js` | Tactical battle **stepper** |
| `VegaAI.js` | AI empire controller |
| `VegaDiplomacy.js` | Treaties, attitudes, council politics |
| `VegaLeaders.js` | Leader pool, offers, postings |
**Render tier:** `MasterOfVegaGame.js` (scene), `VegaStarMap.js`,
`VegaNebula.js`, `VegaSystemView.js`, `VegaCombatView.js`, `VegaScreens.js`,
`VegaArt.js`, `VegaFx.js`.
State is plain JSON with the RNG cursor inside it (`state.rngState`,
explicit-step mulberry32), so replaying a seed reproduces the galaxy, the
battles and the winner exactly. `serialize()` strips `rules` and every
underscore-prefixed memo cache.
## Traps found during the build
Each of these was a real bug that produced a plausible-looking but broken game.
1. **An immobile hull froze the entire fleet.** `fleetSpeed()` returned 0 if
*any* ship in a fleet was immobile. A completed Star Base joins the fleet
over its own colony — which is the empire's main battle fleet — and from that
moment `canSendFleet` refused every order. Instrumented over one game:
**338 of 338** valid attacks (in range, strong enough, at war) were refused
for this reason. No colony could ever be attacked, so no war could be won.
Fix: `fleetSpeed` skips immobile hulls; `sendFleet` splits them into a
garrison that stays behind; `consolidateFleets` keys on mobility so the
garrison is never re-merged.
2. **Conquest was unreachable without bombardment.** Even at a 2500-turn cap,
*no empire was ever eliminated*: a cornered empire's beaten fleet always
retreats to its last fortified homeworld and denies the attacker the clean
orbit an invasion needs. `bombardPopKill` and the Stellar Converter's
`planetCracker` were declared in the rules but never implemented. Implementing
orbital bombardment (MOO1's actual answer) made conquest work immediately.
3. **The invasion window never opened.** Two versions of this failed the same
way. Requiring `defenseHp === 0` never worked because combat resolves on the
attacker's turn and the *defender's* turn rebuilds the batteries first.
Requiring an empty sky failed too — a besieged colony finishes a ship every
few turns and that one hull blocked the landing indefinitely. Measured: 1268
colony-turns under hostile orbit, **2** invasion attempts. Now the rule is
orbital *superiority*, and surviving defences fight as ground support.
4. **Transports never left home.** `manageFleets` had an early
`if (power <= 0) continue`, and transports carry no guns — so transport
fleets skipped every movement branch. 3495 idle observations against 12 in
transit. They now escort the main battle fleet.
5. **The AI threw transports away.** Without a forecast it launched every
landing regardless of odds: 1971 failures against 130 successes.
`invasionForecast()` now gates it — success rate went to ~91%.
6. **Nobody ever met anybody.** Contact required flying a ship into an occupied
system, so most galaxies had no diplomacy and no wars at all. Empires now
also make contact by proximity (`economy.contactRange`).
7. **The Council could never elect anyone.** Every empire stood as a candidate
and therefore voted for itself — 30 sessions in an 800-turn game, all null.
MOO1's rule: the **two largest** empires stand and everyone else votes
between them.
8. **…and then it elected someone every time.** A dominant empire won its own
council vote a hundred turns before it could finish a war, so conquest never
completed. MOO1's **refusal to submit** (the defeated candidate walks out and
the election is void) restored the balance.
9. **Combat had a decisive first-strike bias.** Firing was sequential, so
whichever side acted first in the round contact was made won. Mirror matches
swung from 100% to 0% attacker depending on tier, because the parity of the
contact round changes with fleet speed. Damage is now **banked and applied
together** at the end of the round.
10. **…and then ties were resolved in a fixed direction.** Symmetric fleets
reach the disengage round *exactly* tied astonishingly often; giving the
attacker every drawn battle produced a 44pp bias. The coin is now flipped.
11. **Every battle stalemated on the round cap.** Two causes: in-combat repair
(6% of hull/round) outpaced damage, and the attrition tail of an even fight
is enormously long. Repair was cut to 3%/6%, and a **disengage rule** now
makes the weaker side withdraw at round 25 rather than grinding to the cap.
12. **An all-missile ship ran dry and stalemated.** At some tiers missiles score
better damage-per-space than beams, so a pure knapsack built ships that
emptied their racks in five rounds and then sat unarmed. `MISSILE_SHARE`
caps missiles at 40% of tonnage. This is a design rule, not an optimisation.
13. **Researching a weapon could make ships worse.** A greedy "best
damage-per-space" fill mounts one oversized gun and strands the leftover
tonnage — the cruiser's beam damage *dropped* from 42 to 28 on learning
Death Ray. The loadout is now an unbounded **knapsack**, which is provably
monotonic in tech because researching only ever adds candidates. The one
sanctioned exception is the tier where missiles first appear (see the
verifier's section 4).
14. **Waste ran away and killed every colony.** Population fell 50 → 20 with 868
accumulated waste. Cleanup is now **mandatory** and taken off the top,
pro-rata from the other channels — MOO1's eco slider snapping to the
minimum. Related: factories beyond what the population can staff are
**mothballed** (`effectiveFactories`), or a shrinking colony keeps polluting
from factories nobody is left to run and can never recover.
15. **Espionage leaked the whole tech tree.** Crediting raw espionage score made
every empire a spy agency; all five reached 60/60 techs, making the
per-species availability roll meaningless. Only the surplus over a baseline
now counts.
16. **The Council never convened.** `colonizedFraction` measured against *every*
star, but a third of systems hold only gas giants and belts, so "half the
galaxy colonised" was unreachable. It now measures against settleable stars.
17. **Fleets proliferated to 281.** Ships completing while the local fleet is in
transit each spawn a new fleet. `consolidateFleets` merges idle fleets each
turn.
18. **Dead empires left ghost fleets** on the map forever. Caught by the soak's
invariant checker, not by playing.
19. **Worldgen fairness could not always be satisfied.** On large sparse
galaxies a homeworld can have *no* star inside its opening fuel range, so
there was nothing to upgrade and that empire simply could not expand.
`guaranteeNearbyWorlds` now seeds new worlds when it must.
## Balance reference (27-game AI soak)
```
outcomes: { conquest: 17, council: 7, timeout: 3 }
turns: min 110, median 282, max 800
AI turn time: 0.45 ms average (budget 50 ms)
mirror-match bias: < 5.2pp at every tier, zero stalemates
wins spread across 8 of 10 species
```
## Files touched to register the game
`src/data/gamesRegistry.js`, `src/main.js`, `src/scenes/GameRoomScene.js`
(`slugDispatch`), `src/data/assetManifest.js`, `src/scenes/PreloadScene.js`
(eager-loads `mastervega-artwork.json`), `src/services/soundtrack.js`
(`hacker`).
## Verification
```bash
node tools/verifyMasterOfVega.js # 809 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 808 checks, ~15s
node tools/verifyMasterOfVega.js --games=50 # a deeper soak
```
Ten sections; section 2 runs the real procedural painters against a Proxy fake
canvas, section 10 is the self-play soak with invariants and a turn-time budget.
**Never browser-tested.** Everything above is engine- and Node-verified only.
## Art
All sheets are optional and start `path: null`; `VegaArt.js` paints stand-ins at
the identical frame geometry. See `src/games/mastervega/sprites.md` for the
frame maps. Frame indexes are append-only.

View File

@ -71,6 +71,20 @@ export const MANIFEST = {
excitebike: [ excitebike: [
(scene) => musicFrom(scene, 'nintendo-music'), (scene) => musicFrom(scene, 'nintendo-music'),
], ],
// Every sheet in mastervega-artwork.json starts with path:null and is painted
// procedurally by VegaArt.js, so this entry needs no edit when art arrives —
// only the JSON does.
mastervega: [
{ type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' },
(scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']),
image('vega-menu-bg', 'assets/images/vega/background-menu.png'),
image('vega-menu-title', 'assets/images/vega/menu-title.png'),
{ type: 'audio', key: 'laser-zap', path: 'assets/fx/laser-zap.mp3' },
{ type: 'audio', key: 'scifi-explode', path: 'assets/fx/scifi-explode.mp3' },
{ type: 'audio', key: 'ta-rocket-1', path: 'assets/fx/ta-rocket-1.mp3' },
{ type: 'audio', key: 'ta-rocket-2', path: 'assets/fx/ta-rocket-2.mp3' },
(scene) => musicFrom(scene, 'hacker-music'),
],
forbiddenisland: [ forbiddenisland: [
// Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded // Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded
// frame 2i+1 (see IslandData.TILE_FRAME_ROW). // frame 2i+1 (see IslandData.TILE_FRAME_ROW).

View File

@ -119,3 +119,4 @@ registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category:
registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 89 }); registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 89 });
registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 }); registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 });
registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 }); registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 });
registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 });

View File

@ -0,0 +1,524 @@
// Master of Vega — the Phaser scene.
//
// The engine (VegaLogic) is headless; this scene drives it and renders through
// VegaStarMap. AI empires play via VegaAI. The scene owns three things and
// nothing else: the setup screen, the HUD, and the turn driver.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { playSound } from '../../ui/Sounds.js';
import { compileRules, turnToYear } from './VegaRules.js';
import { ensureSheets, speciesPortraitFrame } from './VegaArt.js';
import * as Logic from './VegaLogic.js';
import { runAITurn } from './VegaAI.js';
import VegaStarMap from './VegaStarMap.js';
import VegaFx from './VegaFx.js';
import { openSystemView } from './VegaSystemView.js';
import { openCombatView } from './VegaCombatView.js';
import {
FONT, D, openResearchScreen, openDiplomacyScreen, openCouncilScreen, openLeaderScreen,
showVictoryOverlay,
} from './VegaScreens.js';
const SAVE_KEY = 'mastervega-save';
export default class MasterOfVegaGame extends Phaser.Scene {
constructor() { super('MasterOfVegaGame'); }
init(data) {
this.roomData = data ?? {};
// getGameSoundtrack() reads scene.gameDef.slug to pick the track list.
this.gameDef = data?.game ?? { slug: 'mastervega', name: 'Master of Vega' };
this.modalOpen = false;
this.busy = false;
}
create() {
this.cameras.main.setBackgroundColor('#03060d');
this.rules = compileRules(this.cache.json.get('mastervega-rules'));
const artwork = this.cache.json.get('mastervega-artwork') ?? { sheets: {} };
const { keys, procedural } = ensureSheets(this, this.rules, artwork);
this.art = keys;
if (procedural.length) {
console.info(`[MasterOfVega] procedural art for: ${procedural.join(', ')}`);
}
this.tooltip = new Tooltip(this, { depth: 70 });
try {
const { tracks, volume } = getGameSoundtrack(this);
if (tracks?.length) this.music = new MusicPlayer(this, tracks, volume);
} catch (err) { /* music is optional */ }
this.events.once('shutdown', () => this.teardown());
this.showSetup();
}
teardown() {
this.map?.destroy();
this.fx?.destroy();
this.music?.destroy?.();
}
// ---------------------------------------------------------------- setup
showSetup() {
const layer = this.add.container(0, 0).setDepth(D.modal);
this.setupLayer = layer;
// Supplied menu art if present, flat panel colour if not — the setup screen
// has to work with zero art files like everything else here.
if (this.textures.exists('vega-menu-bg')) {
const bg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'vega-menu-bg');
bg.setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
layer.add(bg);
// Darken it so the cards and text stay legible over the artwork.
layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, 0.62).setOrigin(0, 0));
} else {
layer.add(this.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x03060d, 1).setOrigin(0, 0));
}
if (this.textures.exists('vega-menu-title')) {
const t = this.add.image(GAME_WIDTH / 2, 74, 'vega-menu-title').setOrigin(0.5);
// Contain within a header box rather than stretching — the source is a
// large title card and its aspect must be preserved.
const src = this.textures.get('vega-menu-title').getSourceImage();
const fit = Math.min(760 / src.width, 132 / src.height);
t.setScale(fit);
layer.add(t);
} else {
layer.add(this.add.text(GAME_WIDTH / 2, 60, 'MASTER OF VEGA', {
fontFamily: FONT, fontSize: '58px', color: '#cfe8ff',
}).setOrigin(0.5));
}
layer.add(this.add.text(GAME_WIDTH / 2, 138, 'Choose your species and the shape of the galaxy.', {
fontFamily: FONT, fontSize: '20px', color: '#7f97b3',
}).setOrigin(0.5));
const choice = {
speciesId: 'human',
sizeId: 'medium',
shapeId: 'spiral',
difficultyId: 'normal',
empires: 4,
};
// --- species picker
const cols = 5;
const cardW = 300;
const cardH = 190;
const gridX = (GAME_WIDTH - cols * cardW) / 2;
const cards = [];
this.rules.speciesList.forEach((spec, i) => {
const cx = gridX + (i % cols) * cardW;
const cy = 182 + Math.floor(i / cols) * cardH;
const card = this.add.container(cx, cy);
const bg = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0x0b1220, 0.95).setOrigin(0, 0);
bg.setStrokeStyle(1.5, 0x24405f, 1);
card.add(bg);
card.add(this.add.image(52, 62, this.art.portraits, speciesPortraitFrame(this.rules, spec.id))
.setDisplaySize(84, 84));
card.add(this.add.text(104, 16, spec.name, {
fontFamily: FONT, fontSize: '22px', color: spec.color,
}));
card.add(this.add.text(104, 46, spec.strengths.join('\n'), {
fontFamily: FONT, fontSize: '12px', color: '#7fd8a0', wordWrap: { width: cardW - 130 },
}));
card.add(this.add.text(104, 100, spec.weaknesses.join('\n'), {
fontFamily: FONT, fontSize: '12px', color: '#e08a8a', wordWrap: { width: cardW - 130 },
}));
card.add(this.add.text(14, 150, spec.desc, {
fontFamily: FONT, fontSize: '11px', color: '#6f8aa3', wordWrap: { width: cardW - 40 },
}));
// The hit zone is a top-left rectangle of its own, never the container —
// a Container's hit area is always centred on its origin.
const zone = this.add.rectangle(0, 0, cardW - 12, cardH - 12, 0xffffff, 0.001)
.setOrigin(0, 0).setInteractive({ useHandCursor: true });
zone.on('pointerup', () => {
choice.speciesId = spec.id;
for (const c of cards) c.bg.setStrokeStyle(1.5, 0x24405f, 1);
bg.setStrokeStyle(2.5, 0x6fc4ff, 1);
});
card.add(zone);
layer.add(card);
cards.push({ bg, spec });
});
cards[0].bg.setStrokeStyle(2.5, 0x6fc4ff, 1);
// --- option rows
const optY = 600;
const mkRow = (label, y, options, key, format = (o) => o.name) => {
layer.add(this.add.text(GAME_WIDTH / 2 - 620, y, label, {
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
}).setOrigin(0, 0.5));
const buttons = [];
options.forEach((opt, i) => {
const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, y, format(opt), () => {
choice[key] = opt.id ?? opt;
for (const other of buttons) other.setActive(false);
b.setActive(true);
}, { width: 195, height: 46, fontSize: 19 });
if ((opt.id ?? opt) === choice[key]) b.setActive(true);
buttons.push(b);
layer.add(b);
});
};
mkRow('Galaxy', optY, this.rules.galaxySizeList, 'sizeId');
mkRow('Shape', optY + 62, this.rules.galaxyShapeList, 'shapeId');
mkRow('Difficulty', optY + 124, this.rules.difficultyList, 'difficultyId');
layer.add(this.add.text(GAME_WIDTH / 2 - 620, optY + 186, 'Empires', {
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
}).setOrigin(0, 0.5));
const empButtons = [];
[2, 3, 4, 5, 6].forEach((n, i) => {
const b = new Button(this, GAME_WIDTH / 2 - 380 + i * 210, optY + 186, `${n}`, () => {
choice.empires = n;
for (const other of empButtons) other.setActive(false);
b.setActive(true);
}, { width: 195, height: 46, fontSize: 19 });
if (n === choice.empires) b.setActive(true);
empButtons.push(b);
layer.add(b);
});
const start = new Button(this, GAME_WIDTH / 2 + 430, optY + 124, 'Begin', () => {
layer.destroy();
this.beginGame(choice);
}, { width: 260, height: 64, fontSize: 30 });
layer.add(start);
// Resume, if a save is waiting.
const saved = this.readSave();
if (saved) {
const resume = new Button(this, GAME_WIDTH / 2 + 430, optY + 196, 'Resume', () => {
layer.destroy();
this.beginGame(null, saved);
}, { width: 260, height: 50, fontSize: 22 });
layer.add(resume);
}
const back = new Button(this, 120, 60, '← Menu', () => this.scene.start('GameMenu'),
{ width: 170, height: 48, fontSize: 20, variant: 'ghost' });
layer.add(back);
}
// ----------------------------------------------------------------- game
beginGame(choice, savedState = null) {
if (savedState) {
this.state = savedState;
} else {
const cap = this.rules.galaxySizes[choice.sizeId].maxEmpires;
const count = Math.min(choice.empires, cap);
// The human's species first, then distinct rivals drawn from the rest.
const pool = this.rules.speciesList
.map((s) => s.id)
.filter((id) => id !== choice.speciesId);
// Math.random is fine here — determinism starts at the engine seed below.
for (let i = pool.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
const speciesIds = [choice.speciesId, ...pool.slice(0, count - 1)];
this.state = Logic.createGame(this.rules, {
sizeId: choice.sizeId,
shapeId: choice.shapeId,
difficultyId: choice.difficultyId,
seed: (Math.random() * 1e9) | 0,
speciesIds,
humanIndex: 0,
});
}
this.state.rules = this.rules;
this.fxLayer = this.add.container(0, 0).setDepth(D.hud - 1);
this.fx = new VegaFx(this, this.fxLayer);
this.map = new VegaStarMap(this, this.rules, this.state, this.art, {
onStarClick: (idx) => this.onStarClick(idx),
onStarHover: (idx) => this.onStarHover(idx),
onFleetClick: (fleet) => this.onFleetClick(fleet),
blockWheel: () => this.modalOpen,
});
this.buildHud();
this.refreshHud();
this.log('The stars are yours to take.');
}
// ------------------------------------------------------------------ HUD
buildHud() {
const hud = this.add.container(0, 0).setDepth(D.hud);
this.hud = hud;
const bar = this.add.rectangle(0, 0, GAME_WIDTH, 62, 0x06101c, 0.92).setOrigin(0, 0);
bar.setStrokeStyle(1, 0x6fc4ff, 0.35);
hud.add(bar);
this.hudText = this.add.text(24, 18, '', { fontFamily: FONT, fontSize: '20px', color: '#cfe8ff' });
hud.add(this.hudText);
const mk = (label, x, fn) => {
const b = new Button(this, x, 31, label, fn, { width: 150, height: 42, fontSize: 18 });
hud.add(b);
return b;
};
mk('Research', GAME_WIDTH - 830, () => this.openModal((done) =>
openResearchScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done)));
mk('Diplomacy', GAME_WIDTH - 670, () => this.openModal((done) =>
openDiplomacyScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshAll())));
mk('Council', GAME_WIDTH - 510, () => this.openModal((done) =>
openCouncilScreen(this, this.rules, this.state, done)));
mk('Leaders', GAME_WIDTH - 350, () => this.openModal((done) =>
openLeaderScreen(this, this.rules, this.state, this.state.humanIndex, this.art, done,
() => this.refreshHud())));
this.endTurnBtn = new Button(this, GAME_WIDTH - 130, 31, 'End turn', () => this.onEndTurn(),
{ width: 190, height: 46, fontSize: 20 });
hud.add(this.endTurnBtn);
// --- status log, bottom left
this.logLines = [];
this.logText = this.add.text(24, GAME_HEIGHT - 168, '', {
fontFamily: FONT, fontSize: '16px', color: '#8fa8c0', lineSpacing: 3,
});
hud.add(this.logText);
const back = new Button(this, 96, GAME_HEIGHT - 40, '← Menu', () => {
this.writeSave();
this.scene.start('GameMenu');
}, { width: 150, height: 40, fontSize: 17, variant: 'ghost' });
hud.add(back);
}
refreshHud() {
const emp = this.state.empires[this.state.humanIndex];
if (!emp) return;
const cols = Logic.empireColonies(this.state, emp.idx);
this.hudText.setText(
`${emp.name} · Year ${turnToYear(this.state.turn)} · `
+ `${cols.length} colonies · ${Math.round(emp.totalPop)} population · `
+ `${Math.round(emp.bc)} BC (${emp.lastIncome >= 0 ? '+' : ''}${Math.round(emp.lastIncome ?? 0)}) · `
+ `${emp.techsKnown} technologies`,
);
}
log(line) {
this.logLines.push(line);
if (this.logLines.length > 8) this.logLines.shift();
this.logText?.setText(this.logLines.join('\n'));
}
refreshAll() {
this.map?.refresh();
this.refreshHud();
}
// -------------------------------------------------------------- modals
openModal(factory) {
if (this.modalOpen) return;
this.modalOpen = true;
// Arm a click guard so the modal's own close button does not fall through
// to the star map underneath it.
factory(() => {
this.time.delayedCall(60, () => { this.modalOpen = false; });
this.refreshAll();
});
}
onStarClick(idx) {
if (this.modalOpen || this.busy) return;
// A pending fleet order consumes the click instead of opening the system.
if (this.pendingOrder && this.selectedFleet) {
const fleet = this.selectedFleet;
this.pendingOrder = false;
this.selectedFleet = null;
if (Logic.canSendFleet(this.rules, this.state, fleet, idx)) {
Logic.sendFleet(this.rules, this.state, fleet, idx);
const eta = Logic.fleetEta(this.rules, this.state, fleet);
this.log(`Fleet away — ${this.state.galaxy.stars[idx].name} in ${eta} turns.`);
playSound(this, 'ta-rocket-1');
this.refreshAll();
} else {
this.log('Out of fuel range. Research propulsion, or plant a colony closer.');
}
return;
}
this.openModal((done) => openSystemView(this, this.rules, this.state, idx, this.art, {
viewerIdx: this.state.humanIndex,
onChanged: () => this.refreshAll(),
onClose: done,
}));
}
onStarHover(idx) {
if (idx < 0) return;
const star = this.state.galaxy.stars[idx];
const emp = this.state.empires[this.state.humanIndex];
if (!star || (emp && !emp.explored[idx])) return;
}
onFleetClick(fleet) {
if (this.modalOpen || this.busy) return;
if (fleet.empireIdx !== this.state.humanIndex) return;
this.selectedFleet = fleet;
this.log(`Fleet selected — click a star within range to send it.`);
this.pendingOrder = true;
}
// ----------------------------------------------------------- turn driver
onEndTurn() {
if (this.busy || this.modalOpen) return;
this.busy = true;
this.endTurnBtn.setEnabled(false);
this.writeSave();
// Move first, then hand any battle the player is involved in to the
// tactical view before the engine resolves the rest. endEmpireTurn is told
// not to move again.
Logic.moveFleetsFor(this.rules, this.state, this.state.humanIndex);
this.playPlayerBattles(() => {
Logic.endEmpireTurn(this.rules, this.state, this.state.humanIndex, { skipMove: true });
this.runToHumanTurn();
});
}
// Fight the human's battles one at a time on the tactical screen. Each one is
// prepared by the engine, driven round by round by the view, and its outcome
// handed straight back — so a battle the player fights and one the AI
// auto-resolves go through exactly the same code.
playPlayerBattles(done) {
const me = this.state.humanIndex;
const pending = Logic.pendingBattlesFor(this.rules, this.state, me);
if (!pending.length) { done(); return; }
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;
openCombatView(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.refreshAll();
next(i + 1);
},
});
};
next(0);
}
// Resolve instantly, animate afterwards. Every AI empire is played out
// synchronously, then the interesting events are replayed for the player.
runToHumanTurn() {
const step = () => {
if (this.state.over) { this.finishGame(); return; }
if (this.state.current === this.state.humanIndex) {
Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex);
this.announceEvents();
this.refreshAll();
this.busy = false;
this.endTurnBtn.setEnabled(true);
return;
}
const e = this.state.current;
Logic.beginEmpireTurn(this.rules, this.state, e);
runAITurn(this.rules, this.state, e);
Logic.endEmpireTurn(this.rules, this.state, e);
this.time.delayedCall(60, step);
};
step();
}
// Turn engine events into log lines and map pings. `announced` marks a record
// consumed, since beginEmpireTurn trims rather than clears the event list.
announceEvents() {
const me = this.state.humanIndex;
for (const ev of this.state.events) {
if (ev.announced) continue;
ev.announced = true;
const star = ev.starIdx >= 0 ? this.state.galaxy.stars[ev.starIdx] : null;
const name = (i) => this.state.empires[i]?.name ?? '?';
if (ev.type === 'techDone' && ev.empire === me) {
this.log(`Researched ${this.rules.techs[ev.techId]?.name ?? ev.techId}.`);
} else if (ev.type === 'refit' && ev.empire === me) {
this.log(`${ev.count} × ${this.rules.hulls[ev.hullId]?.name} refitted to Mark ${ev.toMark} at ${this.state.galaxy.stars[ev.starIdx]?.name} (${ev.cost} BC).`);
} else if (ev.type === 'combat' && (ev.attacker === me || ev.defender === me)) {
this.log(`Battle at ${star?.name}: ${ev.winner === 'attacker' ? name(ev.attacker) : name(ev.defender)} holds the field.`);
if (star) this.fx?.ping(star.x, star.y, 0xffa050);
} else if (ev.type === 'captured') {
this.log(`${name(ev.empire)} has taken ${star?.name} from ${name(ev.from)}.`);
} else if (ev.type === 'colonyDestroyed') {
this.log(`${star?.name} has been bombed out of existence by ${name(ev.empire)}.`);
} else if (ev.type === 'colonised' && ev.empire === me) {
this.log(`Colony founded at ${star?.name}.`);
} else if (ev.type === 'contact') {
this.log(`We have made contact with the ${name(ev.other === me ? ev.empire : ev.other)}.`);
} else if (ev.type === 'warDeclared') {
this.log(`${name(ev.empire)} declares war on ${name(ev.other)}.`);
} else if (ev.type === 'councilRefused') {
this.log(`${name(ev.empire)} refuses to submit to ${name(ev.winner)}. The Council is void.`);
} else if (ev.type === 'council' && ev.winner >= 0) {
this.log(`${name(ev.winner)} is elected High Guardian of the Galaxy.`);
} else if (ev.type === 'eliminated') {
this.log(`The ${name(ev.empire)} are no more.`);
}
}
}
finishGame() {
this.busy = true;
this.clearSave();
showVictoryOverlay(this, this.rules, this.state, () => this.scene.start('GameMenu'));
}
update(time, delta) {
this.map?.update(time, delta);
}
// ------------------------------------------------------------ save/load
writeSave() {
try {
if (this.state && !this.state.over) {
window.localStorage.setItem(SAVE_KEY, Logic.serialize(this.state));
}
} catch (err) { /* storage may be unavailable */ }
}
readSave() {
try {
const raw = window.localStorage.getItem(SAVE_KEY);
return raw ? Logic.deserialize(raw) : null;
} catch (err) { return null; }
}
clearSave() {
try { window.localStorage.removeItem(SAVE_KEY); } catch (err) { /* ignore */ }
}
}

View File

@ -0,0 +1,435 @@
// Master of Vega — the AI empire controller. Headless.
//
// One entry point, runAITurn(rules, state, e), structured as a fixed pipeline
// so a soak failure can always be traced to a stage:
// strategy -> research -> colonies (sliders + build queue) -> fleets
// -> diplomacy -> leaders
//
// The AI plays by exactly the same rules as the human: it calls the same
// engine functions, and gets no hidden information. Its only advantages come
// from the difficulty multipliers in the rules file.
import {
empireColonies, empireFleets, colonyAt, reachableStars, canColonize, colonize, invade,
sendFleet, canSendFleet, enqueue, setSlider, setResearchAlloc, colonyProduction,
colonyFactoryCap, effectiveFactories, colonyDefenseCap, empireDesign, empireComponents,
fleetPower, atWar, rand, nextResearchTarget, invasionForecast, bombard,
} from './VegaLogic.js';
import { runDiplomacyTurn } from './VegaDiplomacy.js';
import { runLeaderTurn } from './VegaLeaders.js';
import { parsecs } from './VegaGalaxyGen.js';
// The warship the AI builds: best power-per-BC it can actually afford in a
// reasonable number of turns. Left unchecked an AI will queue a battleship on
// turn 30 and stall its economy for a century waiting for it.
function preferredWarship(rules, state, e, budgetPerTurn) {
const hulls = ['frigate', 'destroyer', 'cruiser', 'battleship'];
let best = null;
let bestScore = -Infinity;
for (const h of hulls) {
const d = empireDesign(rules, state, e, h);
if (d.damage <= 0) continue;
// Anything that takes more than ~15 turns to build is not a real option.
if (d.cost > budgetPerTurn * 15) continue;
const score = (d.hp + d.damage * 4) / d.cost;
if (score > bestScore) { bestScore = score; best = d; }
}
return best ?? empireDesign(rules, state, e, 'frigate');
}
function computeStrategy(rules, state, e) {
const emp = state.empires[e];
const colonies = empireColonies(state, e);
const reach = reachableStars(rules, state, e);
// Where could we settle right now?
const targets = [];
for (const key of Object.keys(reach)) {
const starIdx = Number(key);
const star = state.galaxy.stars[starIdx];
if (!star.planets.length) continue;
for (let orbit = 0; orbit < star.planets.length; orbit += 1) {
if (!canColonize(rules, state, e, starIdx, orbit)) continue;
const planet = star.planets[orbit];
const type = rules.planetTypes[planet.typeId];
const rich = rules.richness[planet.richId]?.industryMult ?? 1;
// Prefer big, rich, mild worlds close to home.
const home = colonies.length
? Math.min(...colonies.map((c) => parsecs(state.galaxy, c.starIdx, starIdx)))
: 0;
const score = planet.basePop * rich * (1 - 0.1 * type.hostility) - home * 2;
targets.push({ starIdx, orbit, score });
}
}
targets.sort((a, b) => b.score - a.score);
const enemies = state.empires.filter((o) => o.alive && o.idx !== e && atWar(state, e, o.idx));
let threat = 0;
for (const o of enemies) {
for (const f of empireFleets(state, o.idx)) threat = Math.max(threat, fleetPower(rules, state, f));
}
const myFleet = empireFleets(state, e).reduce((t, f) => t + fleetPower(rules, state, f), 0);
let phase = 'develop';
if (enemies.length && (threat > myFleet * 0.6 || colonies.length > 2)) phase = 'war';
else if (targets.length > 0 && colonies.length < 10) phase = 'expand';
const income = colonies.reduce((t, c) => t + colonyProduction(rules, state, c), 0);
return { colonies, reach, targets, enemies, threat, myFleet, phase, income };
}
// --------------------------------------------------------------------------
function manageResearch(rules, state, e, strat) {
const emp = state.empires[e];
const fields = Object.keys(rules.techFields);
const w = {};
for (const f of fields) w[f] = 1;
if (strat.phase === 'war') { w.weapons = 3; w.construction = 2.2; w.forcefields = 2; w.computers = 1.6; w.propulsion = 1; w.planetology = 0.6; }
else if (strat.phase === 'expand') { w.propulsion = 2.6; w.planetology = 2.6; w.construction = 1.4; w.computers = 1; w.weapons = 0.8; w.forcefields = 0.7; }
else { w.computers = 1.6; w.construction = 1.6; w.planetology = 1.4; w.propulsion = 1.2; w.weapons = 1; w.forcefields = 1; }
// No point pouring beakers into a field where everything left is either known
// or was rolled unavailable — that research would vanish.
for (const f of fields) {
if (!emp.researching[f] && !nextResearchTarget(rules, state, e, f)) w[f] = 0;
}
const total = fields.reduce((t, f) => t + w[f], 0);
if (total <= 0) return;
for (const f of fields) setResearchAlloc(rules, state, e, f, w[f] / total);
}
// --------------------------------------------------------------------------
const BUILDING_PRIORITY = [
'automatedfactory', 'researchlab', 'pollutionprocessor', 'spaceport', 'cloningcenter',
'robominers', 'stockexchange', 'supercomputer', 'missilebase', 'terraformingplant',
'holosimulator', 'groundbattery', 'planetaryshield', 'soilenrichmentfac', 'spycenter', 'artemisnet',
];
function manageColony(rules, state, e, colony, strat) {
const emp = state.empires[e];
const spec = rules.species[emp.speciesId];
const prod = colonyProduction(rules, state, colony);
const comps = empireComponents(rules, state, e);
// Ecology first: work out what cleanup actually costs and fund exactly that.
// Under-funding steals from every other channel, over-funding is dead money.
const wasteGen = effectiveFactories(rules, state, colony) * rules.economy.wastePerFactory * spec.traits.ecologyMult;
const cleanupCost = (colony.waste + wasteGen) * rules.economy.wasteCleanupCost * comps.wasteMult;
const ecoNeed = prod > 0 ? Math.min(0.6, cleanupCost / prod) : 0;
const roomForFactories = colony.factories < colonyFactoryCap(rules, state, colony);
const roomForDefense = colony.defenseHp < colonyDefenseCap(rules, state, colony);
const frontier = strat.enemies.length > 0;
let ships = 0.15;
let defense = 0;
let industry = roomForFactories ? 0.45 : 0.1;
let research = 0.25;
if (strat.phase === 'war') {
ships = 0.45;
defense = roomForDefense ? 0.15 : 0;
industry = roomForFactories ? 0.25 : 0.05;
research = 0.15;
} else if (strat.phase === 'expand') {
ships = 0.3;
industry = roomForFactories ? 0.4 : 0.1;
research = 0.25;
} else if (frontier && roomForDefense) {
defense = 0.1;
}
const rest = Math.max(0, 1 - ecoNeed);
const sum = ships + defense + industry + research;
const norm = sum > 0 ? rest / sum : 0;
// setSlider redistributes the remainder across the others, so ecology is set
// last and the rest are written straight in.
colony.sliders.ecology = ecoNeed;
colony.sliders.ships = ships * norm;
colony.sliders.defense = defense * norm;
colony.sliders.industry = industry * norm;
colony.sliders.research = research * norm;
// --- build queue
if (colony.queue.length >= 3) return;
const budget = Math.max(1, prod * (colony.sliders.ships || 0.1));
// Colony ships, one per outstanding target, capped so expansion cannot eat
// the entire economy.
const colonyShipsOut = empireFleets(state, e)
.reduce((t, f) => t + f.ships.filter((s) => s.hullId === 'colonyship').reduce((n, s) => n + s.count, 0), 0);
const queuedColonyShips = state.colonies
.filter((c) => c.empireIdx === e)
.reduce((t, c) => t + c.queue.filter((q) => q.id === 'colonyship').length, 0);
const wantColony = Math.min(3, strat.targets.length) - colonyShipsOut - queuedColonyShips;
if (wantColony > 0 && strat.phase !== 'war') {
enqueue(rules, state, colony, 'ship', 'colonyship');
return;
}
// Warships when threatened, or a standing patrol once developed.
const warship = preferredWarship(rules, state, e, budget);
// At war, keep building: a fleet that stops at parity can never break
// through, and the enemy is building too.
const needFleet = strat.phase === 'war'
? strat.myFleet < Math.max(strat.threat * 4, prod * 30)
: strat.myFleet < 400 + state.turn * 4;
if (needFleet && warship) {
enqueue(rules, state, colony, 'ship', warship.hullId);
return;
}
// A war fleet with no marines can bombard forever and take nothing. Keep a
// standing invasion capability whenever we are actually at war.
if (strat.phase === 'war') {
const transports = empireFleets(state, e)
.reduce((t, f) => t + f.ships.filter((s) => s.hullId === 'transport').reduce((n, s) => n + s.count, 0), 0);
// Enough marines to actually carry a defended world, not a token squad.
let wanted = 10;
for (const enemy of strat.enemies) {
for (const c of empireColonies(state, enemy.idx)) {
wanted = Math.max(wanted, Math.ceil((c.pop / 8 + 6) * 1.6 / (rules.hulls.transport.troops ?? 4)));
}
}
if (transports < Math.min(wanted, 40)) { enqueue(rules, state, colony, 'ship', 'transport'); return; }
}
// Buildings, cheapest useful thing first.
for (const bid of BUILDING_PRIORITY) {
const b = rules.buildings[bid];
if (!b) continue;
if (colony.buildings.includes(bid)) continue;
if (b.prereq && !emp.known[b.prereq]) continue;
if (colony.queue.some((q) => q.kind === 'building' && q.id === bid)) continue;
// Do not saddle a tiny outpost with upkeep it cannot carry.
if (b.cost > prod * 25) continue;
enqueue(rules, state, colony, 'building', bid);
return;
}
// Nothing else worth doing — extend range from the frontier.
if (!colony.buildings.includes('starbase') && strat.phase !== 'war' && prod > 20) {
enqueue(rules, state, colony, 'ship', 'starbase');
}
}
// --------------------------------------------------------------------------
function manageFleets(rules, state, e, strat) {
const emp = state.empires[e];
const reach = strat.reach;
const fleets = empireFleets(state, e).filter((f) => f.starIdx >= 0 && f.toStar < 0);
const claimed = new Set();
for (const fleet of fleets) {
const hasColonyShip = fleet.ships.some((s) => s.hullId === 'colonyship' && s.count > 0);
const hasTransport = fleet.ships.some((s) => s.hullId === 'transport' && s.count > 0);
const isScout = fleet.ships.every((s) => s.hullId === 'scout');
const power = fleetPower(rules, state, fleet);
// 1. Settle where we stand, if we can.
if (hasColonyShip) {
const star = state.galaxy.stars[fleet.starIdx];
let done = false;
for (let orbit = 0; orbit < star.planets.length; orbit += 1) {
if (canColonize(rules, state, e, fleet.starIdx, orbit)) {
if (colonize(rules, state, e, fleet.starIdx, orbit)) { done = true; break; }
}
}
if (done) continue;
const target = strat.targets.find((t) => !claimed.has(t.starIdx));
if (target && canSendFleet(rules, state, fleet, target.starIdx)) {
claimed.add(target.starIdx);
sendFleet(rules, state, fleet, target.starIdx);
continue;
}
}
// 2a. Bombard whatever we are sitting on top of. Softening the colony makes
// the subsequent landing viable, and against a cornered empire that will
// never yield clean orbit it is the only way to finish the war at all.
{
const colony = colonyAt(state, fleet.starIdx);
if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx) && power > 0) {
// Bomb only when we cannot take the world intact. A captured colony is
// worth far more than a dead one, and bombarding unconditionally turned
// every war into scorched earth — 805 worlds burned against 40 taken.
const forecast = invasionForecast(rules, state, e, fleet.starIdx);
if (!forecast || !forecast.favourable) bombard(rules, state, e, fleet.starIdx);
}
}
// 2b. Invade a cleared colony, or move up to one our warships are besieging.
if (hasTransport) {
const colony = colonyAt(state, fleet.starIdx);
if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx)) {
// Only land when the marines can actually carry the world. A failed
// landing costs the whole transport wave for nothing, so waiting for a
// second wave beats throwing away the first.
const forecast = invasionForecast(rules, state, e, fleet.starIdx);
if (forecast?.favourable && invade(rules, state, e, fleet.starIdx)) continue;
}
// Follow the siege: head for an enemy colony where we already hold orbit.
let siege = null;
let siegeD = Infinity;
for (const enemy of strat.enemies) {
for (const c of empireColonies(state, enemy.idx)) {
if (!reach[c.starIdx]) continue;
const mine = state.fleets.some((f) => f.starIdx === c.starIdx && f.empireIdx === e
&& fleetPower(rules, state, f) > 0);
if (!mine) continue;
const d = parsecs(state.galaxy, fleet.starIdx, c.starIdx);
if (d < siegeD) { siegeD = d; siege = c; }
}
}
if (siege && siege.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, siege.starIdx)) {
sendFleet(rules, state, fleet, siege.starIdx);
continue;
}
// No siege to join yet: fall in with the main battle fleet so the marines
// travel WITH the warships and are already in orbit the turn after it
// wins. Transports carry no guns, so without this they never satisfy the
// `power > 0` test below, never move, and spend the entire war parked
// over the homeworld — measured at 3495 idle observations against 12 in
// transit, and it is why no colony was ever taken.
if (strat.phase === 'war') {
let escort = null;
let escortPower = 0;
for (const f of empireFleets(state, e)) {
if (f.id === fleet.id || f.starIdx < 0) continue;
const p = fleetPower(rules, state, f);
if (p > escortPower) { escortPower = p; escort = f; }
}
if (escort && escort.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, escort.starIdx)) {
sendFleet(rules, state, fleet, escort.starIdx);
continue;
}
}
}
// 3. Scouts chart the dark.
if (isScout) {
const unexplored = Object.keys(reach)
.map(Number)
.filter((i) => !emp.explored[i]);
if (unexplored.length) {
const nearest = unexplored.reduce((best, i) => (
parsecs(state.galaxy, fleet.starIdx, i) < parsecs(state.galaxy, fleet.starIdx, best) ? i : best
), unexplored[0]);
if (canSendFleet(rules, state, fleet, nearest)) { sendFleet(rules, state, fleet, nearest); continue; }
}
}
if (power <= 0) continue;
// 4. Warships: defend a threatened colony, else press the attack.
if (strat.phase === 'war' && strat.enemies.length) {
// Anything of ours under threat and undefended comes first.
let rescue = null;
let rescueD = Infinity;
for (const c of strat.colonies) {
const hostile = state.fleets.some((f) => f.starIdx === c.starIdx
&& f.empireIdx !== e && atWar(state, e, f.empireIdx));
if (!hostile) continue;
const d = parsecs(state.galaxy, fleet.starIdx, c.starIdx);
if (d < rescueD) { rescueD = d; rescue = c; }
}
if (rescue && rescue.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, rescue.starIdx)) {
sendFleet(rules, state, fleet, rescue.starIdx);
continue;
}
// Otherwise hit the weakest enemy colony we can reach.
let best = null;
let bestScore = -Infinity;
for (const enemy of strat.enemies) {
for (const c of empireColonies(state, enemy.idx)) {
if (!reach[c.starIdx]) continue;
// Planetary defences count for less than live warships: they cannot
// chase, cannot reinforce, and can be ground down across several
// turns of siege. Weighting them one-for-one against fleet power made
// every defended colony look unassailable, so the war fleet parked at
// the frontier and no war ever advanced.
const guard = state.fleets
.filter((f) => f.starIdx === c.starIdx && f.empireIdx === enemy.idx)
.reduce((t, f) => t + fleetPower(rules, state, f), 0) + c.defenseHp * 0.4;
// Attack at rough parity. Demanding a clear local edge produced a
// permanent phoney war: both sides built to the same strength, each
// decided it was not quite winning enough, and nothing ever moved for
// seven hundred turns.
if (power < guard) continue;
// Concentrate on whoever is closest to collapse. Spreading pressure
// evenly across every rival keeps them all alive indefinitely; a war
// is only won by finishing somebody off.
const enemyColonies = empireColonies(state, enemy.idx).length;
const finisher = enemyColonies <= 2 ? 60 : 0;
const score = c.pop + finisher - enemyColonies * 4
- guard * 0.05 - parsecs(state.galaxy, fleet.starIdx, c.starIdx);
if (score > bestScore) { bestScore = score; best = c; }
}
}
if (best && best.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, best.starIdx)) {
sendFleet(rules, state, fleet, best.starIdx);
continue;
}
}
// 5. At war with nothing it can take alone, a fleet RALLIES instead of
// sitting still. Sending every squadron off independently means each one
// meets the enemy's whole navy on its own and dies; concentrating turns a
// stalemate into a breakthrough. Fleets sharing a system merge next turn,
// so this compounds into one hammer.
if (strat.phase === 'war' && strat.enemies.length) {
let rally = null;
let rallyPower = power;
for (const f of empireFleets(state, e)) {
if (f.id === fleet.id || f.starIdx < 0) continue;
const p = fleetPower(rules, state, f);
if (p > rallyPower) { rallyPower = p; rally = f; }
}
if (rally && rally.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, rally.starIdx)) {
sendFleet(rules, state, fleet, rally.starIdx);
continue;
}
// No bigger friend: gather at the colony nearest the enemy front.
let staging = null;
let stagingD = Infinity;
for (const c of strat.colonies) {
for (const enemy of strat.enemies) {
for (const ec of empireColonies(state, enemy.idx)) {
const d = parsecs(state.galaxy, c.starIdx, ec.starIdx);
if (d < stagingD) { stagingD = d; staging = c; }
}
}
}
if (staging && staging.starIdx !== fleet.starIdx && canSendFleet(rules, state, fleet, staging.starIdx)) {
sendFleet(rules, state, fleet, staging.starIdx);
}
continue;
}
// 6. In peacetime, idle warships fall back to the most valuable colony.
const home = strat.colonies.slice().sort((a, b) => b.pop - a.pop)[0];
if (home && fleet.starIdx !== home.starIdx
&& canSendFleet(rules, state, fleet, home.starIdx) && rand(state) < 0.25) {
sendFleet(rules, state, fleet, home.starIdx);
}
}
}
// --------------------------------------------------------------------------
export function runAITurn(rules, state, e) {
const emp = state.empires[e];
if (!emp.alive) return;
const strat = computeStrategy(rules, state, e);
manageResearch(rules, state, e, strat);
for (const colony of strat.colonies) manageColony(rules, state, e, colony, strat);
manageFleets(rules, state, e, strat);
runDiplomacyTurn(rules, state, e);
runLeaderTurn(rules, state, e);
}

View File

@ -0,0 +1,555 @@
// Master of Vega — procedural art fallback.
//
// Every spritesheet in data/mastervega-artwork.json is optional. If its `path`
// is null (or the PNG 404s) we paint a canvas stand-in with the identical frame
// layout, so the renderers never branch on whether art exists and the game is
// fully playable with zero art files present.
//
// Modelled on src/games/totalannihilation/TAArt.js, including the structural
// point: painters are keyed by the sheet's `kind`, NOT its name. That is what
// makes adding an eleventh species or a second leader sheet a pure JSON edit.
const OUTLINE = '#12151d';
/** Deterministic per-frame PRNG so speckles are stable across reloads. */
export function frameRng(seed) {
let a = (seed * 2654435761) >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), a | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Create a canvas texture laid out as a spritesheet and register its frames.
* `at(frame, draw)` translates to that frame's origin; `finish()` uploads.
*/
export function mkCanvasSheet(scene, key, wantW, wantH, wantCols, wantCount) {
// Guard every dimension. A NaN here reaches createCanvas as a zero-height
// texture and surfaces as an opaque WebGL error far from the real mistake.
const size = (v, fallback) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback);
const frameW = size(wantW, 64);
const frameH = size(wantH, 64);
const cols = size(wantCols, 8);
const count = size(wantCount, 1);
if (frameW !== wantW || frameH !== wantH || cols !== wantCols || count !== wantCount) {
console.warn(`[VegaArt] sheet "${key}" has a bad layout (${wantW}x${wantH}, cols ${wantCols}, `
+ `frames ${wantCount}) — falling back to ${frameW}x${frameH}, cols ${cols}, frames ${count}`);
}
const rows = Math.max(1, Math.ceil(count / cols));
const tex = scene.textures.createCanvas(key, cols * frameW, rows * frameH);
const ctx = tex.getContext();
return {
ctx,
at(frame, draw) {
const fx = (frame % cols) * frameW;
const fy = ((frame / cols) | 0) * frameH;
ctx.save();
ctx.translate(fx, fy);
draw(ctx, frameW, frameH);
ctx.restore();
},
finish() {
tex.refresh();
for (let f = 0; f < count; f += 1) {
const fx = (f % cols) * frameW;
const fy = ((f / cols) | 0) * frameH;
tex.add(f, 0, fx, fy, frameW, frameH);
}
return tex;
},
};
}
export function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function poly(ctx, pts) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.closePath();
}
function shade(hex, amount) {
const n = parseInt(hex.slice(1), 16);
const cl = (v) => Math.max(0, Math.min(255, Math.round(v)));
const r = cl(((n >> 16) & 255) * amount);
const g = cl(((n >> 8) & 255) * amount);
const b = cl((n & 255) * amount);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
// --------------------------------------------------------------------------
// Ships — one row per species, one column per hull.
// Silhouettes in unit space (0..1 on both axes), nose UP. Each hull reads at a
// glance from its outline alone, which matters because the star map draws these
// at 24px.
const HULL_SHAPES = {
0: [[0.5, 0.06], [0.62, 0.42], [0.58, 0.88], [0.42, 0.88], [0.38, 0.42]], // scout — a dart
1: [[0.5, 0.10], [0.74, 0.34], [0.74, 0.76], [0.5, 0.92], [0.26, 0.76], [0.26, 0.34]], // colony ship — a fat pod
2: [[0.34, 0.14], [0.66, 0.14], [0.78, 0.5], [0.66, 0.88], [0.34, 0.88], [0.22, 0.5]], // transport — a barge
3: [[0.5, 0.05], [0.66, 0.40], [0.60, 0.90], [0.40, 0.90], [0.34, 0.40]], // frigate
4: [[0.5, 0.04], [0.60, 0.30], [0.80, 0.56], [0.62, 0.92], [0.38, 0.92], [0.20, 0.56], [0.40, 0.30]], // destroyer
5: [[0.5, 0.03], [0.64, 0.26], [0.86, 0.50], [0.72, 0.70], [0.66, 0.94], [0.34, 0.94], [0.28, 0.70], [0.14, 0.50], [0.36, 0.26]], // cruiser
6: [[0.5, 0.02], [0.62, 0.20], [0.78, 0.34], [0.92, 0.62], [0.74, 0.72], [0.70, 0.96], [0.30, 0.96], [0.26, 0.72], [0.08, 0.62], [0.22, 0.34], [0.38, 0.20]], // battleship
7: [[0.5, 0.08], [0.78, 0.28], [0.90, 0.62], [0.66, 0.90], [0.34, 0.90], [0.10, 0.62], [0.22, 0.28]], // star base — a ring fort
};
function paintShips(scene, key, spec, rules) {
const cols = spec.cols ?? 8;
const rows = spec.rows ?? 10;
const sheet = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, cols, cols * rows);
const speciesByFrame = new Map(rules.speciesList.map((s) => [s.shipFrame, s]));
for (let f = 0; f < cols * rows; f += 1) {
const hullFrame = f % cols;
const speciesFrame = Math.floor(f / cols);
const species = speciesByFrame.get(speciesFrame) ?? rules.speciesList[0];
const shape = HULL_SHAPES[hullFrame] ?? HULL_SHAPES[3];
const rnd = frameRng(f + 1);
sheet.at(f, (ctx, w, h) => {
const body = species.color;
ctx.lineJoin = 'round';
// Hull
poly(ctx, shape.map(([x, y]) => [x * w, y * h]));
const grad = ctx.createLinearGradient(0, 0, w, h);
grad.addColorStop(0, shade(body, 1.25));
grad.addColorStop(0.55, body);
grad.addColorStop(1, shade(body, 0.5));
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = OUTLINE;
ctx.lineWidth = Math.max(1.5, w * 0.022);
ctx.stroke();
// Cockpit / core glow
ctx.beginPath();
ctx.ellipse(w * 0.5, h * 0.34, w * 0.09, h * 0.13, 0, 0, Math.PI * 2);
ctx.fillStyle = '#cfe8ff';
ctx.globalAlpha = 0.85;
ctx.fill();
ctx.globalAlpha = 1;
// Engine flare at the stern
const flare = ctx.createLinearGradient(0, h * 0.86, 0, h);
flare.addColorStop(0, 'rgba(255,214,140,0.9)');
flare.addColorStop(1, 'rgba(255,120,60,0)');
ctx.fillStyle = flare;
ctx.fillRect(w * 0.38, h * 0.86, w * 0.24, h * 0.14);
// Hull plating speckle so big hulls do not read as flat blocks
ctx.fillStyle = shade(body, 0.72);
const plates = 3 + hullFrame;
for (let i = 0; i < plates; i += 1) {
const px = w * (0.3 + rnd() * 0.4);
const py = h * (0.35 + rnd() * 0.45);
ctx.fillRect(px, py, w * 0.06, h * 0.03);
}
});
}
return sheet.finish();
}
// --------------------------------------------------------------------------
// Planets
function paintPlanets(scene, key, spec, rules) {
const cols = spec.cols ?? 5;
const count = rules.planetTypeList.length;
const sheet = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, cols, Math.max(count, cols));
const byFrame = new Map(rules.planetTypeList.map((p) => [p.frame, p]));
for (let f = 0; f < Math.max(count, cols); f += 1) {
const type = byFrame.get(f);
const rnd = frameRng(f + 101);
sheet.at(f, (ctx, w, h) => {
if (!type) return;
const cx = w / 2;
const cy = h / 2;
const r = w * 0.42;
const base = type.color;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = base;
ctx.fillRect(0, 0, w, h);
if (type.id === 'gasgiant') {
// Latitude banding.
for (let i = 0; i < 9; i += 1) {
const y = (i / 9) * h;
ctx.fillStyle = shade(base, 0.72 + (i % 2) * 0.4 + rnd() * 0.12);
ctx.fillRect(0, y, w, h / 9);
}
ctx.beginPath();
ctx.ellipse(cx * 1.25, cy * 1.15, w * 0.13, h * 0.06, 0, 0, Math.PI * 2);
ctx.fillStyle = shade('#c05a3a', 1);
ctx.fill();
} else if (type.id === 'asteroids') {
ctx.fillStyle = '#0b0d12';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 34; i += 1) {
const a = rnd() * Math.PI * 2;
const d = r * (0.25 + rnd() * 0.75);
ctx.beginPath();
ctx.arc(cx + Math.cos(a) * d, cy + Math.sin(a) * d * 0.5, w * (0.012 + rnd() * 0.03), 0, Math.PI * 2);
ctx.fillStyle = shade(base, 0.6 + rnd() * 0.8);
ctx.fill();
}
} else {
// Continents / surface mottling.
const blobs = type.habitability > 0.5 ? 7 : 11;
for (let i = 0; i < blobs; i += 1) {
const a = rnd() * Math.PI * 2;
const d = r * rnd() * 0.85;
const rr = w * (0.06 + rnd() * 0.16);
ctx.beginPath();
ctx.ellipse(cx + Math.cos(a) * d, cy + Math.sin(a) * d, rr, rr * (0.6 + rnd() * 0.6), rnd() * 3, 0, Math.PI * 2);
ctx.fillStyle = shade(base, type.hostility > 2 ? 0.6 + rnd() * 0.5 : 0.72 + rnd() * 0.55);
ctx.globalAlpha = 0.85;
ctx.fill();
}
ctx.globalAlpha = 1;
// Ice caps on anything cold enough to have them.
if (['tundra', 'terran', 'ocean', 'steppe', 'minimal'].includes(type.id)) {
ctx.fillStyle = 'rgba(236,246,255,0.85)';
ctx.beginPath();
ctx.ellipse(cx, cy - r * 0.92, r * 0.55, r * 0.24, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx, cy + r * 0.92, r * 0.5, r * 0.22, 0, 0, Math.PI * 2);
ctx.fill();
}
}
// Terminator shading — the single cheapest thing that makes a flat disc
// read as a sphere.
const lit = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.4, r * 0.1, cx, cy, r * 1.05);
lit.addColorStop(0, 'rgba(255,255,255,0.30)');
lit.addColorStop(0.5, 'rgba(0,0,0,0)');
lit.addColorStop(1, 'rgba(0,0,0,0.72)');
ctx.fillStyle = lit;
ctx.fillRect(0, 0, w, h);
ctx.restore();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(180,210,255,0.28)';
ctx.lineWidth = Math.max(1, w * 0.01);
ctx.stroke();
});
}
return sheet.finish();
}
// --------------------------------------------------------------------------
// Stars
function paintStars(scene, key, spec, rules) {
const cols = spec.cols ?? 3;
const count = rules.starClassList.length;
const sheet = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, cols, Math.max(count, cols));
rules.starClassList.forEach((cls, f) => {
sheet.at(f, (ctx, w, h) => {
const cx = w / 2;
const cy = h / 2;
const r = w * 0.20;
if (cls.special === 'blackhole') {
// Accretion ring, then a hole punched out of the middle.
const ring = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 2.6);
ring.addColorStop(0, 'rgba(255,190,120,0)');
ring.addColorStop(0.35, 'rgba(255,170,90,0.85)');
ring.addColorStop(0.7, 'rgba(150,90,220,0.45)');
ring.addColorStop(1, 'rgba(40,20,70,0)');
ctx.fillStyle = ring;
ctx.fillRect(0, 0, w, h);
ctx.beginPath();
ctx.arc(cx, cy, r * 0.95, 0, Math.PI * 2);
ctx.fillStyle = '#04030a';
ctx.fill();
return;
}
// Outer corona
const glow = ctx.createRadialGradient(cx, cy, r * 0.2, cx, cy, w * 0.48);
glow.addColorStop(0, cls.coreColor);
glow.addColorStop(0.18, cls.color);
glow.addColorStop(0.5, `${cls.color}55`);
glow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, w, h);
// Core
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = cls.coreColor;
ctx.fill();
// Lens-flare spikes — four long, four short.
ctx.save();
ctx.translate(cx, cy);
ctx.globalAlpha = 0.55;
for (let i = 0; i < 8; i += 1) {
const len = w * (i % 2 === 0 ? 0.46 : 0.26);
ctx.rotate(Math.PI / 4);
const g = ctx.createLinearGradient(0, 0, 0, -len);
g.addColorStop(0, cls.color);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(-w * 0.012, 0);
ctx.lineTo(w * 0.012, 0);
ctx.lineTo(0, -len);
ctx.closePath();
ctx.fill();
}
ctx.restore();
if (cls.special === 'binary') {
ctx.beginPath();
ctx.arc(cx + w * 0.20, cy - h * 0.13, r * 0.62, 0, Math.PI * 2);
ctx.fillStyle = cls.coreColor;
ctx.fill();
}
if (cls.special === 'pulsar') {
// The sweeping beam pair.
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-0.5);
const beam = ctx.createLinearGradient(0, 0, 0, -w * 0.5);
beam.addColorStop(0, 'rgba(210,240,255,0.9)');
beam.addColorStop(1, 'rgba(120,190,255,0)');
ctx.fillStyle = beam;
for (const dir of [1, -1]) {
ctx.save();
ctx.scale(1, dir);
ctx.beginPath();
ctx.moveTo(-w * 0.04, 0);
ctx.lineTo(w * 0.04, 0);
ctx.lineTo(w * 0.10, -w * 0.5);
ctx.lineTo(-w * 0.10, -w * 0.5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.restore();
}
});
});
return sheet.finish();
}
// --------------------------------------------------------------------------
// Portraits — species and leaders share one painter.
function paintPortraits(scene, key, spec, rules) {
const cols = spec.cols ?? 5;
const rows = spec.rows ?? 2;
const count = cols * rows;
const sheet = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, cols, count);
// Species sheets get species colours; the leader sheet has no species, so it
// falls back to a neutral ramp.
const isSpecies = count <= rules.speciesList.length + cols;
const byFrame = new Map(rules.speciesList.map((s) => [s.portraitFrame, s]));
for (let f = 0; f < count; f += 1) {
const species = isSpecies ? byFrame.get(f) : null;
const rnd = frameRng(f + 501);
const tint = species ? species.color : `hsl(${(f * 47) % 360}, 32%, 55%)`;
sheet.at(f, (ctx, w, h) => {
// Backdrop
const bg = ctx.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, shade(tint, 0.34));
bg.addColorStop(1, '#0a0c12');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
// Head silhouette. Its proportions vary per frame so the ten species do
// not all read as the same creature in different colours.
const headW = w * (0.30 + rnd() * 0.14);
const headH = h * (0.30 + rnd() * 0.14);
const cx = w / 2;
const cy = h * 0.44;
ctx.beginPath();
ctx.ellipse(cx, cy, headW, headH, 0, 0, Math.PI * 2);
const face = ctx.createRadialGradient(cx - headW * 0.3, cy - headH * 0.35, headW * 0.15, cx, cy, headW * 1.2);
face.addColorStop(0, shade(tint, 1.35));
face.addColorStop(1, shade(tint, 0.55));
ctx.fillStyle = face;
ctx.fill();
ctx.strokeStyle = OUTLINE;
ctx.lineWidth = Math.max(2, w * 0.012);
ctx.stroke();
// Shoulders
ctx.beginPath();
ctx.moveTo(cx - w * 0.34, h);
ctx.quadraticCurveTo(cx, h * 0.66, cx + w * 0.34, h);
ctx.closePath();
ctx.fillStyle = shade(tint, 0.42);
ctx.fill();
ctx.strokeStyle = OUTLINE;
ctx.stroke();
// Eyes — count and placement carry most of the "alien" read.
const eyes = 1 + Math.floor(rnd() * 3);
ctx.fillStyle = '#e8f6ff';
for (let i = 0; i < eyes; i += 1) {
const ex = cx + (i - (eyes - 1) / 2) * headW * 0.62;
ctx.beginPath();
ctx.ellipse(ex, cy - headH * 0.12, headW * 0.16, headH * 0.11, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#101820';
for (let i = 0; i < eyes; i += 1) {
const ex = cx + (i - (eyes - 1) / 2) * headW * 0.62;
ctx.beginPath();
ctx.ellipse(ex, cy - headH * 0.12, headW * 0.06, headH * 0.07, 0, 0, Math.PI * 2);
ctx.fill();
}
// A crest, horns or antennae, chosen per frame.
ctx.strokeStyle = shade(tint, 0.8);
ctx.lineWidth = Math.max(2, w * 0.016);
const crest = Math.floor(rnd() * 3);
if (crest === 0) {
for (const s of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(cx + s * headW * 0.6, cy - headH * 0.6);
ctx.quadraticCurveTo(cx + s * headW * 1.2, cy - headH * 1.3, cx + s * headW * 0.75, cy - headH * 1.5);
ctx.stroke();
}
} else if (crest === 1) {
ctx.beginPath();
ctx.moveTo(cx - headW * 0.5, cy - headH * 0.85);
ctx.lineTo(cx, cy - headH * 1.45);
ctx.lineTo(cx + headW * 0.5, cy - headH * 0.85);
ctx.stroke();
}
// Vignette
const vig = ctx.createRadialGradient(cx, h * 0.45, w * 0.2, cx, h * 0.5, w * 0.72);
vig.addColorStop(0, 'rgba(0,0,0,0)');
vig.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = vig;
ctx.fillRect(0, 0, w, h);
});
}
return sheet.finish();
}
// --------------------------------------------------------------------------
// Icons — buildings and tech glyphs.
function paintIcons(scene, key, spec) {
const cols = spec.cols ?? 8;
const rows = spec.rows ?? 2;
const count = cols * rows;
const sheet = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, cols, count);
for (let f = 0; f < count; f += 1) {
const rnd = frameRng(f + 907);
const hue = (f * 37) % 360;
sheet.at(f, (ctx, w, h) => {
const pad = w * 0.14;
roundRect(ctx, pad, pad, w - pad * 2, h - pad * 2, w * 0.16);
const g = ctx.createLinearGradient(0, pad, 0, h - pad);
g.addColorStop(0, `hsl(${hue}, 42%, 46%)`);
g.addColorStop(1, `hsl(${hue}, 46%, 24%)`);
ctx.fillStyle = g;
ctx.fill();
ctx.strokeStyle = 'rgba(220,236,255,0.5)';
ctx.lineWidth = Math.max(1, w * 0.03);
ctx.stroke();
// A distinct glyph per frame so a grid of icons is scannable even before
// real art lands.
ctx.strokeStyle = '#eaf4ff';
ctx.lineWidth = Math.max(1.5, w * 0.055);
ctx.lineCap = 'round';
const cx = w / 2;
const cy = h / 2;
const r = w * 0.20;
const glyph = f % 6;
ctx.beginPath();
if (glyph === 0) { ctx.arc(cx, cy, r, 0, Math.PI * 2); }
else if (glyph === 1) { ctx.moveTo(cx - r, cy - r); ctx.lineTo(cx + r, cy + r); ctx.moveTo(cx + r, cy - r); ctx.lineTo(cx - r, cy + r); }
else if (glyph === 2) { ctx.moveTo(cx, cy - r); ctx.lineTo(cx + r, cy + r); ctx.lineTo(cx - r, cy + r); ctx.closePath(); }
else if (glyph === 3) { ctx.rect(cx - r, cy - r, r * 2, r * 2); }
else if (glyph === 4) { ctx.moveTo(cx - r, cy); ctx.lineTo(cx + r, cy); ctx.moveTo(cx, cy - r); ctx.lineTo(cx, cy + r); }
else { ctx.moveTo(cx - r, cy + r * 0.6); ctx.lineTo(cx - r * 0.2, cy - r * 0.6); ctx.lineTo(cx + r * 0.3, cy + r * 0.2); ctx.lineTo(cx + r, cy - r * 0.7); }
ctx.stroke();
if (rnd() < 0.4) {
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.fillRect(pad, pad, w - pad * 2, (h - pad * 2) * 0.28);
}
});
}
return sheet.finish();
}
// --------------------------------------------------------------------------
export const PROC_PAINTERS = {
ship: paintShips,
planet: paintPlanets,
star: paintStars,
portrait: paintPortraits,
icon: (scene, key, spec) => paintIcons(scene, key, spec),
};
/**
* Resolve every sheet to a texture key: the drop-in art if it loaded, otherwise
* a procedurally painted stand-in with the same frame layout. Callers use
* `keys[name]` and never have to know which they got.
*/
export function ensureSheets(scene, rules, art) {
const keys = Object.create(null);
const procedural = [];
for (const [name, spec] of Object.entries(art?.sheets ?? {})) {
if (spec.path && scene.textures.exists(spec.key)) { keys[name] = spec.key; continue; }
const procKey = `${spec.key}-proc`;
if (!scene.textures.exists(procKey)) {
const painter = PROC_PAINTERS[spec.kind];
if (!painter) {
console.warn(`[VegaArt] sheet "${name}" has unknown kind "${spec.kind}" — skipping`);
continue;
}
painter(scene, procKey, spec, rules);
}
keys[name] = procKey;
procedural.push(name);
}
return { keys, procedural };
}
// Frame helpers — the one place that knows how the sheets are indexed.
export const shipFrame = (rules, speciesId, hullId) => {
const s = rules.species[speciesId];
const h = rules.hulls[hullId];
return (s?.shipFrame ?? 0) * 8 + (h?.frame ?? 0);
};
export const planetFrame = (rules, typeId) => rules.planetTypes[typeId]?.frame ?? 0;
export const starFrame = (rules, classId) => rules.starClassList.findIndex((c) => c.id === classId);
export const speciesPortraitFrame = (rules, speciesId) => rules.species[speciesId]?.portraitFrame ?? 0;
export const techFrame = (rules, techId) => rules.techs[techId]?.iconFrame ?? 0;
export const buildingFrame = (rules, buildingId) => rules.buildings[buildingId]?.frame ?? 0;

View File

@ -0,0 +1,377 @@
// Master of Vega — tactical space combat.
//
// Headless and deterministic. The battle is a STEPPER: createBattle() sets the
// board up, stepRound() advances exactly one round, and runBattle() just calls
// stepRound() until someone wins. That is deliberate — the playable battle and
// the "auto-resolve" button run literally the same code, so they can never
// disagree, and the verifier asserts it.
//
// Positions are tracked as a column on a gridCols-wide lane. Rows exist only
// for rendering; the sim cares about the distance between two stacks, because
// that is what decides whether beams can reach or only missiles can.
import { designFor } from './VegaShips.js';
// Aggregate rather than per-shot rolls. A late-game battle can involve tens of
// thousands of individual shots; sampling each one would dominate the soak's
// runtime for no extra fidelity. This is a normal approximation to the
// binomial, which is exactly what all those independent rolls converge to.
function sampleHits(rnd, shots, chance) {
if (shots <= 0 || chance <= 0) return 0;
if (chance >= 1) return shots;
const mean = shots * chance;
const sd = Math.sqrt(shots * chance * (1 - chance));
// Two uniforms give a cheap symmetric bell without a Box-Muller transcendental.
const jitter = (rnd() + rnd() - 1) * 1.7320508 * sd;
return Math.max(0, Math.min(shots, Math.round(mean + jitter)));
}
const avgDmg = (w) => (w.min + w.max) / 2;
// One entry per hull type per side. `hpFront` is damage carried on the ship
// currently taking fire, so a stack degrades ship by ship instead of all at once.
function makeStack(rules, design, count, side, idx) {
return {
uid: `${side}-${idx}`,
side,
hullId: design.hullId,
name: design.name,
mark: design.mark,
design,
count,
startCount: count,
hpEach: design.hp,
hpFront: design.hp,
shield: design.shield,
speed: Math.max(1, design.speed || 1),
immobile: design.immobile,
x: 0,
// Missile racks are per-battle, not per-round: this is what makes missiles
// an opening burst rather than a second beam.
salvoesLeft: new Map(design.mounts.filter((m) => m.weapon.kind === 'missile').map((m) => [m.weapon.id, m.weapon.shots])),
retreated: false,
};
}
function sideStacks(rules, empire, fleetShips, side) {
const out = [];
fleetShips.forEach((s, i) => {
if (s.count <= 0) return;
const design = s.design ?? designFor(rules, empire.known, s.hullId, empire.traits ?? {}, s.skills ?? {});
// A ship built at an older Mark keeps that Mark's stats.
const d = s.mark && s.mark !== design.mark ? { ...design, mark: s.mark } : design;
out.push(makeStack(rules, d, s.count, side, i));
});
return out;
}
export function createBattle(rules, opts) {
const {
attacker, defender, colony = null, starIdx = -1, rnd = Math.random,
} = opts;
const C = rules.combat;
const aStacks = sideStacks(rules, attacker.empire, attacker.ships, 'attacker');
const dStacks = sideStacks(rules, defender.empire, defender.ships, 'defender');
for (const s of aStacks) s.x = 0;
for (const s of dStacks) s.x = C.gridCols - 1;
// A defended colony fights as an extra immobile "stack" that cannot be
// boarded — killing it is what clears the way for an invasion.
let planet = null;
if (colony && colony.defenseHp > 0) {
planet = {
uid: 'planet',
side: 'defender',
isPlanet: true,
name: 'Planetary Defences',
count: 1,
hpEach: colony.defenseHp,
hpFront: colony.defenseHp,
shield: colony.shieldBonus ?? 0,
x: C.gridCols - 1,
speed: 0,
immobile: true,
damage: C.planetDefenseBase + colony.defenseHp * 0.05,
salvoesLeft: new Map(),
retreated: false,
};
dStacks.push(planet);
}
return {
rules, C, rnd, starIdx, colony,
attackerIdx: attacker.empireIdx,
defenderIdx: defender.empireIdx,
attackerName: attacker.name ?? 'Attacker',
defenderName: defender.name ?? 'Defender',
attackerTraits: attacker.empire.traits ?? {},
defenderTraits: defender.empire.traits ?? {},
stacks: [...aStacks, ...dStacks],
planet,
pending: new Map(),
round: 0,
done: false,
winner: null,
log: [],
};
}
const living = (b, side) => b.stacks.filter((s) => s.side === side && s.count > 0 && !s.retreated);
function hitChance(C, shooter, target, shooterTraits, targetTraits) {
const targeting = shooter.design?.targeting ?? 0;
const attack = (shooter.design?.attack ?? 0);
const defense = (target.design?.defense ?? 0);
const chance = C.baseHitChance
+ C.hitPerTargeting * targeting
+ C.hitPerAttack * attack
- C.hitPerDefense * defense;
return Math.max(0.05, Math.min(0.95, chance));
}
// Fire everything one stack can bring to bear this round.
function fire(b, shooter, targets, events) {
if (!targets.length) return;
const C = b.C;
// Planetary defences are a flat battery, not a mount list.
if (shooter.isPlanet) {
const target = targets[0];
const dmg = Math.max(0, shooter.damage - target.shield) * (0.75 + b.rnd() * 0.5);
applyDamage(b, target, dmg, events, shooter);
return;
}
for (const m of shooter.design.mounts) {
const w = m.weapon;
const range = w.kind === 'missile' ? C.missileRange : C.beamRange;
// Prefer the closest reachable enemy; missiles reach across the lane,
// beams need the fleets to have closed.
const inRange = targets.filter((t) => Math.abs(t.x - shooter.x) <= range);
if (!inRange.length) continue;
const target = inRange.reduce((best, t) => (Math.abs(t.x - shooter.x) < Math.abs(best.x - shooter.x) ? t : best), inRange[0]);
let shotsPerShip = 1;
if (w.kind === 'missile') {
const left = shooter.salvoesLeft.get(w.id) ?? 0;
if (left <= 0) continue;
shooter.salvoesLeft.set(w.id, left - 1);
shotsPerShip = 1;
}
const totalShots = shooter.count * m.count * shotsPerShip;
const chance = hitChance(C, shooter, target,
shooter.side === 'attacker' ? b.attackerTraits : b.defenderTraits,
target.side === 'attacker' ? b.attackerTraits : b.defenderTraits);
const hits = sampleHits(b.rnd, totalShots, chance);
if (hits <= 0) continue;
const effShield = Math.max(0, (target.shield ?? 0) - (w.shieldPierce ?? 0));
const perHit = Math.max(0, avgDmg(w) - effShield);
if (perHit <= 0) {
events.push({ kind: 'bounce', from: shooter.uid, to: target.uid, weapon: w.name });
continue;
}
applyDamage(b, target, hits * perHit, events, shooter, w);
}
}
// Damage is BANKED, not applied. Everything fires against the board as it
// stood at the start of the round, and the totals land together in
// applyPending(). Without this the sequence of fire decides the battle: a stack
// that shoots first can wipe a target before it ever returns fire, so whichever
// side happens to act first in the round contact is made wins. That bias is
// invisible in a single battle and completely dominates a mirror match.
function applyDamage(b, target, damage, events, shooter, weapon = null) {
b.pending.set(target, (b.pending.get(target) ?? 0) + damage);
events.push({
kind: 'fire',
from: shooter.uid,
to: target.uid,
weapon: weapon?.name ?? 'Planetary Defences',
damage: Math.round(damage),
});
}
function applyPending(b, events) {
for (const [target, damage] of b.pending) {
let left = damage;
let killed = 0;
while (left > 0 && target.count > 0) {
if (left >= target.hpFront) {
left -= target.hpFront;
target.count -= 1;
killed += 1;
target.hpFront = target.hpEach;
} else {
target.hpFront -= left;
left = 0;
}
}
if (target.count <= 0) { target.count = 0; target.hpFront = 0; }
if (killed > 0) events.push({ kind: 'losses', uid: target.uid, killed, left: target.count });
}
b.pending.clear();
}
// orders: { [stackUid]: 'advance' | 'hold' | 'retreat' }. Anything unlisted
// advances, which is what the AI and auto-resolve want.
export function stepRound(b, orders = {}) {
if (b.done) return null;
b.round += 1;
const events = [];
// Retreat resolves before anyone shoots — a stack that withdraws this round
// takes no further fire, which is what makes retreating worth doing.
if (b.round > b.C.retreatAfterRound) {
for (const s of b.stacks) {
if (s.count > 0 && !s.immobile && orders[s.uid] === 'retreat') {
s.retreated = true;
events.push({ kind: 'retreat', uid: s.uid });
}
}
}
// Initiative order still decides who *moves* first (a faster fleet dictates
// the range the battle is fought at), but because damage is banked and
// applied together, it no longer decides who survives to shoot back.
const order = b.stacks
.filter((s) => s.count > 0 && !s.retreated)
.sort((x, y) => (y.design?.initiative ?? 0) - (x.design?.initiative ?? 0) || x.uid.localeCompare(y.uid));
for (const s of order) {
if (s.immobile || orders[s.uid] === 'hold') continue;
const enemies = living(b, s.side === 'attacker' ? 'defender' : 'attacker');
if (!enemies.length) continue;
const nearest = enemies.reduce((best, t) => (Math.abs(t.x - s.x) < Math.abs(best.x - s.x) ? t : best), enemies[0]);
const dir = Math.sign(nearest.x - s.x);
// Close to beam range but never move onto the enemy's own square.
const want = Math.abs(nearest.x - s.x) - b.C.beamRange;
s.x += dir * Math.max(0, Math.min(s.speed, want));
}
for (const s of order) {
const enemies = living(b, s.side === 'attacker' ? 'defender' : 'attacker');
if (!enemies.length) continue;
fire(b, s, enemies, events);
}
applyPending(b, events);
// Between-round repairs (Automated Repair Unit and damage-control crews).
for (const s of b.stacks) {
if (s.count > 0 && s.design?.repairPerRound > 0 && s.hpFront < s.hpEach) {
s.hpFront = Math.min(s.hpEach, s.hpFront + s.hpEach * s.design.repairPerRound);
}
}
const aLeft = living(b, 'attacker');
const dLeft = living(b, 'defender');
const aArmed = aLeft.some((s) => (s.design?.damage ?? 0) > 0 || s.isPlanet);
const dArmed = dLeft.some((s) => (s.design?.damage ?? 0) > 0 || s.isPlanet);
if (!aLeft.length) { b.done = true; b.winner = 'defender'; }
else if (!dLeft.length) { b.done = true; b.winner = 'attacker'; }
else if (!aArmed && !dArmed) { b.done = true; b.winner = 'draw'; }
else if (b.round >= b.C.maxRounds) { b.done = true; b.winner = 'defender'; }
b.log.push({ round: b.round, events });
return { round: b.round, events, done: b.done, winner: b.winner };
}
// How badly a side is losing, used by the AI (and by the player's own nerve)
// to decide whether to pull out.
export function sideStrength(b, side) {
return living(b, side).reduce((t, s) => t + s.count * (s.hpEach + (s.design?.damage ?? 0) * 4), 0);
}
// Default orders for a side not under player control.
//
// The disengage rule is what stops battles ending on the round cap. Two evenly
// matched fleets grind each other down ever more slowly — damage falls as ships
// die, so the tail of a symmetric battle is enormously long, and a fixed cap
// turns that tail into an arbitrary "defender wins". Real fleets break off. So
// once a battle has clearly gone on too long, the weaker side withdraws and the
// engagement resolves decisively.
function autoOrders(b) {
const orders = {};
const aStr = sideStrength(b, 'attacker');
const dStr = sideStrength(b, 'defender');
if (b.round <= b.C.retreatAfterRound) return orders;
const withdraw = (side) => { for (const s of living(b, side)) orders[s.uid] = 'retreat'; };
// An attacker who is being beaten badly leaves early rather than feeding the
// whole fleet into a losing action.
if (aStr > 0 && dStr > aStr * 2.5) { withdraw('attacker'); return orders; }
if (b.round >= (b.C.disengageRound ?? 25) && aStr > 0 && dStr > 0) {
// Mirror matches reach this point EXACTLY tied astonishingly often — both
// fleets are the same ships losing hulls in step. Resolving a tie in a
// fixed direction hands one side every drawn battle in the game, so the
// coin has to actually be flipped.
const weaker = aStr === dStr
? (b.rnd() < 0.5 ? 'attacker' : 'defender')
: (aStr < dStr ? 'attacker' : 'defender');
// Immobile planetary defences cannot withdraw, so a colony keeps fighting
// even after its fleet screen breaks off — which is exactly right.
withdraw(weaker);
}
return orders;
}
// Auto-resolve: the same stepper, driven to completion.
export function runBattle(b, { allowRetreat = true } = {}) {
let guard = 0;
while (!b.done && guard < b.C.maxRounds + 2) {
guard += 1;
stepRound(b, allowRetreat ? autoOrders(b, 'defender') : {});
}
if (!b.done) { b.done = true; b.winner = 'defender'; }
return battleResult(b);
}
export function battleResult(b) {
const survivors = (side) => b.stacks
.filter((s) => s.side === side && s.count > 0 && !s.isPlanet)
.map((s) => ({ hullId: s.hullId, mark: s.mark, count: s.count }));
const losses = (side) => b.stacks
.filter((s) => s.side === side && !s.isPlanet)
.map((s) => ({ hullId: s.hullId, mark: s.mark, lost: s.startCount - s.count }))
.filter((s) => s.lost > 0);
return {
winner: b.winner,
rounds: b.round,
starIdx: b.starIdx,
attackerIdx: b.attackerIdx,
defenderIdx: b.defenderIdx,
attackerSurvivors: survivors('attacker'),
defenderSurvivors: survivors('defender'),
attackerLosses: losses('attacker'),
defenderLosses: losses('defender'),
planetDefenseLeft: b.planet ? Math.max(0, b.planet.hpFront) : 0,
planetDestroyed: b.planet ? b.planet.count <= 0 : false,
log: b.log,
};
}
// --------------------------------------------------------------------------
// Ground combat — resolved in one shot after orbit is cleared.
export function resolveInvasion(rules, rnd, attackTroops, attackBonus, colony, defenseBonus, defenderPop) {
const C = rules.combat;
let att = attackTroops;
// Population itself defends: every colony is a militia of last resort.
let def = Math.max(1, Math.round(defenderPop / 8)) + Math.round((colony.groundDefense ?? 0) / 10);
const attOdds = 0.5 + (attackBonus - defenseBonus) * C.groundOddsScale;
const p = Math.max(0.1, Math.min(0.9, attOdds));
let guard = 0;
while (att > 0 && def > 0 && guard < 500) {
guard += 1;
if (rnd() < p) def -= 1;
else att -= 1;
}
return { captured: att > 0, attackersLeft: att, defendersLeft: def };
}

View File

@ -0,0 +1,167 @@
// Master of Vega — the tactical battle screen.
//
// This view does not decide anything. It drives VegaCombat's stepper one round
// at a time and animates the events that come back, which is why "play it out"
// and "auto-resolve" can never disagree: auto-resolve is the same stepper with
// the animation skipped.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { FONT, D } from './VegaScreens.js';
import VegaFx from './VegaFx.js';
import { stepRound, runBattle, battleResult } from './VegaCombat.js';
import { shipFrame } from './VegaArt.js';
const LANE_LEFT = 260;
const LANE_RIGHT = GAME_WIDTH - 260;
export function openCombatView(scene, rules, battle, art, opts = {}) {
const { attackerSpecies = 'human', defenderSpecies = 'human', onDone = null, playerSide = null } = opts;
const layer = scene.add.container(0, 0).setDepth(D.modal);
layer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x01040a, 0.94)
.setOrigin(0, 0).setInteractive());
const fxLayer = scene.add.container(0, 0);
layer.add(fxLayer);
const fx = new VegaFx(scene, fxLayer);
const title = scene.add.text(GAME_WIDTH / 2, 40,
`${battle.attackerName} vs ${battle.defenderName}`, {
fontFamily: FONT, fontSize: '30px', color: '#cfe8ff',
}).setOrigin(0.5);
layer.add(title);
const roundText = scene.add.text(GAME_WIDTH / 2, 82, 'Round 0', {
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
}).setOrigin(0.5);
layer.add(roundText);
const stackLayer = scene.add.container(0, 0);
layer.add(stackLayer);
const colX = (x) => LANE_LEFT + (x / (rules.combat.gridCols - 1)) * (LANE_RIGHT - LANE_LEFT);
const markers = new Map();
function buildMarkers() {
stackLayer.removeAll(true);
markers.clear();
const rows = { attacker: 0, defender: 0 };
for (const s of battle.stacks) {
const side = s.side;
const row = rows[side]++;
const y = 200 + row * 120 + (side === 'attacker' ? 0 : 60);
const c = scene.add.container(colX(s.x), y);
if (s.isPlanet) {
const g = scene.add.graphics();
g.fillStyle(0x8a6a3a, 1);
g.fillCircle(0, 0, 26);
g.lineStyle(2, 0xffd88a, 0.8);
g.strokeCircle(0, 0, 32);
c.add(g);
} else {
const species = side === 'attacker' ? attackerSpecies : defenderSpecies;
const img = scene.add.image(0, 0, art.ships, shipFrame(rules, species, s.hullId))
.setDisplaySize(58, 58)
.setRotation(side === 'attacker' ? Math.PI / 2 : -Math.PI / 2);
c.add(img);
}
const label = scene.add.text(0, 38, `${s.name}`, {
fontFamily: FONT, fontSize: '13px', color: '#9fb6cc',
}).setOrigin(0.5);
c.add(label);
const count = scene.add.text(0, 54, `×${s.count}`, {
fontFamily: FONT, fontSize: '16px', color: side === 'attacker' ? '#9fd8ff' : '#ffb0a0',
}).setOrigin(0.5);
c.add(count);
stackLayer.add(c);
markers.set(s.uid, { stack: s, container: c, count, label, y });
}
}
function syncMarkers() {
for (const [, m] of markers) {
m.count.setText(`×${m.stack.count}`);
m.container.setAlpha(m.stack.count > 0 && !m.stack.retreated ? 1 : 0.25);
scene.tweens.add({
targets: m.container, x: colX(m.stack.x), duration: 260, ease: 'Sine.easeInOut',
});
}
}
function animate(step) {
if (!step) return;
roundText.setText(`Round ${step.round}`);
for (const ev of step.events) {
const from = markers.get(ev.from);
const to = markers.get(ev.to);
if (ev.kind === 'fire' && from && to) {
const missile = /rocket|missile/i.test(ev.weapon);
fx.beam(from.container.x, from.container.y, to.container.x, to.container.y,
missile ? 0xffb060 : 0x9fd8ff, missile);
scene.time.delayedCall(missile ? 240 : 60, () => {
if (to.container.active) fx.hit(to.container.x, to.container.y, missile ? 0xffb060 : 0xffd28a);
});
} else if (ev.kind === 'losses') {
const m = markers.get(ev.uid);
if (m) scene.time.delayedCall(180, () => {
if (m.container.active) fx.explode(m.container.x, m.container.y, 0xffa050, 1);
});
} else if (ev.kind === 'retreat') {
const m = markers.get(ev.uid);
if (m) m.container.setAlpha(0.25);
}
}
scene.time.delayedCall(320, syncMarkers);
}
function finish() {
const result = battleResult(battle);
const won = playerSide && result.winner === playerSide;
const banner = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2,
result.winner === 'draw' ? 'STALEMATE'
: `${result.winner === 'attacker' ? battle.attackerName : battle.defenderName} HOLDS THE FIELD`, {
fontFamily: FONT, fontSize: '52px',
color: playerSide ? (won ? '#ffd88a' : '#e08a8a') : '#cfe8ff',
}).setOrigin(0.5);
layer.add(banner);
scene.time.delayedCall(1400, () => {
fx.destroy();
layer.destroy();
onDone?.(result);
});
}
// --- controls
const next = new Button(scene, GAME_WIDTH / 2 - 230, GAME_HEIGHT - 70, 'Next round', () => {
if (battle.done) return;
animate(stepRound(battle, {}));
if (battle.done) scene.time.delayedCall(700, finish);
}, { width: 220, height: 52 });
layer.add(next);
const auto = new Button(scene, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Auto-resolve', () => {
runBattle(battle);
syncMarkers();
finish();
}, { width: 220, height: 52 });
layer.add(auto);
const retreat = new Button(scene, GAME_WIDTH / 2 + 230, GAME_HEIGHT - 70, 'Withdraw', () => {
if (battle.done || !playerSide) return;
const orders = {};
for (const s of battle.stacks) if (s.side === playerSide) orders[s.uid] = 'retreat';
animate(stepRound(battle, orders));
if (battle.done) scene.time.delayedCall(700, finish);
}, { width: 220, height: 52, variant: playerSide ? 'solid' : 'ghost' });
layer.add(retreat);
buildMarkers();
syncMarkers();
return { layer, destroy: () => { fx.destroy(); layer.destroy(); } };
}

View File

@ -0,0 +1,205 @@
// Master of Vega — diplomacy, treaties and the Galactic Council's politics.
// Headless: no Phaser, so the whole state machine is soak-testable.
//
// Attitude runs -100 (hatred) to +100 (devotion) and is the single number every
// decision reads. Species diplomacy traits bias it permanently — the Lithox sit
// at -100 and simply cannot be talked to, which is the point of them.
import { empireColonies, atWar, rand, randInt } from './VegaLogic.js';
export const TREATIES = ['none', 'peace', 'alliance', 'war'];
export function attitudeOf(state, a, b) {
return state.empires[a]?.attitude?.[b] ?? 0;
}
export function moodOf(attitude) {
if (attitude <= -60) return 'hostile';
if (attitude <= -20) return 'cold';
if (attitude < 20) return 'neutral';
if (attitude < 60) return 'warm';
return 'devoted';
}
function adjust(state, a, b, delta) {
const emp = state.empires[a];
emp.attitude[b] = Math.max(-100, Math.min(100, (emp.attitude[b] ?? 0) + delta));
}
// The natural pull a species feels toward or away from everyone else. A species
// with a diplomacy penalty drifts back to dislike no matter what you do for it.
function baseline(rules, state, a) {
return Math.round((rules.species[state.empires[a].speciesId].traits.diplomacy ?? 0) / 2);
}
export function canNegotiate(rules, state, a, b) {
const sa = rules.species[state.empires[a].speciesId];
const sb = rules.species[state.empires[b].speciesId];
// A species with total diplomatic incapacity never comes to the table.
if ((sa.traits.diplomacy ?? 0) <= -100 || (sb.traits.diplomacy ?? 0) <= -100) return false;
return !!state.empires[a].contacted[b];
}
export function declareWar(rules, state, a, b) {
state.empires[a].treaties[b] = 'war';
state.empires[b].treaties[a] = 'war';
adjust(state, b, a, -35);
adjust(state, a, b, -15);
state.events.push({ type: 'warDeclared', empire: a, other: b, turn: state.turn });
// Everyone else notices an aggressor.
for (const o of state.empires) {
if (!o.alive || o.idx === a || o.idx === b) continue;
if (o.contacted[a]) adjust(state, o.idx, a, -6);
}
}
export function makePeace(rules, state, a, b) {
state.empires[a].treaties[b] = 'peace';
state.empires[b].treaties[a] = 'peace';
adjust(state, a, b, 12);
adjust(state, b, a, 12);
state.events.push({ type: 'peace', empire: a, other: b, turn: state.turn });
}
export function formAlliance(rules, state, a, b) {
state.empires[a].treaties[b] = 'alliance';
state.empires[b].treaties[a] = 'alliance';
adjust(state, a, b, 20);
adjust(state, b, a, 20);
state.events.push({ type: 'alliance', empire: a, other: b, turn: state.turn });
}
// Relative strength, used everywhere an empire has to decide whether it can
// afford an opinion.
export function powerOf(rules, state, e) {
const emp = state.empires[e];
let power = emp.totalPop;
for (const c of empireColonies(state, e)) power += c.factories * 0.5 + c.defenseHp * 0.1;
power += emp.techsKnown * 8;
return power;
}
// Would `b` accept this proposal from `a`?
export function wouldAccept(rules, state, a, b, kind) {
if (!canNegotiate(rules, state, a, b)) return false;
const att = attitudeOf(state, b, a);
const ratio = powerOf(rules, state, a) / Math.max(1, powerOf(rules, state, b));
switch (kind) {
case 'peace':
// The losing side sues for peace readily; a winning one has no reason to.
return att > -70 && (att > -20 || ratio > 1.4);
case 'alliance':
return att > 45 && !atWar(state, a, b);
case 'techTrade':
return att > 0;
default:
return false;
}
}
export function proposeTreaty(rules, state, a, b, kind) {
if (!wouldAccept(rules, state, a, b, kind)) {
adjust(state, b, a, -3);
return false;
}
if (kind === 'peace') makePeace(rules, state, a, b);
else if (kind === 'alliance') formAlliance(rules, state, a, b);
return true;
}
// Trade a tech each. Both sides gain, which is why an empire with a research
// bonus should think twice — and why a species locked out of half the tree by
// its availability roll should think once.
export function tradeTech(rules, state, a, b, giveId, wantId) {
const A = state.empires[a];
const B = state.empires[b];
if (!A.known[giveId] || !B.known[wantId]) return false;
if (B.known[giveId] || A.known[wantId]) return false;
if (!wouldAccept(rules, state, a, b, 'techTrade')) return false;
// grantTech lives in VegaLogic; importing it here would make the two modules
// mutually dependent, so the caller applies the grants.
return true;
}
// Per-turn drift. Attitudes decay toward the species baseline, war grinds them
// down, and shared borders create friction.
export function driftAttitudes(rules, state, e) {
const emp = state.empires[e];
const base = baseline(rules, state, e);
for (const other of state.empires) {
if (!other.alive || other.idx === e) continue;
if (!emp.contacted[other.idx]) continue;
const cur = emp.attitude[other.idx] ?? 0;
const pull = cur < base ? 1 : -1;
if (Math.abs(cur - base) > 1) adjust(state, e, other.idx, pull);
if (atWar(state, e, other.idx)) adjust(state, e, other.idx, -2);
else if (emp.treaties[other.idx] === 'alliance') adjust(state, e, other.idx, 1);
}
}
// The AI's diplomatic step: pick fights it can win, sue for peace when losing,
// and ally with whoever it likes enough.
export function runDiplomacyTurn(rules, state, e) {
const emp = state.empires[e];
const diff = rules.difficulties[state.difficultyId];
driftAttitudes(rules, state, e);
const others = state.empires.filter((o) => o.alive && o.idx !== e && emp.contacted[o.idx]);
if (!others.length) return;
const myPower = powerOf(rules, state, e);
for (const other of others) {
const att = attitudeOf(state, e, other.idx);
const ratio = myPower / Math.max(1, powerOf(rules, state, other.idx));
if (atWar(state, e, other.idx)) {
// Sue for peace when clearly losing, or when the war has gone cold.
// Only sue for peace when genuinely losing, and not immediately — a war
// that ends on the turn it starts accomplishes nothing.
if (ratio < 0.55 && rand(state) < 0.12) proposeTreaty(rules, state, e, other.idx, 'peace');
continue;
}
if (!canNegotiate(rules, state, e, other.idx)) {
// Species that cannot negotiate still go to war — more readily, in fact.
if (ratio > 1.25 && rand(state) < 0.06 * diff.aiAggression) declareWar(rules, state, e, other.idx);
continue;
}
// Aggression scales with how much stronger you are and how little you like
// them. An empire that is behind does not start wars, but one that is
// comfortably ahead does not need a grievance either — requiring BOTH a
// grudge and a big power lead meant war effectively never happened.
let appetite = 0;
if (att < -20) appetite += 0.6;
if (att < -50) appetite += 0.5;
if (ratio > 1.2) appetite += 0.5;
if (ratio > 1.8) appetite += 0.7;
// Late in a game with no clear leader, someone has to force the issue.
if (state.turn > 200) appetite += 0.4;
if (appetite >= 1 && rand(state) < 0.035 * appetite * diff.aiAggression) {
declareWar(rules, state, e, other.idx);
continue;
}
if (att > 45 && emp.treaties[other.idx] !== 'alliance' && rand(state) < 0.08) {
proposeTreaty(rules, state, e, other.idx, 'alliance');
}
}
}
// A stalled galaxy is a boring galaxy. If nobody has fought for a long time and
// the Council keeps failing to elect anyone, nudge the strongest empire into
// picking on someone. Without this, high-attitude galaxies can idle to the turn
// cap with five empires politely coexisting and no winner.
export function breakStalemate(rules, state) {
const alive = state.empires.filter((e) => e.alive);
if (alive.length < 2) return false;
if (alive.some((a) => alive.some((b) => a.idx !== b.idx && atWar(state, a.idx, b.idx)))) return false;
const ranked = alive.slice().sort((a, b) => powerOf(rules, state, b.idx) - powerOf(rules, state, a.idx));
const aggressor = ranked[0];
const victim = ranked[randInt(state, Math.max(1, ranked.length - 1)) + 1] ?? ranked[1];
if (!victim || victim.idx === aggressor.idx) return false;
declareWar(rules, state, aggressor.idx, victim.idx);
return true;
}

View File

@ -0,0 +1,135 @@
// Master of Vega — throwaway visual effects.
//
// Mirrors the shape of TAFx / SlotsFx / MahjongFx: a small class that owns its
// generated textures and cleans up after itself. Everything is drawn from
// procedurally generated textures — no new art. All tuning lives in JUICE.
import * as Phaser from 'phaser';
export const JUICE = {
beamMs: 220,
hitMs: 380,
ringMs: 900,
ringRadius: 90,
shakeMs: 140,
shake: 0.004,
maxFx: 260,
};
export default class VegaFx {
constructor(scene, layer) {
this.scene = scene;
this.layer = layer ?? scene.add.container(0, 0);
this.items = [];
this.ensureTextures();
}
ensureTextures() {
const scene = this.scene;
if (!scene.textures.exists('vega-fx-spark')) {
const g = scene.make.graphics({ add: false });
// A soft additive dot: the one primitive every other effect is built from.
for (let i = 12; i > 0; i -= 1) {
g.fillStyle(0xffffff, 0.09);
g.fillCircle(16, 16, i * 1.3);
}
g.generateTexture('vega-fx-spark', 32, 32);
g.destroy();
}
if (!scene.textures.exists('vega-fx-ring')) {
const g = scene.make.graphics({ add: false });
g.lineStyle(3, 0xffffff, 1);
g.strokeCircle(32, 32, 28);
g.generateTexture('vega-fx-ring', 64, 64);
g.destroy();
}
}
track(obj) {
this.items.push(obj);
// Hard cap: drop the oldest rather than let a long battle accumulate
// thousands of tweened objects.
while (this.items.length > JUICE.maxFx) {
const old = this.items.shift();
if (old && old.active) old.destroy();
}
return obj;
}
/** A weapon shot from a to b. Beams are a line, missiles a travelling dot. */
beam(x1, y1, x2, y2, colour = 0x9fd8ff, missile = false) {
if (!missile) {
const g = this.scene.add.graphics();
g.lineStyle(6, colour, 0.16);
g.lineBetween(x1, y1, x2, y2);
g.lineStyle(2, colour, 0.9);
g.lineBetween(x1, y1, x2, y2);
this.layer.add(g);
this.track(g);
this.scene.tweens.add({
targets: g, alpha: 0, duration: JUICE.beamMs, onComplete: () => g.destroy(),
});
return g;
}
const dot = this.scene.add.image(x1, y1, 'vega-fx-spark')
.setTint(colour).setBlendMode(Phaser.BlendModes.ADD).setScale(0.9);
this.layer.add(dot);
this.track(dot);
this.scene.tweens.add({
targets: dot, x: x2, y: y2, duration: JUICE.beamMs * 2, ease: 'Sine.easeIn',
onComplete: () => dot.destroy(),
});
return dot;
}
/** An impact burst where a shot lands. */
hit(x, y, colour = 0xffd28a, scale = 1) {
for (let i = 0; i < 6; i += 1) {
const a = (i / 6) * Math.PI * 2 + Math.random() * 0.5;
const s = this.scene.add.image(x, y, 'vega-fx-spark')
.setTint(colour).setBlendMode(Phaser.BlendModes.ADD).setScale(scale * 0.8);
this.layer.add(s);
this.track(s);
this.scene.tweens.add({
targets: s,
x: x + Math.cos(a) * 26 * scale,
y: y + Math.sin(a) * 26 * scale,
alpha: 0,
scale: scale * 0.2,
duration: JUICE.hitMs,
onComplete: () => s.destroy(),
});
}
}
/** An expanding ring — used for events pinged on the star map. */
ping(x, y, colour = 0xffffff, radius = JUICE.ringRadius) {
const r = this.scene.add.image(x, y, 'vega-fx-ring')
.setTint(colour).setBlendMode(Phaser.BlendModes.ADD).setScale(0.2);
this.layer.add(r);
this.track(r);
this.scene.tweens.add({
targets: r,
scale: radius / 32,
alpha: 0,
duration: JUICE.ringMs,
ease: 'Cubic.easeOut',
onComplete: () => r.destroy(),
});
return r;
}
/** A ship or stack dying. */
explode(x, y, colour = 0xffa050, scale = 1) {
this.hit(x, y, colour, scale * 1.6);
this.ping(x, y, colour, 60 * scale);
this.scene.cameras.main.shake(JUICE.shakeMs, JUICE.shake * scale);
}
clear() {
for (const item of this.items) if (item && item.active) item.destroy();
this.items.length = 0;
}
destroy() { this.clear(); }
}

View File

@ -0,0 +1,448 @@
// Master of Vega — galaxy generation. Headless and fully deterministic: the
// same (size, shape, seed, species list) always produces a byte-identical
// galaxy, which is what lets the verifier soak self-play games reproducibly.
//
// Distances are in PARSECS. Pixels are only ever a rendering concern, so the
// engine's fuel-range maths never has to know how big the star map is drawn.
export const PARSEC_PX = 90;
// Standalone mulberry32 — generation happens once, before there is a game
// state to carry an RNG cursor in, so this one is closure-based on purpose.
// VegaLogic has its own explicit-state variant for in-game randomness.
export function mulberry32(seed) {
let a = seed >>> 0;
return function next() {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
function weightedPick(rnd, list, weightOf) {
let total = 0;
for (const item of list) total += Math.max(0, weightOf(item));
if (total <= 0) return list[0];
let r = rnd() * total;
for (const item of list) {
r -= Math.max(0, weightOf(item));
if (r <= 0) return item;
}
return list[list.length - 1];
}
// --------------------------------------------------------------------------
// Star placement
// Each shape returns a candidate point in [0,w]x[0,h]. Rejection against a
// minimum separation happens in the caller, so a shape only has to describe
// where stars *want* to be.
function samplePoint(rnd, shape, w, h, clusterCenters) {
const cx = w / 2;
const cy = h / 2;
const rx = w / 2;
const ry = h / 2;
if (shape.id === 'spiral') {
const arms = Math.max(1, shape.arms ?? 2);
const arm = Math.floor(rnd() * arms);
// t biased outward so the core does not swallow every star.
const t = Math.sqrt(rnd());
const spin = 2.4;
const ang = t * spin * Math.PI + (arm * 2 * Math.PI) / arms;
// Jitter widens with radius, giving the arms a realistic feathered edge.
const spread = 0.10 + 0.13 * t;
const jr = (rnd() + rnd() + rnd() - 1.5) * spread;
const ja = (rnd() + rnd() + rnd() - 1.5) * spread * 1.6;
const r = Math.min(1, t + jr);
return { x: cx + Math.cos(ang + ja) * r * rx * 0.94, y: cy + Math.sin(ang + ja) * r * ry * 0.94 };
}
if (shape.id === 'ring') {
const r = 0.56 + rnd() * 0.42;
const ang = rnd() * Math.PI * 2;
const jitter = (rnd() + rnd() - 1) * 0.05;
return { x: cx + Math.cos(ang) * (r + jitter) * rx * 0.94, y: cy + Math.sin(ang) * (r + jitter) * ry * 0.94 };
}
if (shape.id === 'cluster') {
const c = clusterCenters[Math.floor(rnd() * clusterCenters.length)];
// Box-Muller would be cleaner but three uniforms is plenty and keeps the
// RNG cursor advancing a fixed number of steps per attempt.
const gx = (rnd() + rnd() + rnd() - 1.5) * 0.9;
const gy = (rnd() + rnd() + rnd() - 1.5) * 0.9;
return { x: cx + (c.x + gx * c.r) * rx * 0.94, y: cy + (c.y + gy * c.r) * ry * 0.94 };
}
// elliptical — uniform over the disc, slightly centre-weighted
const r = Math.sqrt(rnd()) * 0.94;
const ang = rnd() * Math.PI * 2;
return { x: cx + Math.cos(ang) * r * rx, y: cy + Math.sin(ang) * r * ry };
}
function placeStars(rnd, shape, count, w, h) {
const clusterCenters = [];
if (shape.id === 'cluster') {
const n = shape.clusters ?? 5;
for (let i = 0; i < n; i += 1) {
const ang = (i / n) * Math.PI * 2 + rnd() * 0.5;
const rad = 0.30 + rnd() * 0.45;
clusterCenters.push({ x: Math.cos(ang) * rad, y: Math.sin(ang) * rad, r: 0.16 + rnd() * 0.10 });
}
}
// Target separation from the area each star "owns", relaxed on repeated
// failure so a tight shape can never hang the generator.
const area = w * h;
let minSep = Math.sqrt(area / count) * 0.62;
const pts = [];
let attempts = 0;
const maxAttempts = count * 400;
while (pts.length < count && attempts < maxAttempts) {
attempts += 1;
const p = samplePoint(rnd, shape, w, h, clusterCenters);
if (p.x < 40 || p.y < 40 || p.x > w - 40 || p.y > h - 40) continue;
let ok = true;
for (const q of pts) {
if (Math.hypot(p.x - q.x, p.y - q.y) < minSep) { ok = false; break; }
}
if (ok) pts.push(p);
else if (attempts % (count * 8) === 0) minSep *= 0.93;
}
// Last-resort top-up: relax entirely rather than return a short galaxy.
while (pts.length < count) {
const p = samplePoint(rnd, shape, w, h, clusterCenters);
pts.push({ x: Math.max(40, Math.min(w - 40, p.x)), y: Math.max(40, Math.min(h - 40, p.y)) });
}
// Deterministic ordering regardless of acceptance order.
pts.sort((a, b) => (a.y - b.y) || (a.x - b.x));
return pts;
}
// --------------------------------------------------------------------------
// Planets
function rollPlanets(rnd, rules, starClass) {
if (starClass.special === 'blackhole') return [];
let maxPlanets = 5;
if (starClass.special === 'pulsar') maxPlanets = 2;
else if (starClass.id === 'brown') maxPlanets = 2;
else if (starClass.id === 'red') maxPlanets = 3;
const count = Math.floor(rnd() * (maxPlanets + 1));
const bias = starClass.planetBias ?? 0;
const planets = [];
for (let orbit = 0; orbit < count; orbit += 1) {
// Roughly 40% of slots are uninhabitable scenery — gas giants and belts
// are common in real systems and give the orrery something to draw.
const uninhabitable = rules.planetTypeList.filter((p) => !p.colonizable);
const habitable = rules.colonizableTypes;
let type;
if (rnd() < 0.38 && uninhabitable.length) {
type = uninhabitable[Math.floor(rnd() * uninhabitable.length)];
} else {
// Good worlds are rarer than bad ones; planetBias tilts the whole ladder.
type = weightedPick(rnd, habitable, (p) => {
const base = 14 - 8 * p.habitability;
return base * Math.max(0.02, 1 + 0.5 * bias * (p.habitability - 0.55));
});
}
const size = weightedPick(rnd, rules.planetSizeList, (s) => s.weight);
const rich = weightedPick(rnd, rules.richnessList, (m) => m.weight * (1 + 0.25 * (starClass.richBias ?? 0)));
const grav = weightedPick(rnd, rules.gravityList, (g) => g.weight);
planets.push({
orbit,
typeId: type.id,
sizeId: size.id,
richId: rich.id,
gravId: grav.id,
basePop: Math.round(size.basePop * type.habitability),
// Orrery presentation, generated here so it is stable across reloads.
orbitRadius: 46 + orbit * 30 + Math.floor(rnd() * 12),
orbitAngle: rnd() * Math.PI * 2,
orbitSpeed: (0.30 - orbit * 0.045) * (0.85 + rnd() * 0.3),
});
}
return planets;
}
// --------------------------------------------------------------------------
// Starlanes — Gabriel graph
// An edge (p,q) survives if no third star sits inside the circle whose
// diameter is pq. The Gabriel graph contains the Euclidean minimum spanning
// tree, so the lane network is ALWAYS connected — no repair pass needed, and
// the verifier asserts it.
function buildLanes(stars) {
const lanes = [];
const adj = stars.map(() => []);
for (let i = 0; i < stars.length; i += 1) {
for (let j = i + 1; j < stars.length; j += 1) {
const a = stars[i];
const b = stars[j];
const mx = (a.x + b.x) / 2;
const my = (a.y + b.y) / 2;
const r2 = ((a.x - b.x) ** 2 + (a.y - b.y) ** 2) / 4;
let blocked = false;
for (let k = 0; k < stars.length; k += 1) {
if (k === i || k === j) continue;
const c = stars[k];
if ((c.x - mx) ** 2 + (c.y - my) ** 2 < r2 - 1e-9) { blocked = true; break; }
}
if (blocked) continue;
const d = dist(a, b);
lanes.push({ a: i, b: j, dist: d, parsecs: d / PARSEC_PX });
adj[i].push(j);
adj[j].push(i);
}
}
return { lanes, adj };
}
export function isConnected(stars, adj) {
if (!stars.length) return true;
const seen = new Uint8Array(stars.length);
const stack = [0];
seen[0] = 1;
let n = 1;
while (stack.length) {
const cur = stack.pop();
for (const nb of adj[cur]) {
if (!seen[nb]) { seen[nb] = 1; n += 1; stack.push(nb); }
}
}
return n === stars.length;
}
// --------------------------------------------------------------------------
// Homeworlds
// Farthest-point sampling: seed with the star nearest the galaxy centroid,
// then repeatedly take whichever star is furthest from everything chosen so
// far. Deterministic, and it spreads empires as evenly as the shape allows.
function pickHomeStars(stars, numEmpires) {
const eligible = stars
.map((s, i) => ({ s, i }))
.filter(({ s }) => s.special !== 'blackhole' && s.special !== 'pulsar' && s.planets.length > 0);
const pool = eligible.length >= numEmpires ? eligible : stars.map((s, i) => ({ s, i }));
const cx = stars.reduce((t, s) => t + s.x, 0) / stars.length;
const cy = stars.reduce((t, s) => t + s.y, 0) / stars.length;
let first = pool[0];
let bestD = Infinity;
for (const cand of pool) {
const d = Math.hypot(cand.s.x - cx, cand.s.y - cy);
if (d < bestD) { bestD = d; first = cand; }
}
const chosen = [first];
while (chosen.length < numEmpires) {
let best = null;
let bestScore = -Infinity;
for (const cand of pool) {
if (chosen.some((c) => c.i === cand.i)) continue;
let nearest = Infinity;
for (const c of chosen) nearest = Math.min(nearest, dist(cand.s, c.s));
if (nearest > bestScore) { bestScore = nearest; best = cand; }
}
if (!best) break;
chosen.push(best);
}
return chosen.map((c) => c.i);
}
// Every empire must start on its species' native world, at a size and richness
// that does not decide the game on turn one.
function installHomeworld(rules, star, spec) {
const type = rules.planetTypes[spec.homeworld];
const size = rules.planetSizes.large ?? rules.planetSizeList[rules.planetSizeList.length - 1];
const home = {
orbit: 0,
typeId: type.id,
sizeId: size.id,
richId: 'normal',
gravId: 'normal',
basePop: Math.round(size.basePop * type.habitability),
orbitRadius: 70,
orbitAngle: 0,
orbitSpeed: 0.28,
homeworld: true,
};
// Keep any other planets in the system but push them outward one orbit, so
// the capital always sits in the innermost slot the orrery draws first.
const rest = star.planets.filter((p) => p.orbit !== 0).map((p) => ({ ...p }));
star.planets = [home, ...rest];
for (let i = 0; i < star.planets.length; i += 1) {
star.planets[i].orbit = i;
if (i > 0) star.planets[i].orbitRadius = 70 + i * 30;
}
}
// Fairness pass: guarantee every empire can see at least `want` freely
// settleable worlds inside its opening fuel range. Without this, a start in a
// hostile pocket is simply dead, and the soak test would blame the AI for it.
function guaranteeNearbyWorlds(rules, stars, homeIdx, rangeParsecs, want) {
const upgraded = [];
for (const hi of homeIdx) {
const home = stars[hi];
let open = 0;
const candidates = [];
for (let i = 0; i < stars.length; i += 1) {
if (i === hi) continue;
if (dist(home, stars[i]) / PARSEC_PX > rangeParsecs) continue;
for (const p of stars[i].planets) {
const t = rules.planetTypes[p.typeId];
if (t.colonizable && t.hostility === 0) open += 1;
else candidates.push({ starIdx: i, planet: p, type: t });
}
}
const tundra = rules.planetTypes.tundra ?? rules.colonizableTypes[rules.colonizableTypes.length - 1];
const makeOpen = (planet) => {
planet.typeId = tundra.id;
planet.basePop = Math.round((rules.planetSizes[planet.sizeId]?.basePop ?? 40) * tundra.habitability);
};
let guard = 0;
while (open < want && candidates.length && guard < 40) {
guard += 1;
// Upgrade the least-bad candidate: a hostile world becomes tundra rather
// than a gas giant becoming terran, so the map still reads honestly.
candidates.sort((a, b) => (b.type.habitability - a.type.habitability));
const c = candidates.shift();
makeOpen(c.planet);
upgraded.push({ starIdx: c.starIdx, orbit: c.planet.orbit });
open += 1;
}
// On a large, sparse galaxy a homeworld can have NO star at all inside its
// opening fuel range, and then there is nothing to upgrade — that empire
// simply cannot expand until it researches propulsion, while its rivals
// are already colonising. Seed new worlds instead: first at in-range stars,
// and failing that in the home system itself, so every start is playable.
guard = 0;
while (open < want && guard < 20) {
guard += 1;
const inRange = [];
for (let i = 0; i < stars.length; i += 1) {
if (i === hi) continue;
if (dist(home, stars[i]) / PARSEC_PX <= rangeParsecs) inRange.push(stars[i]);
}
const host = inRange.length
? inRange.reduce((best, s) => (s.planets.length < best.planets.length ? s : best), inRange[0])
: home;
const orbit = host.planets.length;
host.planets.push({
orbit,
typeId: tundra.id,
sizeId: 'medium',
richId: 'normal',
gravId: 'normal',
basePop: Math.round((rules.planetSizes.medium?.basePop ?? 60) * tundra.habitability),
orbitRadius: 70 + orbit * 30,
orbitAngle: 0,
orbitSpeed: 0.2,
seeded: true,
});
upgraded.push({ starIdx: host.idx, orbit, seeded: true });
open += 1;
}
}
return upgraded;
}
// --------------------------------------------------------------------------
export function generateGalaxy(rules, opts) {
const {
sizeId = 'medium',
shapeId = 'spiral',
seed = 1,
speciesIds = ['human'],
} = opts;
const size = rules.galaxySizes[sizeId];
if (!size) throw new Error(`unknown galaxy size ${sizeId}`);
const shape = rules.galaxyShapes[shapeId];
if (!shape) throw new Error(`unknown galaxy shape ${shapeId}`);
if (speciesIds.length > size.maxEmpires) {
throw new Error(`${speciesIds.length} empires exceeds ${sizeId} galaxy max of ${size.maxEmpires}`);
}
const rnd = mulberry32(seed * 2654435761);
const pts = placeStars(rnd, shape, size.stars, size.width, size.height);
// Names are dealt without replacement so no galaxy has two Vegas.
const namePool = rules.starNames.slice();
for (let i = namePool.length - 1; i > 0; i -= 1) {
const j = Math.floor(rnd() * (i + 1));
[namePool[i], namePool[j]] = [namePool[j], namePool[i]];
}
const stars = pts.map((p, i) => {
const cls = weightedPick(rnd, rules.starClassList, (c) => c.weight);
return {
idx: i,
name: namePool[i] ?? `Star ${i + 1}`,
x: Math.round(p.x),
y: Math.round(p.y),
classId: cls.id,
special: cls.special ?? null,
planets: rollPlanets(rnd, rules, cls),
// Binary companions are pure presentation, but they must be stable.
companionAngle: cls.special === 'binary' ? rnd() * Math.PI * 2 : 0,
beamAngle: cls.special === 'pulsar' ? rnd() * Math.PI * 2 : 0,
};
});
const homeIdx = pickHomeStars(stars, speciesIds.length);
// A home system with no planets at all can come out of pickHomeStars' fallback
// path; installHomeworld always seeds orbit 0, so this is safe either way.
speciesIds.forEach((sid, e) => {
installHomeworld(rules, stars[homeIdx[e]], rules.species[sid]);
});
const { lanes, adj } = buildLanes(stars);
const upgraded = guaranteeNearbyWorlds(
rules, stars, homeIdx,
(rules.economy.baseFuelRange ?? 4) + 1.5,
2,
);
return {
sizeId,
shapeId,
seed,
width: size.width,
height: size.height,
stars,
lanes,
adj,
homeIdx,
upgraded,
};
}
// Straight-line distance in parsecs between two systems. Fleets fly direct —
// the lane graph is a readability aid and a range guide, not a rail network.
export function parsecs(galaxy, i, j) {
return dist(galaxy.stars[i], galaxy.stars[j]) / PARSEC_PX;
}
// Rough measure of how good a system is to settle, used by worldgen fairness
// checks and by the AI's expansion scoring.
export function systemQuality(rules, star) {
let q = 0;
for (const p of star.planets) {
const t = rules.planetTypes[p.typeId];
if (!t.colonizable) continue;
const rich = rules.richness[p.richId]?.industryMult ?? 1;
q += p.basePop * rich * (1 - 0.08 * t.hostility);
}
return q;
}

View File

@ -0,0 +1,80 @@
// Master of Vega — leaders (the MOO2 convenience).
//
// A leader is hired once, costs upkeep forever, and does nothing at all until
// posted. Admins run a colony; captains run a fleet. Headless.
import { empireColonies, empireFleets, hireLeader, assignLeader, fleetPower } from './VegaLogic.js';
// Leaders are a shared galactic pool — once an empire hires Nyx Holt, nobody
// else can. That makes the offer worth taking when it appears.
export function leaderTaken(state, leaderId) {
return state.empires.some((e) => e.leaders.some((l) => l.leaderId === leaderId));
}
export function availableLeaders(rules, state) {
return rules.leaderList.filter((l) => !leaderTaken(state, l.id));
}
// Which leaders are on offer to an empire this turn. Offers rotate on a fixed
// cycle derived from the turn and the empire index, so they are stable within a
// turn (the UI can render them repeatedly) without needing to live in state.
export function leaderOffers(rules, state, e, count = 3) {
const pool = availableLeaders(rules, state);
if (!pool.length) return [];
const cycle = Math.floor(state.turn / 10);
const out = [];
for (let i = 0; i < Math.min(count, pool.length); i += 1) {
out.push(pool[(cycle * 7 + e * 3 + i * 5) % pool.length]);
}
// De-duplicate in case the stride collides on a small pool.
return [...new Set(out)];
}
export function leaderOf(rules, state, e, kind, id) {
const emp = state.empires[e];
const l = emp.leaders.find((x) => x.assignKind === kind && x.assignId === id);
return l ? rules.leaders[l.leaderId] : null;
}
export function unassignedLeaders(rules, state, e) {
return state.empires[e].leaders.filter((l) => l.assignKind === null || l.assignId < 0);
}
// AI leader management: hire what is affordable and clearly useful, then post
// admins to the biggest colonies and captains to the strongest fleets.
export function runLeaderTurn(rules, state, e) {
const emp = state.empires[e];
// Never spend the last of the treasury on staff — ships and colonies first.
const budget = emp.bc * 0.35;
for (const offer of leaderOffers(rules, state, e)) {
if (emp.leaders.length >= 6) break;
if (offer.hireCost > budget) continue;
if (hireLeader(rules, state, e, offer.id)) break; // one hire per turn
}
const colonies = empireColonies(state, e).slice().sort((a, b) => b.pop - a.pop);
const fleets = empireFleets(state, e)
.filter((f) => f.starIdx >= 0 || f.toStar >= 0)
.sort((a, b) => fleetPower(rules, state, b) - fleetPower(rules, state, a));
for (const l of emp.leaders) {
const def = rules.leaders[l.leaderId];
if (def.kind === 'admin') {
const posted = new Set(emp.leaders.filter((x) => x !== l && x.assignKind === 'colony').map((x) => x.assignId));
const target = colonies.find((c) => !posted.has(c.id));
if (target && l.assignId !== target.id) assignLeader(rules, state, e, l.leaderId, 'colony', target.id);
} else {
const posted = new Set(emp.leaders.filter((x) => x !== l && x.assignKind === 'fleet').map((x) => x.assignId));
const target = fleets.find((f) => !posted.has(f.id));
if (target && l.assignId !== target.id) assignLeader(rules, state, e, l.leaderId, 'fleet', target.id);
}
}
// A posting can go stale when a colony falls or a fleet is destroyed.
const colonyIds = new Set(colonies.map((c) => c.id));
const fleetIds = new Set(empireFleets(state, e).map((f) => f.id));
for (const l of emp.leaders) {
if (l.assignKind === 'colony' && !colonyIds.has(l.assignId)) { l.assignKind = null; l.assignId = -1; }
if (l.assignKind === 'fleet' && !fleetIds.has(l.assignId)) { l.assignKind = null; l.assignId = -1; }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,174 @@
// Master of Vega — the star map's nebula backdrop.
//
// A fullscreen Phaser Shader GameObject running domain-warped fBm, seeded from
// the galaxy seed so every game has its own sky. Modelled on
// src/games/balatro/BalatroSwirlPipeline.js, including its discipline: the
// Canvas path returns an object with the SAME API, so callers never branch on
// the renderer.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
const FRAG = `
precision mediump float;
uniform float time;
uniform vec2 resolution;
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform vec3 uColorC;
uniform float uSeed;
uniform float uDensity;
uniform float uShape; // 0 = even cloud, 1 = spiral arms, 2 = ring
varying vec2 outTexCoord;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21) + uSeed);
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
float total = 0.0;
float amp = 0.5;
for (int i = 0; i < 5; i++) {
total += vnoise(p) * amp;
p *= 2.02;
amp *= 0.5;
}
return total;
}
void main() {
vec2 uv = outTexCoord;
vec2 p = (uv - 0.5) * vec2(resolution.x / resolution.y, 1.0) * 2.2;
float t = time * 0.012;
// Domain warp: noise displacing the lookup of more noise. This is what turns
// bland cloud into something with filaments and structure.
vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, 1.3) - t * 0.7));
vec2 r = vec2(fbm(p + 4.0 * q + vec2(1.7, 9.2) + t * 0.4),
fbm(p + 4.0 * q + vec2(8.3, 2.8) - t * 0.3));
float f = fbm(p + 4.0 * r);
// The nebula is generated to agree with the galaxy's own topology, so the gas
// sits where the stars do rather than fighting the layout.
float radius = length(p);
float mask = 1.0;
if (uShape > 1.5) {
mask = smoothstep(0.35, 0.95, radius) * (1.0 - smoothstep(1.5, 2.1, radius));
} else if (uShape > 0.5) {
float ang = atan(p.y, p.x);
float arms = cos(ang * 2.0 - radius * 2.6) * 0.5 + 0.5;
mask = mix(0.35, 1.0, arms) * (1.0 - smoothstep(0.7, 2.0, radius));
} else {
mask = 1.0 - smoothstep(0.5, 1.9, radius);
}
float v = clamp(f * 1.5 * uDensity * mask, 0.0, 1.0);
vec3 col = mix(uColorA, uColorB, clamp(f * f * 2.4, 0.0, 1.0));
col = mix(col, uColorC, clamp(length(r) * 0.75, 0.0, 1.0));
col *= v;
// A faint deep-space floor so the screen is never pure black.
col += uColorA * 0.16;
gl_FragColor = vec4(col, 1.0);
}
`;
// Palettes chosen so empire colours and the range overlay stay readable on top.
export const NEBULA_PALETTES = {
violet: [0x141026, 0x5a2f8c, 0x2b6fa8],
ember: [0x1a1010, 0x8c3a2f, 0xc07a2a],
teal: [0x0c1a1e, 0x1f6b78, 0x54c0a8],
rose: [0x1a0f18, 0x8c3060, 0x4a3ba0],
gold: [0x191408, 0x8a6a1e, 0xa83a2a],
};
const PALETTE_ORDER = Object.keys(NEBULA_PALETTES);
const SHAPE_ID = { elliptical: 0, cluster: 0, spiral: 1, ring: 2 };
const vec = (hex) => ({
x: ((hex >> 16) & 255) / 255,
y: ((hex >> 8) & 255) / 255,
z: (hex & 255) / 255,
});
/**
* Build the nebula backdrop into `layer`.
* Returns { setDensity, destroy } on both renderer paths.
*/
export function makeNebula(scene, layer, { seed = 1, shapeId = 'spiral', density = 1 } = {}) {
const paletteName = PALETTE_ORDER[Math.abs(seed) % PALETTE_ORDER.length];
const [a, b, c] = NEBULA_PALETTES[paletteName];
const usingWebGL = scene.renderer && scene.renderer.type === Phaser.WEBGL;
if (usingWebGL) {
const base = new Phaser.Display.BaseShader('vega-nebula', FRAG, undefined, {
uColorA: { type: '3f', value: vec(a) },
uColorB: { type: '3f', value: vec(b) },
uColorC: { type: '3f', value: vec(c) },
uSeed: { type: '1f', value: (Math.abs(seed) % 1000) / 1000 },
uDensity: { type: '1f', value: density },
uShape: { type: '1f', value: SHAPE_ID[shapeId] ?? 0 },
});
const go = scene.add.shader(base, GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT);
layer.add(go);
return {
paletteName,
setDensity(v) { go.setUniform('uDensity.value', v); },
destroy() { go.destroy(); },
};
}
// Canvas fallback: a vertical gradient plus a few huge soft blobs drifting.
// Not the same picture, but the same job — the map is never bare black.
const g = scene.add.graphics();
g.fillGradientStyle(a, a, 0x05060a, 0x05060a, 1);
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
layer.add(g);
const blobs = [];
for (let i = 0; i < 5; i += 1) {
const blob = scene.add.graphics();
blob.fillStyle(i % 2 === 0 ? b : c, 0.10);
blob.fillCircle(0, 0, 260 + i * 90);
blob.setPosition(
(GAME_WIDTH / 6) * (i + 0.5) + (i % 2) * 180,
GAME_HEIGHT * (0.3 + 0.12 * (i % 3)),
);
blob.setBlendMode(Phaser.BlendModes.ADD);
layer.add(blob);
blobs.push(blob);
scene.tweens.add({
targets: blob,
x: blob.x + 120 - i * 40,
y: blob.y + 70,
duration: 18000 + i * 3500,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
return {
paletteName,
setDensity(v) { for (const blob of blobs) blob.setAlpha(0.10 * v); },
destroy() { g.destroy(); for (const blob of blobs) blob.destroy(); },
};
}

View File

@ -0,0 +1,242 @@
// Master of Vega — rules compiler. Pure data module, no Phaser imports, so it
// runs headless in Node (tools/verifyMasterOfVega.js) and in the browser scene.
//
// compileRules(json) validates data/mastervega-rules.json and returns an
// indexed, derived rule set that the engine, the AI and the UI all consume.
export function compileRules(json) {
const errors = [];
const need = (cond, msg) => { if (!cond) errors.push(msg); };
const required = ['techFields', 'techs', 'hulls', 'buildings', 'planetTypes', 'planetSizes',
'mineralRichness', 'gravity', 'starClasses', 'species', 'leaders', 'galaxySizes',
'galaxyShapes', 'difficulties'];
for (const key of required) {
need(Array.isArray(json[key]) && json[key].length > 0, `${key} missing or empty`);
}
need(json.economy && typeof json.economy === 'object', 'economy block missing');
need(json.combat && typeof json.combat === 'object', 'combat block missing');
need(json.council && typeof json.council === 'object', 'council block missing');
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
const byId = (list, label) => {
const map = {};
for (const item of list) {
need(typeof item.id === 'string' && item.id.length > 0, `${label} entry missing id`);
need(!map[item.id], `${label} duplicate id ${item.id}`);
map[item.id] = item;
}
return map;
};
const techFields = byId(json.techFields, 'techField');
const techs = byId(json.techs, 'tech');
const hulls = byId(json.hulls, 'hull');
const buildings = byId(json.buildings, 'building');
const planetTypes = byId(json.planetTypes, 'planetType');
const planetSizes = byId(json.planetSizes, 'planetSize');
const richness = byId(json.mineralRichness, 'mineralRichness');
const gravity = byId(json.gravity, 'gravity');
const starClasses = byId(json.starClasses, 'starClass');
const species = byId(json.species, 'species');
const leaders = byId(json.leaders, 'leader');
const galaxySizes = byId(json.galaxySizes, 'galaxySize');
const galaxyShapes = byId(json.galaxyShapes, 'galaxyShape');
const difficulties = byId(json.difficulties, 'difficulty');
// --- tech graph: each tech belongs to a field, has 0-1 prereqs, and that
// prereq must be in the SAME field. Six independent chains keeps research
// pricing per field honest and makes the tree trivially renderable.
for (const t of json.techs) {
need(!!techFields[t.field], `tech ${t.id} unknown field ${t.field}`);
need(Array.isArray(t.prereqs) && t.prereqs.length <= 1, `tech ${t.id} needs 0 or 1 prereqs`);
for (const p of t.prereqs) {
need(!!techs[p], `tech ${t.id} prereq ${p} unknown`);
if (techs[p]) need(techs[p].field === t.field, `tech ${t.id} prereq ${p} is in a different field`);
}
need(typeof t.cost === 'number' && t.cost > 0, `tech ${t.id} needs a positive cost`);
need(Number.isInteger(t.tier) && t.tier >= 0, `tech ${t.id} bad tier`);
need(typeof t.name === 'string' && t.name.length > 0, `tech ${t.id} missing name`);
need(typeof t.desc === 'string' && t.desc.length > 0, `tech ${t.id} missing desc`);
need(Number.isInteger(t.iconFrame) && t.iconFrame >= 0, `tech ${t.id} bad iconFrame`);
}
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
// Topological rank; also proves acyclicity and full reachability. An
// unrankable tech means a cycle or a dangling prereq.
const rank = {};
let assigned = 0;
let progress = true;
while (progress) {
progress = false;
for (const t of json.techs) {
if (rank[t.id] !== undefined) continue;
if (t.prereqs.every((p) => rank[p] !== undefined)) {
rank[t.id] = t.prereqs.length ? 1 + Math.max(...t.prereqs.map((p) => rank[p])) : 0;
assigned += 1;
progress = true;
}
}
}
need(assigned === json.techs.length,
`tech graph has a cycle or unreachable techs (${json.techs.length - assigned} unranked)`);
// tier is authored by hand and read by the UI as a column index; if it ever
// disagrees with the computed rank the tree renders in the wrong order.
for (const t of json.techs) {
if (rank[t.id] !== undefined) need(rank[t.id] === t.tier, `tech ${t.id} tier ${t.tier} != rank ${rank[t.id]}`);
}
// Cost must climb along each chain, or a later tech would be cheaper than the
// prereq that unlocks it.
for (const t of json.techs) {
for (const p of t.prereqs) {
if (techs[p]) need(t.cost > techs[p].cost, `tech ${t.id} costs no more than its prereq ${p}`);
}
}
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
// --- cross references
for (const b of json.buildings) {
if (b.prereq) need(!!techs[b.prereq], `building ${b.id} prereq tech ${b.prereq} unknown`);
need(typeof b.cost === 'number' && b.cost > 0, `building ${b.id} needs a positive cost`);
need(typeof b.upkeep === 'number' && b.upkeep >= 0, `building ${b.id} bad upkeep`);
if (b.channel !== null && b.channel !== undefined) {
need(json.economy.channels.includes(b.channel), `building ${b.id} unknown channel ${b.channel}`);
need(typeof b.mult === 'number' && b.mult > 0, `building ${b.id} needs a positive mult`);
}
}
for (const h of json.hulls) {
need(['recon', 'colony', 'troops', 'warship', 'base'].includes(h.role), `hull ${h.id} bad role`);
need(typeof h.baseCost === 'number' && h.baseCost > 0, `hull ${h.id} bad baseCost`);
need(typeof h.baseHp === 'number' && h.baseHp > 0, `hull ${h.id} bad baseHp`);
need(Number.isInteger(h.space) && h.space >= 0, `hull ${h.id} bad space`);
if (h.role === 'warship' || h.role === 'base') need(h.space > 0, `hull ${h.id} is armed but has no space`);
else need(h.space === 0, `hull ${h.id} is unarmed but has weapon space`);
}
for (const p of json.planetTypes) {
need(typeof p.habitability === 'number' && p.habitability >= 0, `planetType ${p.id} bad habitability`);
need(Number.isInteger(p.hostility) && p.hostility >= 0, `planetType ${p.id} bad hostility`);
if (p.colonizable) need(p.habitability > 0, `planetType ${p.id} is colonizable but uninhabitable`);
else need(p.habitability === 0, `planetType ${p.id} is not colonizable but has habitability`);
}
for (const s of json.species) {
need(!!planetTypes[s.homeworld], `species ${s.id} homeworld ${s.homeworld} unknown`);
if (planetTypes[s.homeworld]) {
need(planetTypes[s.homeworld].colonizable, `species ${s.id} homeworld ${s.homeworld} is not colonizable`);
// A species whose homeworld is hostile must be able to actually live
// there on turn one, or its start is unplayable.
if (planetTypes[s.homeworld].hostility > 0) {
need(s.traits.colonizeAnything === true || s.traits.hostileImmune === true,
`species ${s.id} starts on hostile ${s.homeworld} without hostileImmune/colonizeAnything`);
}
}
for (const f of Object.keys(techFields)) {
need(typeof s.techAffinity?.[f] === 'number' && s.techAffinity[f] > 0,
`species ${s.id} missing techAffinity for ${f}`);
}
for (const k of ['industryMult', 'researchMult', 'tradeMult', 'growthMult', 'maxPopMult']) {
need(typeof s.traits?.[k] === 'number' && s.traits[k] > 0, `species ${s.id} bad trait ${k}`);
}
need(typeof s.traits?.ecologyMult === 'number' && s.traits.ecologyMult >= 0, `species ${s.id} bad ecologyMult`);
need(Number.isInteger(s.traits?.factoriesPerPop) && s.traits.factoriesPerPop > 0,
`species ${s.id} bad factoriesPerPop`);
need(typeof s.color === 'string' && /^#[0-9a-f]{6}$/i.test(s.color), `species ${s.id} bad color`);
need(Array.isArray(s.strengths) && s.strengths.length > 0, `species ${s.id} needs strengths`);
need(Array.isArray(s.weaknesses) && s.weaknesses.length > 0, `species ${s.id} needs weaknesses`);
}
for (const l of json.leaders) {
need(['admin', 'captain'].includes(l.kind), `leader ${l.id} bad kind`);
need(typeof l.hireCost === 'number' && l.hireCost > 0, `leader ${l.id} bad hireCost`);
need(l.skills && Object.keys(l.skills).length > 0, `leader ${l.id} has no skills`);
}
for (const g of json.galaxySizes) {
need(Number.isInteger(g.stars) && g.stars > 0, `galaxySize ${g.id} bad stars`);
need(Number.isInteger(g.maxEmpires) && g.maxEmpires >= 2, `galaxySize ${g.id} bad maxEmpires`);
// Every empire needs a homeworld plus room to expand into.
need(g.stars >= g.maxEmpires * 3, `galaxySize ${g.id} has too few stars for ${g.maxEmpires} empires`);
}
for (const d of json.difficulties) {
for (const k of ['aiProdMult', 'aiResearchMult', 'aiAggression', 'humanResearchMult']) {
need(typeof d[k] === 'number' && d[k] > 0, `difficulty ${d.id} bad ${k}`);
}
need(Number.isInteger(d.aiStartBonus) && d.aiStartBonus >= 0, `difficulty ${d.id} bad aiStartBonus`);
}
need(json.species.length >= 2, 'need at least two species');
need(json.starNames.length >= Math.max(...json.galaxySizes.map((g) => g.stars)),
'starNames must cover the largest galaxy');
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
// --- derived indexes
// Techs grouped by field, ordered by tier — the research screen renders these
// columns directly, and the AI walks them to find "the next thing".
const techsByField = {};
for (const f of Object.keys(techFields)) techsByField[f] = [];
for (const t of json.techs) techsByField[t.field].push(t);
for (const f of Object.keys(techsByField)) techsByField[f].sort((a, b) => a.tier - b.tier);
// What each tech unlocks — powers UI hovers and the "every tech matters"
// verify check.
const gates = {};
for (const id of Object.keys(techs)) gates[id] = { buildings: [], prereqOf: [], effects: [] };
for (const b of json.buildings) if (b.prereq) gates[b.prereq].buildings.push(b.id);
for (const t of json.techs) for (const p of t.prereqs) gates[p].prereqOf.push(t.id);
for (const t of json.techs) gates[t.id].effects = Object.keys(t.effects ?? {});
// Colonizable planet types sorted by how hostile they are, so worldgen and
// the AI can both ask "what is the best thing I could settle here".
const colonizableTypes = json.planetTypes.filter((p) => p.colonizable);
const weightedPick = (list) => {
const total = list.reduce((s, x) => s + (x.weight ?? 1), 0);
return { list, total };
};
return {
version: json.version ?? 1,
raw: json,
techFields, techs, hulls, buildings, planetTypes, planetSizes, richness, gravity,
starClasses, species, leaders, galaxySizes, galaxyShapes, difficulties,
techFieldList: json.techFields,
techList: json.techs,
hullList: json.hulls,
buildingList: json.buildings,
planetTypeList: json.planetTypes,
planetSizeList: json.planetSizes,
richnessList: json.mineralRichness,
gravityList: json.gravity,
starClassList: json.starClasses,
speciesList: json.species,
leaderList: json.leaders,
galaxySizeList: json.galaxySizes,
galaxyShapeList: json.galaxyShapes,
difficultyList: json.difficulties,
techRank: rank,
techsByField,
techGates: gates,
colonizableTypes,
sizeWeights: weightedPick(json.planetSizes),
richWeights: weightedPick(json.mineralRichness),
gravityWeights: weightedPick(json.gravity),
starWeights: weightedPick(json.starClasses),
economy: json.economy,
combat: json.combat,
council: json.council,
victory: json.victory ?? { conquest: true, council: true, turnCap: 800 },
starNames: json.starNames,
};
}
// Research cost for a tech, scaled by how many techs the empire already knows
// in that field. MOO-style: each field gets more expensive as you climb it, so
// spreading research wide is cheaper than driving one field to the end.
export function techCost(rules, tech, knownInField, researchFactor = 1) {
const drag = 1 + 0.05 * Math.max(0, knownInField - tech.tier);
return Math.round(tech.cost * drag * researchFactor);
}
// Turn -> in-fiction year. MOO starts in 2300 and runs a year per turn.
export function turnToYear(turn) { return 2300 + turn; }
// Mark I..VII in Roman, for ship class names.
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
export function markNumeral(mark) { return ROMAN[mark] ?? String(mark); }

View File

@ -0,0 +1,435 @@
// Master of Vega — modal screens and the shared sci-fi chrome they are built
// in. There is no repo-wide panel component (Civilization and Total Annihilation
// both roll their own), so `modalShell` is this game's version: a holographic
// frame with corner ticks, matching the clean bridge-console look rather than
// the arcade CRT treatment.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { techCost } from './VegaRules.js';
import { empireColonies, setResearchAlloc, nextResearchTarget, hireLeader } from './VegaLogic.js';
import {
attitudeOf, moodOf, powerOf, declareWar, proposeTreaty, canNegotiate,
} from './VegaDiplomacy.js';
import { leaderOffers } from './VegaLeaders.js';
import { speciesPortraitFrame } from './VegaArt.js';
export const FONT = '"Julius Sans One"';
export const D = { map: 1, hud: 30, modal: 60, toast: 80 };
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
/** The holographic frame every modal is built inside. */
export function modalShell(scene, title, onClose, { width = 1220, height = 800 } = {}) {
const layer = scene.add.container(0, 0).setDepth(D.modal);
const veil = scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000914, 0.72)
.setOrigin(0, 0).setInteractive();
layer.add(veil);
const x = (GAME_WIDTH - width) / 2;
const y = (GAME_HEIGHT - height) / 2;
const panel = scene.add.rectangle(x, y, width, height, PANEL, 0.96).setOrigin(0, 0);
panel.setStrokeStyle(1.5, ACCENT, 0.55);
layer.add(panel);
// Corner ticks — cheap, and they do most of the work of making a rectangle
// read as a HUD element rather than a dialog box.
const ticks = scene.add.graphics();
ticks.lineStyle(2.5, ACCENT, 0.9);
const t = 26;
for (const [cx, cy, dx, dy] of [
[x, y, 1, 1], [x + width, y, -1, 1], [x, y + height, 1, -1], [x + width, y + height, -1, -1],
]) {
ticks.lineBetween(cx, cy, cx + dx * t, cy);
ticks.lineBetween(cx, cy, cx, cy + dy * t);
}
layer.add(ticks);
const head = scene.add.text(x + 30, y + 22, title.toUpperCase(), {
fontFamily: FONT, fontSize: '30px', color: '#cfe8ff',
});
layer.add(head);
const rule = scene.add.rectangle(x + 30, y + 64, width - 60, 1, ACCENT, 0.4).setOrigin(0, 0);
layer.add(rule);
const close = new Button(scene, x + width - 70, y + 40, '✕', () => {
layer.destroy();
onClose?.();
}, { width: 46, height: 40, variant: 'ghost' });
layer.add(close);
return {
layer, x, y, width, height,
body: { x: x + 30, y: y + 84, w: width - 60, h: height - 120 },
add: (obj) => layer.add(obj),
destroy: () => layer.destroy(),
};
}
/** A labelled horizontal slider. Returns { container, setValue }. */
export function slider(scene, x, y, w, label, value, onChange, colour = ACCENT) {
const c = scene.add.container(x, y);
const text = scene.add.text(0, 0, label, { fontFamily: FONT, fontSize: '17px', color: '#a8c4e0' });
c.add(text);
const pct = scene.add.text(w, 0, `${Math.round(value * 100)}%`, {
fontFamily: FONT, fontSize: '17px', color: '#e8f4ff',
}).setOrigin(1, 0);
c.add(pct);
const trackY = 28;
const track = scene.add.rectangle(0, trackY, w, 8, 0x1b2b42).setOrigin(0, 0.5);
c.add(track);
const fill = scene.add.rectangle(0, trackY, w * value, 8, colour).setOrigin(0, 0.5);
c.add(fill);
const knob = scene.add.circle(w * value, trackY, 9, 0xe8f4ff);
c.add(knob);
// The hit zone is a plain rectangle placed by its own top-left, NOT the
// container — a Container's origin is locked at 0.5 and its hit area is
// always centred, which is the classic way to get an unclickable widget here.
const zone = scene.add.rectangle(0, trackY, w, 30, 0xffffff, 0.001)
.setOrigin(0, 0.5).setInteractive({ useHandCursor: true, draggable: true });
c.add(zone);
// Modal shells are containers sitting at the origin, so the slider
// container's own x IS its world x and pointer maths needs no walk up the
// parent chain.
const apply = (px) => {
const v = Phaser.Math.Clamp(px / w, 0, 1);
fill.width = w * v;
knob.x = w * v;
pct.setText(`${Math.round(v * 100)}%`);
onChange?.(v);
};
zone.on('pointerdown', (p) => apply(p.x - c.x));
scene.input.setDraggable(zone);
zone.on('drag', (p) => apply(p.x - c.x));
return {
container: c,
setValue(v) {
fill.width = w * v;
knob.x = w * v;
pct.setText(`${Math.round(v * 100)}%`);
},
};
}
// --------------------------------------------------------------------------
export function openResearchScreen(scene, rules, state, e, art, onClose) {
const shell = modalShell(scene, 'Research', onClose, { width: 1500, height: 880 });
const emp = state.empires[e];
const fields = rules.techFieldList;
const colW = (shell.body.w - 40) / fields.length;
const sliders = [];
fields.forEach((field, i) => {
const cx = shell.body.x + i * colW;
const head = scene.add.text(cx, shell.body.y, field.name.toUpperCase(), {
fontFamily: FONT, fontSize: '19px', color: '#cfe8ff',
});
shell.add(head);
const s = slider(scene, cx, shell.body.y + 30, colW - 26, 'Allocation', emp.alloc[field.id] ?? 0, (v) => {
setResearchAlloc(rules, state, e, field.id, v);
// Every other field's share shifts, so redraw them all.
sliders.forEach((other, j) => {
if (j !== i) other.setValue(emp.alloc[fields[j].id] ?? 0);
});
});
shell.add(s.container);
sliders.push(s);
// Current target and its progress.
const targetId = emp.researching[field.id] ?? nextResearchTarget(rules, state, e, field.id);
let y = shell.body.y + 84;
if (targetId) {
const tech = rules.techs[targetId];
const cost = techCost(rules, tech, emp.knownInField[field.id]);
const have = emp.beakers[field.id] ?? 0;
const t = scene.add.text(cx, y, tech.name, {
fontFamily: FONT, fontSize: '17px', color: '#ffd88a', wordWrap: { width: colW - 26 },
});
shell.add(t);
y += t.height + 6;
const bar = scene.add.rectangle(cx, y, colW - 26, 6, 0x1b2b42).setOrigin(0, 0);
shell.add(bar);
const prog = scene.add.rectangle(cx, y, (colW - 26) * Phaser.Math.Clamp(have / cost, 0, 1), 6, 0xffd88a).setOrigin(0, 0);
shell.add(prog);
y += 14;
const eta = scene.add.text(cx, y, `${Math.round(have)} / ${cost} RP`, {
fontFamily: FONT, fontSize: '14px', color: '#7f97b3',
});
shell.add(eta);
y += 26;
} else {
const t = scene.add.text(cx, y, 'Nothing further available', {
fontFamily: FONT, fontSize: '15px', color: '#6b7f96', wordWrap: { width: colW - 26 },
});
shell.add(t);
y += 34;
}
// The field's ladder: known, available, or locked out by the start-of-game
// availability roll (which is the thing that makes tech trading matter).
for (const tech of rules.techsByField[field.id]) {
const known = !!emp.known[tech.id];
const avail = !!emp.available[tech.id];
const colour = known ? '#7fd8a0' : (avail ? '#9fb6cc' : '#5a4450');
const mark = known ? '■' : (avail ? '□' : '✕');
const row = scene.add.text(cx, y, `${mark} ${tech.name}`, {
fontFamily: FONT, fontSize: '14px', color: colour, wordWrap: { width: colW - 26 },
});
shell.add(row);
y += row.height + 3;
}
});
const legend = scene.add.text(shell.body.x, shell.y + shell.height - 34,
'■ researched □ available ✕ not available to your species — acquire by trade, espionage or conquest', {
fontFamily: FONT, fontSize: '14px', color: '#6b7f96',
});
shell.add(legend);
return shell;
}
// --------------------------------------------------------------------------
export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged) {
const shell = modalShell(scene, 'Diplomacy', onClose, { width: 1280, height: 780 });
const emp = state.empires[e];
const others = state.empires.filter((o) => o.alive && o.idx !== e && emp.contacted[o.idx]);
if (!others.length) {
shell.add(scene.add.text(shell.body.x, shell.body.y, 'You have not yet met another empire.', {
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
}));
return shell;
}
const rowH = Math.min(150, shell.body.h / others.length);
others.forEach((other, i) => {
const y = shell.body.y + i * rowH;
const spec = rules.species[other.speciesId];
const portrait = scene.add.image(shell.body.x + 52, y + rowH / 2, art.portraits,
speciesPortraitFrame(rules, other.speciesId)).setDisplaySize(96, 96);
shell.add(portrait);
const att = attitudeOf(state, other.idx, e);
const treaty = emp.treaties[other.idx] ?? 'none';
const name = scene.add.text(shell.body.x + 120, y + 18, `${spec.name}`, {
fontFamily: FONT, fontSize: '24px', color: other.color,
});
shell.add(name);
const status = scene.add.text(shell.body.x + 120, y + 52,
`${treaty === 'war' ? 'AT WAR' : treaty.toUpperCase()} · attitude ${att} (${moodOf(att)}) · `
+ `${empireColonies(state, other.idx).length} colonies · power ${Math.round(powerOf(rules, state, other.idx))}`, {
fontFamily: FONT, fontSize: '16px', color: '#9fb6cc',
});
shell.add(status);
const bio = scene.add.text(shell.body.x + 120, y + 78, spec.desc, {
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', wordWrap: { width: 620 },
});
shell.add(bio);
const bx = shell.body.x + shell.body.w - 150;
if (treaty === 'war') {
const b = new Button(scene, bx, y + 40, 'Sue for peace', () => {
proposeTreaty(rules, state, e, other.idx, 'peace');
shell.destroy();
openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged);
onChanged?.();
}, { width: 200, height: 40 });
shell.add(b);
} else {
const b = new Button(scene, bx, y + 24, 'Declare war', () => {
declareWar(rules, state, e, other.idx);
shell.destroy();
openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged);
onChanged?.();
}, { width: 200, height: 38, bg: 0x6b2230 });
shell.add(b);
if (canNegotiate(rules, state, e, other.idx) && treaty !== 'alliance') {
const a = new Button(scene, bx, y + 70, 'Propose alliance', () => {
proposeTreaty(rules, state, e, other.idx, 'alliance');
shell.destroy();
openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged);
onChanged?.();
}, { width: 200, height: 38 });
shell.add(a);
}
}
});
return shell;
}
// --------------------------------------------------------------------------
export function openCouncilScreen(scene, rules, state, onClose) {
const shell = modalShell(scene, 'Galactic Council', onClose, { width: 900, height: 620 });
const r = state.council.lastResult;
if (!r) {
shell.add(scene.add.text(shell.body.x, shell.body.y, 'The Council has not yet convened.', {
fontFamily: FONT, fontSize: '20px', color: '#8fa8c0',
}));
return shell;
}
let y = shell.body.y;
const need = Math.ceil(r.totalPop * rules.council.winFraction);
shell.add(scene.add.text(shell.body.x, y,
`Session of ${2300 + r.turn}${need} votes of ${Math.round(r.totalPop)} needed`, {
fontFamily: FONT, fontSize: '18px', color: '#9fb6cc',
}));
y += 46;
for (const idx of r.candidates ?? []) {
const emp = state.empires[idx];
const v = r.votes[idx] ?? 0;
shell.add(scene.add.text(shell.body.x, y, emp.name, {
fontFamily: FONT, fontSize: '24px', color: emp.color,
}));
const bar = scene.add.rectangle(shell.body.x, y + 34, shell.body.w, 16, 0x1b2b42).setOrigin(0, 0);
shell.add(bar);
const fill = scene.add.rectangle(shell.body.x, y + 34,
shell.body.w * Phaser.Math.Clamp(v / Math.max(1, r.totalPop), 0, 1), 16,
Phaser.Display.Color.HexStringToColor(emp.color).color).setOrigin(0, 0);
shell.add(fill);
shell.add(scene.add.text(shell.body.x + shell.body.w, y + 4, `${Math.round(v)}`, {
fontFamily: FONT, fontSize: '20px', color: '#e8f4ff',
}).setOrigin(1, 0));
y += 76;
}
shell.add(scene.add.text(shell.body.x, y, `Abstained: ${Math.round(r.abstained)}`, {
fontFamily: FONT, fontSize: '17px', color: '#7f97b3',
}));
y += 40;
const verdict = r.winner >= 0
? `${state.empires[r.winner].name} is elected High Guardian of the Galaxy.`
: (r.refused
? 'The defeated candidate REFUSES TO SUBMIT. The election is void, and the matter will be settled by war.'
: 'No candidate reached the required majority. The Council adjourns.');
shell.add(scene.add.text(shell.body.x, y, verdict, {
fontFamily: FONT, fontSize: '19px', color: r.winner >= 0 ? '#ffd88a' : '#e08a8a',
wordWrap: { width: shell.body.w },
}));
return shell;
}
// --------------------------------------------------------------------------
export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged) {
const shell = modalShell(scene, 'Leaders', onClose, { width: 1180, height: 760 });
const emp = state.empires[e];
shell.add(scene.add.text(shell.body.x, shell.body.y, 'AVAILABLE FOR HIRE', {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));
let y = shell.body.y + 34;
for (const offer of leaderOffers(rules, state, e)) {
const affordable = emp.bc >= offer.hireCost;
shell.add(scene.add.image(shell.body.x + 32, y + 30, art.leaders, offer.portraitFrame)
.setDisplaySize(60, 60));
shell.add(scene.add.text(shell.body.x + 76, y + 4, `${offer.name}${offer.kind}`, {
fontFamily: FONT, fontSize: '19px', color: '#e8f4ff',
}));
shell.add(scene.add.text(shell.body.x + 76, y + 30, offer.bio, {
fontFamily: FONT, fontSize: '14px', color: '#7f97b3', wordWrap: { width: 640 },
}));
shell.add(scene.add.text(shell.body.x + 76, y + 50,
Object.entries(offer.skills).map(([k, v]) => `${k} ${v}`).join(' · '), {
fontFamily: FONT, fontSize: '13px', color: '#7fd8a0',
}));
const b = new Button(scene, shell.body.x + shell.body.w - 130, y + 30,
`Hire ${offer.hireCost} BC`, () => {
if (hireLeader(rules, state, e, offer.id)) {
shell.destroy();
openLeaderScreen(scene, rules, state, e, art, onClose, onChanged);
onChanged?.();
}
}, { width: 190, height: 38, variant: affordable ? 'solid' : 'ghost' });
shell.add(b);
y += 84;
}
y += 20;
shell.add(scene.add.text(shell.body.x, y, 'IN YOUR SERVICE', {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));
y += 34;
if (!emp.leaders.length) {
shell.add(scene.add.text(shell.body.x, y, 'None. Leaders take a posting before they do anything.', {
fontFamily: FONT, fontSize: '16px', color: '#6f8aa3',
}));
}
for (const l of emp.leaders) {
const def = rules.leaders[l.leaderId];
const posting = l.assignKind === 'colony'
? (empireColonies(state, e).find((c) => c.id === l.assignId)?.starIdx ?? -1)
: -1;
const where = l.assignKind === 'colony' && posting >= 0
? state.galaxy.stars[posting].name
: (l.assignKind === 'fleet' ? `Fleet ${l.assignId}` : 'unassigned');
shell.add(scene.add.image(shell.body.x + 24, y + 18, art.leaders, def.portraitFrame)
.setDisplaySize(40, 40));
shell.add(scene.add.text(shell.body.x + 56, y + 6,
`${def.name}${def.kind}${where}${def.upkeep} BC/turn`, {
fontFamily: FONT, fontSize: '16px', color: '#c8dcf0',
}));
y += 44;
}
return shell;
}
// --------------------------------------------------------------------------
export function showVictoryOverlay(scene, rules, state, onClose) {
const layer = scene.add.container(0, 0).setDepth(D.toast);
layer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.9)
.setOrigin(0, 0).setInteractive());
const winner = state.winnerIdx >= 0 ? state.empires[state.winnerIdx] : null;
const human = state.humanIndex >= 0 ? state.empires[state.humanIndex] : null;
const won = winner && human && winner.idx === human.idx;
const title = won ? 'VICTORY' : 'DEFEAT';
layer.add(scene.add.text(GAME_WIDTH / 2, 300, title, {
fontFamily: FONT, fontSize: '92px', color: won ? '#ffd88a' : '#e08a8a',
}).setOrigin(0.5));
const kind = {
conquest: 'by conquest — the galaxy holds no rival',
council: 'by acclamation of the Galactic Council',
timeout: 'by dominance when the age ended',
}[state.victoryKind] ?? '';
layer.add(scene.add.text(GAME_WIDTH / 2, 400,
winner ? `${winner.name} ${kind}` : 'The galaxy is empty.', {
fontFamily: FONT, fontSize: '28px', color: '#cfe8ff',
}).setOrigin(0.5));
layer.add(scene.add.text(GAME_WIDTH / 2, 470,
`Year ${2300 + state.turn}`, {
fontFamily: FONT, fontSize: '22px', color: '#7f97b3',
}).setOrigin(0.5));
// Buttons in this repo draw centred, so they are positioned by their centre.
const b = new Button(scene, GAME_WIDTH / 2, 560, 'Return to menu', () => {
layer.destroy();
onClose?.();
}, { width: 220, height: 52 });
layer.add(b);
return layer;
}

View File

@ -0,0 +1,296 @@
// Master of Vega — preset ship classes and the Mark auto-refit.
//
// There is no ship designer. Instead every hull has a MARK that folds in the
// best weapons, armour, shields, engines and battle computers the owner has
// researched, so the tech tree still visibly changes the fleet. A Cruiser Mark
// II and a Cruiser Mark VI are the same hull with six generations between them.
//
// Headless: no Phaser, no game state. Everything here is a pure function of
// (rules, known tech set, species traits, leader skills).
import { markNumeral } from './VegaRules.js';
// The five fields that put hardware on a ship. Planetology is deliberately
// absent — it changes where you can live, not what you fly.
export const COMPONENT_FIELDS = ['weapons', 'construction', 'forcefields', 'propulsion', 'computers'];
export const MAX_MARK = 7;
// Highest tier the empire has reached in each field. The chains are linear, so
// "best tier" is all we ever need to know.
export function fieldTiers(rules, known) {
const tiers = {};
for (const f of Object.keys(rules.techFields)) {
let best = -1;
for (const t of rules.techsByField[f]) {
if (known[t.id] && t.tier > best) best = t.tier;
}
tiers[f] = best;
}
return tiers;
}
// Mark I..VII from the average component tier. Tiers run 0..9 across ten techs
// per field, so a fully-teched empire lands exactly on Mark VII.
export function markFor(rules, known) {
const tiers = fieldTiers(rules, known);
let sum = 0;
for (const f of COMPONENT_FIELDS) sum += Math.max(0, tiers[f]);
const avg = sum / COMPONENT_FIELDS.length;
return Math.max(1, Math.min(MAX_MARK, 1 + Math.floor(avg * 0.7)));
}
// Walk every known tech and keep the best of each component kind. Later techs
// in a chain always supersede earlier ones, so a plain tier comparison is
// enough — no need to model obsolescence explicitly.
export function bestComponents(rules, known) {
const out = {
weapon: null,
secondWeapon: null,
armor: { name: 'Titanium', hpMult: 1 },
shield: 0,
engine: { name: 'Chemical', speed: 0 },
fuelRange: rules.economy.baseFuelRange ?? 4,
targeting: 0,
initiative: 0,
repairPerRound: 0,
refitCostMult: 1,
espionage: 0,
counterEspionage: 0,
planetaryShield: 0,
colonizeHostility: 0,
maxPopBonus: 0,
wasteMult: 1,
factoryCostMult: 1,
researchMult: 1,
scanRange: 0,
cloaked: false,
singularity: false,
redirectInFlight: false,
planetCracker: false,
};
const weapons = [];
for (const t of rules.techList) {
if (!known[t.id]) continue;
const e = t.effects ?? {};
if (e.weapon) weapons.push(e.weapon);
if (e.armor && e.armor.hpMult > out.armor.hpMult) out.armor = e.armor;
if (typeof e.shield === 'number' && e.shield > out.shield) out.shield = e.shield;
if (e.engine && e.engine.speed > out.engine.speed) out.engine = e.engine;
if (typeof e.fuelRange === 'number' && e.fuelRange > out.fuelRange) out.fuelRange = e.fuelRange;
if (typeof e.targeting === 'number' && e.targeting > out.targeting) out.targeting = e.targeting;
if (typeof e.initiative === 'number') out.initiative += e.initiative;
if (typeof e.repairPerRound === 'number' && e.repairPerRound > out.repairPerRound) out.repairPerRound = e.repairPerRound;
if (typeof e.refitCostMult === 'number' && e.refitCostMult < out.refitCostMult) out.refitCostMult = e.refitCostMult;
if (typeof e.espionage === 'number') out.espionage += e.espionage;
if (typeof e.counterEspionage === 'number') out.counterEspionage += e.counterEspionage;
if (typeof e.planetaryShield === 'number' && e.planetaryShield > out.planetaryShield) out.planetaryShield = e.planetaryShield;
if (typeof e.colonizeHostility === 'number' && e.colonizeHostility > out.colonizeHostility) out.colonizeHostility = e.colonizeHostility;
if (typeof e.maxPopBonus === 'number') out.maxPopBonus += e.maxPopBonus;
if (typeof e.wasteMult === 'number' && e.wasteMult < out.wasteMult) out.wasteMult = e.wasteMult;
if (typeof e.factoryCostMult === 'number' && e.factoryCostMult < out.factoryCostMult) out.factoryCostMult = e.factoryCostMult;
if (typeof e.researchMult === 'number' && e.researchMult > out.researchMult) out.researchMult = e.researchMult;
if (typeof e.scanRange === 'number') out.scanRange += e.scanRange;
if (e.cloaked) out.cloaked = true;
if (e.singularity) out.singularity = true;
if (e.redirectInFlight) out.redirectInFlight = true;
if (e.planetCracker) out.planetCracker = true;
}
// Beams fire every round forever; missiles fire `shots` salvoes for the whole
// battle and then the racks are empty. They are ranked separately because
// they are not competing for the same job — see fillMounts.
const avg = (w) => (w.min + w.max) / 2;
out.beams = weapons.filter((w) => w.kind === 'beam').sort((a, b) => avg(b) / b.space - avg(a) / a.space);
out.missiles = weapons.filter((w) => w.kind === 'missile').sort((a, b) => (avg(b) * b.shots) / b.space - (avg(a) * a.shots) / a.space);
out.weapon = out.beams[0] ?? out.missiles[0] ?? null;
out.allWeapons = weapons;
return out;
}
// How much damage a weapon contributes across one battle. Beams keep firing
// every round once the fleets close; missiles empty their racks and stop. That
// difference is the whole reason both weapon lines exist, and it is the only
// place the two are made commensurable.
function battleValue(weapon, beamRounds) {
const avg = (weapon.min + weapon.max) / 2;
return weapon.kind === 'missile' ? avg * weapon.shots : avg * beamRounds;
}
// Unbounded knapsack: the most battle damage obtainable from `space` tonnage
// using `weapons`. Returns [spaceUsed, Map(weaponIndex -> count)].
//
// Because researching a weapon only ever ADDS to the candidate set, the optimum
// over the larger set is never worse than over the smaller one — so damage is
// provably monotonic in tech within a fixed budget. A greedy "best
// damage-per-space first" fill has no such property: it mounts one oversized
// gun and strands the leftover tonnage.
function knapsack(space, weapons, beamRounds) {
if (space <= 0 || !weapons.length) return [0, new Map()];
const best = new Float64Array(space + 1);
const pick = new Int32Array(space + 1).fill(-1);
for (let s = 1; s <= space; s += 1) {
best[s] = best[s - 1];
pick[s] = -1;
for (let w = 0; w < weapons.length; w += 1) {
if (weapons[w].space > s) continue;
const cand = best[s - weapons[w].space] + battleValue(weapons[w], beamRounds);
if (cand > best[s] + 1e-9) { best[s] = cand; pick[s] = w; }
}
}
const counts = new Map();
let s = space;
let used = 0;
let guard = 0;
while (s > 0 && guard < space + 2) {
guard += 1;
const w = pick[s];
if (w < 0) { s -= 1; continue; }
counts.set(weapons[w], (counts.get(weapons[w]) ?? 0) + 1);
used += weapons[w].space;
s -= weapons[w].space;
}
return [used, counts];
}
// Fraction of a warship's tonnage reserved for missiles. This is a design rule,
// not an optimisation, and it exists to prevent a specific failure: at some tech
// tiers missiles genuinely score better damage-per-space than beams, so a pure
// knapsack builds an ALL-missile ship. That ship empties its racks in five
// rounds and then sits there unarmed until the round cap — every such battle
// stalemates. Capping missiles guarantees every warship can still fight on
// round forty.
const MISSILE_SHARE = 0.4;
const MIN_SPLIT_SPACE = 10;
function fillMounts(space, comps, beamRounds) {
if (space <= 0 || !comps.allWeapons.length) return [];
const beams = comps.beams;
const missiles = comps.missiles;
const counts = new Map();
const merge = (m) => { for (const [w, c] of m) counts.set(w, (counts.get(w) ?? 0) + c); };
// Small hulls have no room to split — a frigate carries one gun and that gun
// had better still work late in the fight.
const split = space >= MIN_SPLIT_SPACE && beams.length > 0 && missiles.length > 0;
if (!split) {
const [, m] = knapsack(space, beams.length ? beams : comps.allWeapons, beamRounds);
merge(m);
} else {
const missileBudget = Math.floor(space * MISSILE_SHARE);
const [mUsed, mCounts] = knapsack(missileBudget, missiles, beamRounds);
merge(mCounts);
const [, bCounts] = knapsack(space - mUsed, beams, beamRounds);
merge(bCounts);
}
// Stable order (largest weapon first) so a design always renders and
// serialises identically.
return [...counts.entries()]
.map(([weapon, count]) => ({ weapon, count }))
.sort((a, b) => b.weapon.space - a.weapon.space || a.weapon.id.localeCompare(b.weapon.id));
}
const sumSkills = (skills, key) => (skills?.[key] ?? 0);
// The full derived stat block for one hull at the owner's current tech.
// `traits` is the species trait bag; `skills` is an optional merged leader
// skill bag (fleet captains for warships, nothing for civilian hulls).
export function designFor(rules, known, hullId, traits = {}, skills = {}) {
const hull = rules.hulls[hullId];
if (!hull) throw new Error(`unknown hull ${hullId}`);
const comps = bestComponents(rules, known);
const mark = markFor(rules, known);
const beamRounds = rules.combat.beamRounds ?? 5;
const mounts = fillMounts(hull.space, comps, beamRounds);
const hp = Math.round(hull.baseHp * comps.armor.hpMult);
const weaponCost = mounts.reduce((t, m) => t + m.count * m.weapon.cost, 0);
const cost = Math.round(
hull.baseCost * (1 + 0.15 * (mark - 1))
+ weaponCost
+ comps.shield * 4
+ comps.engine.speed * 3,
);
const immobile = !!hull.immobile;
const speed = immobile ? 0 : comps.engine.speed + (hull.speedBonus ?? 0) + sumSkills(skills, 'speedBonus');
const range = immobile ? 0 : comps.fuelRange + (hull.rangeBonus ?? 0) + sumSkills(skills, 'rangeBonus');
const attack = (traits.shipAttack ?? 0) + sumSkills(skills, 'shipAttack');
const defense = (traits.shipDefense ?? 0) + sumSkills(skills, 'shipDefense');
// Beams are sustained damage per round; missiles are a fixed rack of salvoes.
// `damage` is the whole-battle total the knapsack optimised, expressed per
// round so it reads as a rate next to beamDamage.
const avg = (w) => (w.min + w.max) / 2;
const beamDamage = mounts
.filter((m) => m.weapon.kind === 'beam')
.reduce((t, m) => t + m.count * avg(m.weapon), 0);
const missileSalvo = mounts
.filter((m) => m.weapon.kind === 'missile')
.reduce((t, m) => t + m.count * avg(m.weapon), 0);
const missileSalvos = mounts
.filter((m) => m.weapon.kind === 'missile')
.reduce((t, m) => Math.max(t, m.weapon.shots), 0);
const damage = mounts.reduce((t, m) => t + m.count * battleValue(m.weapon, beamRounds), 0) / beamRounds;
return {
hullId,
hull,
mark,
name: hull.space > 0 || hull.role === 'base'
? `${hull.name} Mark ${markNumeral(mark)}`
: hull.name,
role: hull.role,
cost: Math.max(1, cost),
hp,
shield: comps.shield,
speed,
range,
attack,
defense,
targeting: comps.targeting,
initiative: comps.initiative + speed + sumSkills(skills, 'initiative'),
repairPerRound: Math.max(comps.repairPerRound, sumSkills(skills, 'repairPerRound')),
mounts,
damage,
beamDamage,
missileSalvo,
missileSalvos,
armorName: comps.armor.name,
engineName: comps.engine.name,
immobile,
troops: hull.troops ?? 0,
planetCracker: comps.planetCracker && mounts.some((m) => m.weapon.planetCracker),
cloaked: comps.cloaked,
};
}
// Every hull the empire can currently build, in the order the build list shows
// them. A hull with no weapon tech yet still builds — it is simply unarmed.
export function availableDesigns(rules, known, traits = {}, skills = {}) {
return rules.hullList.map((h) => designFor(rules, known, h.id, traits, skills));
}
// A single scalar for "how dangerous is this stack" — used by the AI to decide
// whether to attack, and by the star map to size a fleet marker. Deliberately
// crude: hp times damage, so neither a paper battleship nor a toothless brick
// scores well.
export function stackPower(design, count) {
if (design.role !== 'warship' && design.role !== 'base') return 0;
const off = design.damage * (1 + 0.06 * design.targeting) * (1 + 0.004 * design.attack);
const def = (design.hp + design.shield * 8) * (1 + 0.004 * design.defense);
return Math.round(count * Math.sqrt(Math.max(1, off) * Math.max(1, def)));
}
// Cost to bring an existing stack up to the current Mark. Charged from the
// empire reserve when a fleet sits over a friendly colony; if it cannot be
// paid the ships simply stay at their old Mark.
export function refitCost(rules, known, hullId, fromMark, traits = {}) {
const now = designFor(rules, known, hullId, traits);
if (fromMark >= now.mark) return 0;
const comps = bestComponents(rules, known);
const steps = now.mark - fromMark;
return Math.max(1, Math.round(now.cost * 0.18 * steps * comps.refitCostMult));
}

View File

@ -0,0 +1,534 @@
// Master of Vega — the star map.
//
// Layer order (each one a SEPARATE root container, because a Phaser Container
// renders its children in insertion order and ignores their depth — putting
// these in one container and setting .depth would silently do nothing):
//
// nebula -> parallax starfield -> territory field -> starlanes
// -> range darkness -> stars -> fleets -> labels
//
// Everything from `territory` down lives inside `this.root`, which is the one
// object that gets scaled and moved for pan and zoom. The nebula and the
// parallax layers are screen-space and move at their own rates.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { makeNebula } from './VegaNebula.js';
import { PARSEC_PX, parsecs, mulberry32 } from './VegaGalaxyGen.js';
import { reachableStars, coloniesAt, empireColonies, fleetEta } from './VegaLogic.js';
import { starFrame } from './VegaArt.js';
const FONT = '"Julius Sans One"';
export const ZOOMS = [0.35, 0.55, 0.85, 1.3, 2.0];
export const DEFAULT_ZOOM_INDEX = 2;
// Semantic-zoom thresholds. Below FAR the map shows territory and empire names
// only; above NEAR it shows per-system detail.
const FAR = 0.5;
const NEAR = 1.2;
// The range and territory fields are painted into low-resolution
// RenderTextures and scaled up. A huge galaxy is 5400px wide — past the safe
// single-texture size — and the upscale blur is exactly the soft edge both
// effects want anyway, so the low resolution is a feature, not a compromise.
const FIELD_DIV = 6;
function ensureSoftDisc(scene, key, size) {
if (scene.textures.exists(key)) return key;
const tex = scene.textures.createCanvas(key, size, size);
const ctx = tex.getContext();
const g = ctx.createRadialGradient(size / 2, size / 2, size * 0.04, size / 2, size / 2, size / 2);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.55, 'rgba(255,255,255,0.85)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
tex.refresh();
return key;
}
export default class VegaStarMap {
constructor(scene, rules, state, artKeys, callbacks = {}) {
this.scene = scene;
this.rules = rules;
this.state = state;
this.art = artKeys;
this.cb = callbacks;
this.viewerIdx = state.humanIndex;
this.zoomIndex = DEFAULT_ZOOM_INDEX;
this.zoom = ZOOMS[this.zoomIndex];
this.selectedStar = -1;
this.hoverStar = -1;
this.rangeDirty = true;
this.territoryDirty = true;
this.time = 0;
const galaxy = state.galaxy;
this.worldW = galaxy.width;
this.worldH = galaxy.height;
ensureSoftDisc(scene, 'vega-soft-disc', 256);
// --- screen-space backdrop
this.bgLayer = scene.add.container(0, 0).setDepth(0);
this.nebula = makeNebula(scene, this.bgLayer, {
seed: galaxy.seed, shapeId: galaxy.shapeId, density: 1,
});
this.starfield = scene.add.container(0, 0).setDepth(1);
this.parallax = [];
this.buildParallax(galaxy.seed);
// --- world-space
this.root = scene.add.container(0, 0).setDepth(2);
this.fieldW = Math.max(2, Math.ceil(this.worldW / FIELD_DIV));
this.fieldH = Math.max(2, Math.ceil(this.worldH / FIELD_DIV));
this.territoryRT = scene.add.renderTexture(0, 0, this.fieldW, this.fieldH)
.setOrigin(0, 0).setScale(FIELD_DIV).setAlpha(0.5)
.setBlendMode(Phaser.BlendModes.ADD);
this.root.add(this.territoryRT);
this.laneGfx = scene.add.graphics();
this.root.add(this.laneGfx);
this.rangeRT = scene.add.renderTexture(0, 0, this.fieldW, this.fieldH)
.setOrigin(0, 0).setScale(FIELD_DIV);
this.root.add(this.rangeRT);
this.starLayer = scene.add.container(0, 0);
this.root.add(this.starLayer);
this.fleetLayer = scene.add.container(0, 0);
this.root.add(this.fleetLayer);
this.labelLayer = scene.add.container(0, 0);
this.root.add(this.labelLayer);
this.buildStars();
this.drawLanes();
this.refresh();
this.centerOn(state.galaxy.homeIdx[Math.max(0, this.viewerIdx)] ?? 0);
this.bindInput();
}
// ------------------------------------------------------------------ setup
buildParallax(seed) {
const rnd = mulberry32(seed * 7919 + 13);
// Four layers at different rates. The nearest layer is sparse and bright,
// the far ones dense and dim, which is what sells depth on a pan.
const layers = [
{ count: 260, rate: 0.08, size: 1.0, alpha: 0.35 },
{ count: 180, rate: 0.16, size: 1.4, alpha: 0.5 },
{ count: 110, rate: 0.28, size: 1.9, alpha: 0.7 },
{ count: 45, rate: 0.44, size: 2.6, alpha: 0.9 },
];
for (const spec of layers) {
const g = this.scene.add.graphics();
const stars = [];
for (let i = 0; i < spec.count; i += 1) {
const x = rnd() * GAME_WIDTH * 1.6 - GAME_WIDTH * 0.3;
const y = rnd() * GAME_HEIGHT * 1.6 - GAME_HEIGHT * 0.3;
const tone = 0.65 + rnd() * 0.35;
stars.push({ x, y, tone });
}
for (const s of stars) {
const c = Math.round(255 * s.tone);
g.fillStyle((c << 16) | (c << 8) | 255, spec.alpha);
g.fillCircle(s.x, s.y, spec.size);
}
this.starfield.add(g);
this.parallax.push({ g, rate: spec.rate, baseX: 0, baseY: 0 });
}
}
buildStars() {
const { rules, state, scene } = this;
this.starSprites = [];
for (const star of state.galaxy.stars) {
const cls = rules.starClasses[star.classId];
const container = scene.add.container(star.x, star.y);
const frame = Math.max(0, starFrame(rules, star.classId));
const body = scene.add.image(0, 0, this.art.stars, frame);
const scale = (cls.radius * 5.5) / 192;
body.setScale(scale);
body.setBlendMode(cls.special === 'blackhole' ? Phaser.BlendModes.NORMAL : Phaser.BlendModes.ADD);
container.add(body);
// Binary companion and pulsar beam are animated in update().
let companion = null;
if (cls.special === 'binary') {
companion = scene.add.image(0, 0, this.art.stars, frame);
companion.setScale(scale * 0.55).setBlendMode(Phaser.BlendModes.ADD);
container.add(companion);
}
// Ownership ring, drawn only when the system is settled.
const ring = scene.add.graphics();
container.add(ring);
// Generous hit area — these are small targets at low zoom.
const hit = scene.add.circle(0, 0, 30, 0xffffff, 0.001).setInteractive({ useHandCursor: true });
hit.on('pointerover', () => { this.hoverStar = star.idx; this.cb.onStarHover?.(star.idx); });
hit.on('pointerout', () => { if (this.hoverStar === star.idx) this.hoverStar = -1; this.cb.onStarHover?.(-1); });
hit.on('pointerup', (p) => { if (!this.dragged) this.cb.onStarClick?.(star.idx, p); });
container.add(hit);
this.starLayer.add(container);
this.starSprites.push({ star, container, body, companion, ring, cls, phase: star.companionAngle });
}
}
drawLanes() {
const g = this.laneGfx;
g.clear();
for (const lane of this.state.galaxy.lanes) {
const a = this.state.galaxy.stars[lane.a];
const b = this.state.galaxy.stars[lane.b];
// Long lanes fade out — they are geometrically real but visually noise.
const alpha = Math.max(0.05, 0.3 - lane.parsecs * 0.012);
g.lineStyle(1.5, 0x4b6fa8, alpha);
g.lineBetween(a.x, a.y, b.x, b.y);
}
}
// ------------------------------------------------------------- the fields
// "Range as light": the galaxy is covered in darkness, and everything inside
// fuel range is erased back out of it. Every propulsion tech literally lights
// up more of the map, which is the clearest progression signal a 4X can give.
// A scratch image reused for every stamp. RenderTexture.erase()/draw() honour
// a game object's scale and tint, but NOT a bare texture key's — a key is
// always stamped at its native 256px. Everything here needs a radius that
// varies, so it all goes through this one object.
stamp(scale, tint = null, alpha = 1) {
if (!this._stamp) {
this._stamp = this.scene.make.image({ key: 'vega-soft-disc', add: false }).setOrigin(0.5, 0.5);
}
this._stamp.setScale(scale).setAlpha(alpha);
if (tint === null) this._stamp.clearTint();
else this._stamp.setTint(tint);
return this._stamp;
}
redrawRange() {
this.rangeDirty = false;
const rt = this.rangeRT;
rt.clear();
if (this.viewerIdx < 0) return; // observer mode sees the whole galaxy
rt.fill(0x04050b, 0.8);
const emp = this.state.empires[this.viewerIdx];
if (!emp) return;
const reach = reachableStars(this.rules, this.state, this.viewerIdx);
// One soft disc per reachable star, wide enough that neighbouring discs
// overlap — otherwise the lit region reads as a string of beads instead of
// one continuous sphere of influence.
const radius = PARSEC_PX * 2.6;
const img = this.stamp((radius * 2) / 256 / FIELD_DIV);
for (const key of Object.keys(reach)) {
const star = this.state.galaxy.stars[Number(key)];
rt.erase(img, star.x / FIELD_DIV, star.y / FIELD_DIV);
}
}
// Soft empire colour fields instead of hard borders.
redrawTerritory() {
this.territoryDirty = false;
const rt = this.territoryRT;
rt.clear();
const viewer = this.viewerIdx >= 0 ? this.state.empires[this.viewerIdx] : null;
for (const colony of this.state.colonies) {
const emp = this.state.empires[colony.empireIdx];
if (!emp) continue;
if (viewer && !viewer.explored[colony.starIdx]) continue;
const star = this.state.galaxy.stars[colony.starIdx];
// Bigger colonies project further, so a homeworld anchors a region and an
// outpost only tints its own system.
const spread = Phaser.Math.Clamp(0.7 + colony.pop / 90, 0.7, 2.4);
const radius = PARSEC_PX * 1.8 * spread;
const img = this.stamp((radius * 2) / 256 / FIELD_DIV,
Phaser.Display.Color.HexStringToColor(emp.color).color, 0.85);
rt.draw(img, star.x / FIELD_DIV, star.y / FIELD_DIV);
}
}
// -------------------------------------------------------------- rendering
refresh() {
this.redrawRange();
this.redrawTerritory();
this.refreshStars();
this.refreshFleets();
this.refreshLabels();
}
refreshStars() {
const { state, rules } = this;
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
for (const s of this.starSprites) {
const explored = !viewer || viewer.explored[s.star.idx];
s.container.setVisible(explored || this.zoom < FAR);
s.body.setAlpha(explored ? 1 : 0.25);
const cols = coloniesAt(state, s.star.idx);
s.ring.clear();
if (!cols.length || !explored) continue;
const owner = state.empires[cols[0].empireIdx];
const colour = Phaser.Display.Color.HexStringToColor(owner.color).color;
s.ring.lineStyle(2.5, colour, 0.95);
s.ring.strokeCircle(0, 0, s.cls.radius * 2.2 + 8);
if (cols.some((c) => c.capital)) {
s.ring.lineStyle(1.5, colour, 0.6);
s.ring.strokeCircle(0, 0, s.cls.radius * 2.2 + 14);
}
}
}
refreshFleets() {
this.fleetLayer.removeAll(true);
const { state, rules } = this;
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
this.fleetMarkers = [];
for (const fleet of state.fleets) {
const emp = state.empires[fleet.empireIdx];
if (!emp) continue;
const own = fleet.empireIdx === this.viewerIdx;
let x;
let y;
if (fleet.starIdx >= 0) {
const star = state.galaxy.stars[fleet.starIdx];
if (viewer && !own && !viewer.explored[fleet.starIdx]) continue;
x = star.x + 26;
y = star.y - 22;
} else {
const a = state.galaxy.stars[fleet.fromStar];
const b = state.galaxy.stars[fleet.toStar];
if (!a || !b) continue;
if (viewer && !own && !viewer.explored[fleet.toStar]) continue;
const t = fleet.total > 0 ? Phaser.Math.Clamp(fleet.progress / fleet.total, 0, 1) : 0;
x = a.x + (b.x - a.x) * t;
y = a.y + (b.y - a.y) * t;
}
const colour = Phaser.Display.Color.HexStringToColor(emp.color).color;
const c = this.scene.add.container(x, y);
// Fleets in transit render as a comet: a bright head with a trail back
// along the lane, plus tick marks for the turns still to run.
if (fleet.starIdx < 0) {
const a = state.galaxy.stars[fleet.fromStar];
const b = state.galaxy.stars[fleet.toStar];
const ang = Math.atan2(b.y - a.y, b.x - a.x);
const trail = this.scene.add.graphics();
for (let i = 1; i <= 8; i += 1) {
trail.fillStyle(colour, 0.42 * (1 - i / 9));
trail.fillCircle(-Math.cos(ang) * i * 7, -Math.sin(ang) * i * 7, 5 - i * 0.45);
}
c.add(trail);
if (own) {
const eta = fleetEta(rules, state, fleet);
const label = this.scene.add.text(0, -20, `${Number.isFinite(eta) ? eta : '—'}`, {
fontFamily: FONT, fontSize: '15px', color: '#cfe3ff',
}).setOrigin(0.5);
c.add(label);
}
}
const head = this.scene.add.graphics();
head.fillStyle(colour, 1);
head.fillCircle(0, 0, 6);
head.lineStyle(1.5, 0xffffff, 0.75);
head.strokeCircle(0, 0, 6);
c.add(head);
const hit = this.scene.add.circle(0, 0, 16, 0xffffff, 0.001).setInteractive({ useHandCursor: true });
hit.on('pointerup', () => { if (!this.dragged) this.cb.onFleetClick?.(fleet); });
hit.on('pointerover', () => this.cb.onFleetHover?.(fleet));
hit.on('pointerout', () => this.cb.onFleetHover?.(null));
c.add(hit);
this.fleetLayer.add(c);
this.fleetMarkers.push({ fleet, container: c });
}
}
refreshLabels() {
this.labelLayer.removeAll(true);
const { state } = this;
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
// At the widest zoom the map shows empire names over their territory
// instead of a fog of unreadable star labels.
if (this.zoom < FAR) {
for (const emp of state.empires) {
if (!emp.alive) continue;
const cols = empireColonies(state, emp.idx);
if (!cols.length) continue;
if (viewer && emp.idx !== viewer.idx && !viewer.contacted[emp.idx]) continue;
const cx = cols.reduce((t, c) => t + state.galaxy.stars[c.starIdx].x, 0) / cols.length;
const cy = cols.reduce((t, c) => t + state.galaxy.stars[c.starIdx].y, 0) / cols.length;
const t = this.scene.add.text(cx, cy, emp.name.toUpperCase(), {
fontFamily: FONT, fontSize: `${Math.round(46 / this.zoom)}px`, color: emp.color,
}).setOrigin(0.5).setAlpha(0.55);
this.labelLayer.add(t);
}
return;
}
for (const s of this.starSprites) {
if (viewer && !viewer.explored[s.star.idx]) continue;
const cols = coloniesAt(state, s.star.idx);
const owner = cols.length ? state.empires[cols[0].empireIdx] : null;
const label = this.scene.add.text(s.star.x, s.star.y + s.cls.radius * 2.2 + 14, s.star.name, {
fontFamily: FONT, fontSize: `${Math.round(17 / Math.max(0.7, this.zoom))}px`,
color: owner ? owner.color : '#9fb3cc',
}).setOrigin(0.5, 0);
this.labelLayer.add(label);
// Closest zoom adds the system's contents.
if (this.zoom >= NEAR) {
const habitable = s.star.planets.filter((p) => this.rules.planetTypes[p.typeId].colonizable).length;
if (s.star.planets.length) {
const sub = this.scene.add.text(
s.star.x, s.star.y + s.cls.radius * 2.2 + 32,
`${s.star.planets.length} worlds · ${habitable} habitable`,
{ fontFamily: FONT, fontSize: '13px', color: '#6f8199' },
).setOrigin(0.5, 0);
this.labelLayer.add(sub);
}
}
}
}
// ------------------------------------------------------------- animation
update(_time, delta) {
this.time += delta;
const t = this.time / 1000;
for (const s of this.starSprites) {
if (s.cls.special === 'pulsar') {
s.body.setRotation(t * 1.4);
} else if (s.cls.special === 'binary' && s.companion) {
const a = s.phase + t * 0.7;
const r = s.cls.radius * 1.9;
s.companion.setPosition(Math.cos(a) * r, Math.sin(a) * r * 0.55);
s.body.setPosition(-Math.cos(a) * r * 0.35, -Math.sin(a) * r * 0.2);
} else if (s.cls.special === 'blackhole') {
s.body.setRotation(-t * 0.35);
} else {
// A slow breath so the map is never completely static.
s.body.setAlpha(0.88 + Math.sin(t * 1.2 + s.star.idx) * 0.12);
}
}
// Parallax follows the camera, each layer at its own rate.
for (const layer of this.parallax) {
layer.g.setPosition(this.root.x * layer.rate, this.root.y * layer.rate);
}
}
// ---------------------------------------------------------------- camera
clampPan() {
const w = this.worldW * this.zoom;
const h = this.worldH * this.zoom;
const slack = 160;
const minX = Math.min(slack, GAME_WIDTH - w - slack);
const maxX = Math.max(GAME_WIDTH - w - slack, slack);
const minY = Math.min(slack, GAME_HEIGHT - h - slack);
const maxY = Math.max(GAME_HEIGHT - h - slack, slack);
this.root.x = Phaser.Math.Clamp(this.root.x, Math.min(minX, maxX), Math.max(minX, maxX));
this.root.y = Phaser.Math.Clamp(this.root.y, Math.min(minY, maxY), Math.max(minY, maxY));
}
applyZoom(newIndex, focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 2) {
const idx = Phaser.Math.Clamp(newIndex, 0, ZOOMS.length - 1);
if (idx === this.zoomIndex) return;
const old = this.zoom;
const next = ZOOMS[idx];
// Keep whatever is under the cursor under the cursor.
const worldX = (focusX - this.root.x) / old;
const worldY = (focusY - this.root.y) / old;
this.zoomIndex = idx;
this.zoom = next;
this.root.setScale(next);
this.root.x = focusX - worldX * next;
this.root.y = focusY - worldY * next;
this.clampPan();
this.refreshLabels();
this.refreshStars();
this.cb.onZoom?.(next);
}
centerOn(starIdx) {
const star = this.state.galaxy.stars[starIdx];
if (!star) return;
this.root.setScale(this.zoom);
this.root.x = GAME_WIDTH / 2 - star.x * this.zoom;
this.root.y = GAME_HEIGHT / 2 - star.y * this.zoom;
this.clampPan();
}
panToStar(starIdx, duration = 420) {
const star = this.state.galaxy.stars[starIdx];
if (!star) return;
const targetX = GAME_WIDTH / 2 - star.x * this.zoom;
const targetY = GAME_HEIGHT / 2 - star.y * this.zoom;
this.scene.tweens.add({
targets: this.root,
x: targetX,
y: targetY,
duration,
ease: 'Sine.easeInOut',
onComplete: () => this.clampPan(),
});
}
bindInput() {
const scene = this.scene;
this.dragged = false;
let dragging = false;
let startX = 0;
let startY = 0;
let originX = 0;
let originY = 0;
scene.input.on('pointerdown', (p) => {
dragging = true;
this.dragged = false;
startX = p.x; startY = p.y;
originX = this.root.x; originY = this.root.y;
});
scene.input.on('pointermove', (p) => {
if (!dragging) return;
const dx = p.x - startX;
const dy = p.y - startY;
// An 8px threshold so a slightly shaky click still selects a star.
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) this.dragged = true;
if (!this.dragged) return;
this.root.x = originX + dx;
this.root.y = originY + dy;
this.clampPan();
});
scene.input.on('pointerup', () => { dragging = false; });
scene.input.on('wheel', (p, _objs, _dx, dy) => {
if (this.cb.blockWheel?.(p)) return;
this.applyZoom(this.zoomIndex + (dy > 0 ? -1 : 1), p.x, p.y);
});
}
setViewer(idx) { this.viewerIdx = idx; this.rangeDirty = true; this.refresh(); }
destroy() {
this.nebula?.destroy();
this.bgLayer.destroy();
this.starfield.destroy();
this.root.destroy();
}
}

View File

@ -0,0 +1,263 @@
// Master of Vega — the system view: a live orrery on the left, and the colony
// console on the right when the selected world is yours.
//
// The orrery is where the closest semantic-zoom step lands, and it is the
// drop-in point for planet artwork: every body is a frame on the `planets`
// sheet, procedural until real art arrives.
import * as Phaser from 'phaser';
import { Button } from '../../ui/Button.js';
import { modalShell, slider, FONT } from './VegaScreens.js';
import { planetFrame, starFrame } from './VegaArt.js';
import {
CHANNELS, coloniesAt, canColonize, colonize, colonyMaxPop, colonyProduction,
colonyFactoryCap, effectiveFactories, colonyDefenseCap, setSlider, enqueue, dequeue,
queueItemCost, empireDesign, invasionForecast, invade, bombard, atWar,
} from './VegaLogic.js';
const CHANNEL_COLOUR = {
ships: 0x6fc4ff, defense: 0xe08a8a, industry: 0xffd88a, ecology: 0x7fd8a0, research: 0xb89cff,
};
export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
const { viewerIdx = state.humanIndex, onChanged = null, onClose = null } = opts;
const star = state.galaxy.stars[starIdx];
const shell = modalShell(scene, star.name, onClose, { width: 1620, height: 900 });
const cls = rules.starClasses[star.classId];
const orreryCX = shell.body.x + 380;
const orreryCY = shell.body.y + 380;
// --- star at the centre of the orrery
const starImg = scene.add.image(orreryCX, orreryCY, art.stars, Math.max(0, starFrame(rules, star.classId)))
.setDisplaySize(cls.radius * 9, cls.radius * 9)
.setBlendMode(cls.special === 'blackhole' ? Phaser.BlendModes.NORMAL : Phaser.BlendModes.ADD);
shell.add(starImg);
shell.add(scene.add.text(shell.body.x, shell.body.y, `${cls.name} star — ${cls.desc}`, {
fontFamily: FONT, fontSize: '16px', color: '#7f97b3', wordWrap: { width: 700 },
}));
// --- orbits and planets
const bodies = [];
const orbitGfx = scene.add.graphics();
shell.add(orbitGfx);
let selected = null;
star.planets.forEach((planet, i) => {
const type = rules.planetTypes[planet.typeId];
// Orbit ellipses are stroked every tick alongside the bodies themselves.
const img = scene.add.image(0, 0, art.planets, planetFrame(rules, planet.typeId))
.setDisplaySize(34 + (rules.planetSizes[planet.sizeId]?.basePop ?? 40) * 0.18,
34 + (rules.planetSizes[planet.sizeId]?.basePop ?? 40) * 0.18)
.setInteractive({ useHandCursor: true });
img.on('pointerup', () => select(i));
shell.add(img);
bodies.push({ planet, img, type, angle: planet.orbitAngle, radius: planet.orbitRadius * 1.35 });
});
// --- right-hand console
const panelX = shell.body.x + 780;
const panelW = shell.body.w - 780;
let console_ = scene.add.container(0, 0);
shell.add(console_);
function rebuild() {
console_.destroy();
console_ = scene.add.container(0, 0);
shell.add(console_);
if (selected === null) {
console_.add(scene.add.text(panelX, shell.body.y + 20,
'Select a world.', { fontFamily: FONT, fontSize: '20px', color: '#7f97b3' }));
return;
}
const planet = star.planets[selected];
const type = rules.planetTypes[planet.typeId];
const colony = coloniesAt(state, starIdx).find((c) => c.orbit === selected);
let y = shell.body.y + 10;
console_.add(scene.add.text(panelX, y, `${star.name} ${['I', 'II', 'III', 'IV', 'V', 'VI'][selected] ?? selected + 1}`, {
fontFamily: FONT, fontSize: '28px', color: '#cfe8ff',
}));
y += 40;
console_.add(scene.add.text(panelX, y,
`${type.name} · ${rules.planetSizes[planet.sizeId]?.name ?? ''} · `
+ `${rules.richness[planet.richId]?.name ?? ''} · ${rules.gravity[planet.gravId]?.name ?? ''}`, {
fontFamily: FONT, fontSize: '16px', color: '#9fb6cc',
}));
y += 26;
console_.add(scene.add.text(panelX, y, type.desc, {
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3', wordWrap: { width: panelW },
}));
y += 44;
if (!colony) {
const settleable = viewerIdx >= 0 && canColonize(rules, state, viewerIdx, starIdx, selected);
const hasShip = state.fleets.some((f) => f.starIdx === starIdx && f.empireIdx === viewerIdx
&& f.ships.some((s) => s.hullId === 'colonyship' && s.count > 0));
console_.add(scene.add.text(panelX, y,
settleable ? (hasShip ? 'A colony ship is in orbit.' : 'Settleable — bring a colony ship.')
: `Uninhabitable at your current planetology.`, {
fontFamily: FONT, fontSize: '16px', color: settleable ? '#7fd8a0' : '#e08a8a',
}));
y += 40;
if (settleable && hasShip) {
console_.add(new Button(scene, panelX + 130, y + 20, 'Found colony', () => {
if (colonize(rules, state, viewerIdx, starIdx, selected)) { onChanged?.(); rebuild(); }
}, { width: 260, height: 44 }));
}
return;
}
const owner = state.empires[colony.empireIdx];
const mine = colony.empireIdx === viewerIdx;
console_.add(scene.add.text(panelX, y, `${owner.name} colony`, {
fontFamily: FONT, fontSize: '19px', color: owner.color,
}));
y += 32;
const maxPop = colonyMaxPop(rules, state, colony);
const prod = colonyProduction(rules, state, colony);
console_.add(scene.add.text(panelX, y,
`Population ${colony.pop.toFixed(1)} / ${maxPop}\n`
+ `Factories ${Math.floor(effectiveFactories(rules, state, colony))} / ${colonyFactoryCap(rules, state, colony)}\n`
+ `Output ${prod.toFixed(1)} BC Defences ${Math.round(colony.defenseHp)} / ${colonyDefenseCap(rules, state, colony)}`
+ (colony.waste > 0.5 ? `\nUncleaned waste ${colony.waste.toFixed(1)}` : ''), {
fontFamily: FONT, fontSize: '16px', color: '#c8dcf0', lineSpacing: 4,
}));
y += 96;
if (!mine) {
// Enemy colony: offer the two things a fleet in orbit can actually do.
if (viewerIdx >= 0 && atWar(state, viewerIdx, colony.empireIdx)) {
const forecast = invasionForecast(rules, state, viewerIdx, starIdx);
console_.add(scene.add.text(panelX, y, forecast && forecast.troops > 0
? `${forecast.troops} marines vs ~${forecast.defenders} defenders — ${Math.round(forecast.odds * 100)}% per exchange`
: 'No troop transports in orbit.', {
fontFamily: FONT, fontSize: '15px', color: forecast?.favourable ? '#7fd8a0' : '#e0b08a',
wordWrap: { width: panelW },
}));
y += 44;
console_.add(new Button(scene, panelX + 120, y + 20, 'Bombard', () => {
bombard(rules, state, viewerIdx, starIdx); onChanged?.(); rebuild();
}, { width: 220, height: 42, bg: 0x6b2230 }));
if (forecast && forecast.troops > 0) {
console_.add(new Button(scene, panelX + 370, y + 20, 'Invade', () => {
invade(rules, state, viewerIdx, starIdx); onChanged?.(); rebuild();
}, { width: 220, height: 42 }));
}
}
return;
}
// --- our colony: the five MOO1 allocation sliders
console_.add(scene.add.text(panelX, y, 'ALLOCATION', {
fontFamily: FONT, fontSize: '17px', color: '#cfe8ff',
}));
y += 28;
const sliders = [];
CHANNELS.forEach((ch, i) => {
const s = slider(scene, panelX, y, panelW - 40,
rules.economy.channelNames[ch] ?? ch, colony.sliders[ch] ?? 0, (v) => {
setSlider(rules, state, colony, ch, v);
sliders.forEach((other, j) => {
if (j !== i) other.setValue(colony.sliders[CHANNELS[j]] ?? 0);
});
onChanged?.();
}, CHANNEL_COLOUR[ch]);
console_.add(s.container);
sliders.push(s);
y += 52;
});
console_.add(scene.add.text(panelX, y,
'Ecology is funded first — a shortfall is taken from the other channels automatically.', {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3', wordWrap: { width: panelW - 40 },
}));
y += 34;
// --- build queue
console_.add(scene.add.text(panelX, y, 'BUILD QUEUE', {
fontFamily: FONT, fontSize: '17px', color: '#cfe8ff',
}));
y += 26;
if (!colony.queue.length) {
console_.add(scene.add.text(panelX, y, 'Idle — output spills into research.', {
fontFamily: FONT, fontSize: '14px', color: '#6f8aa3',
}));
y += 24;
}
colony.queue.forEach((item, i) => {
const cost = queueItemCost(rules, state, colony, item);
const name = item.kind === 'building'
? rules.buildings[item.id].name
: empireDesign(rules, state, colony.empireIdx, item.id).name;
const row = scene.add.text(panelX, y, `${name} ${Math.round(item.progress)}/${Math.round(cost)}`, {
fontFamily: FONT, fontSize: '15px', color: '#c8dcf0',
}).setInteractive({ useHandCursor: true });
row.on('pointerup', () => { dequeue(rules, state, colony, i); onChanged?.(); rebuild(); });
console_.add(row);
y += 22;
});
y += 12;
// --- what can be added
console_.add(scene.add.text(panelX, y, 'ADD TO QUEUE (click to queue)', {
fontFamily: FONT, fontSize: '15px', color: '#8fa8c0',
}));
y += 24;
const emp = state.empires[colony.empireIdx];
let cx = panelX;
for (const hull of rules.hullList) {
const d = empireDesign(rules, state, colony.empireIdx, hull.id);
const t = scene.add.text(cx, y, `${d.name} (${d.cost})`, {
fontFamily: FONT, fontSize: '13px', color: '#9fd8ff',
}).setInteractive({ useHandCursor: true });
t.on('pointerup', () => { enqueue(rules, state, colony, 'ship', hull.id); onChanged?.(); rebuild(); });
console_.add(t);
y += 19;
}
y += 8;
for (const b of rules.buildingList) {
if (colony.buildings.includes(b.id)) continue;
if (b.prereq && !emp.known[b.prereq]) continue;
const t = scene.add.text(cx, y, `${b.name} (${b.cost})`, {
fontFamily: FONT, fontSize: '13px', color: '#ffd88a',
}).setInteractive({ useHandCursor: true });
t.on('pointerup', () => { enqueue(rules, state, colony, 'building', b.id); onChanged?.(); rebuild(); });
console_.add(t);
y += 19;
if (y > shell.y + shell.height - 60) break;
}
}
function select(i) {
selected = i;
rebuild();
}
rebuild();
if (star.planets.length) select(0);
// The orrery turns in real time; planets are where the drop-in art lands.
const tick = scene.time.addEvent({
delay: 33,
loop: true,
callback: () => {
orbitGfx.clear();
orbitGfx.lineStyle(1, 0x2c4468, 0.55);
for (const b of bodies) {
b.angle += b.planet.orbitSpeed * 0.012;
orbitGfx.strokeEllipse(orreryCX, orreryCY, b.radius * 2, b.radius * 1.1);
b.img.setPosition(
orreryCX + Math.cos(b.angle) * b.radius,
orreryCY + Math.sin(b.angle) * b.radius * 0.55,
);
}
},
});
const origDestroy = shell.destroy;
shell.destroy = () => { tick.remove(); origDestroy(); };
return shell;
}

View File

@ -0,0 +1,193 @@
# Master of Vega — art spec
Every sheet below is **optional**. Each one starts with `"path": null` in
`data/mastervega-artwork.json`, and `VegaArt.js` paints a procedural stand-in
with the identical frame layout — the game is fully playable with zero art
files. To use real art: drop the PNG in `assets/images/vega/` and set its
`path` in the artwork JSON. **No code changes are needed.**
Frame indexes are **APPEND-ONLY**. Never renumber an existing frame — the
indexes are baked into `data/mastervega-rules.json` and shifting them silently
mismaps every piece of artwork already drawn.
Status: ⬜ = not painted yet (procedural fallback in use).
---
## 1. `ships` — ⬜ `assets/images/vega/ships.png`
| | |
|---|---|
| Sheet size | **768 × 960** |
| Frame | **96 × 96** |
| Grid | 8 cols × 10 rows = 80 frames |
**One row per species, one column per hull.** Frame = `species.shipFrame × 8 + hull.frame`.
Columns (hull):
| Col | Hull | Notes |
|---|---|---|
| 0 | Scout | Small, fast, unarmed |
| 1 | Colony Ship | Bulbous, carries a habitat pod |
| 2 | Troop Transport | Boxy, no guns |
| 3 | Frigate | Smallest warship |
| 4 | Destroyer | Mid warship |
| 5 | Cruiser | Heavy warship |
| 6 | Battleship | The big one — should read as huge even at 24px |
| 7 | Star Base | Immobile orbital fortress; a ring/station, not a ship |
Rows (species): 0 Human, 1 Kestrelli, 2 Ursaal, 3 Umbrix, 4 Kkrix, 5 Mekhan,
6 Rrashaa, 7 Cerebrai, 8 Ssakar, 9 Lithox.
**Ships face UP.** The star map and battle screen rotate them. Each species has
a colour in the rules file — art can ignore it (the sprite is used as-is) or
lean into it. Silhouette matters more than detail: these draw at ~2458 px.
---
## 2. `planets` — ⬜ `assets/images/vega/planets.png`
| | |
|---|---|
| Sheet size | **960 × 576** |
| Frame | **192 × 192** |
| Grid | 5 cols × 3 rows = 15 frames |
One frame per planet type, in rules order. Draw the planet as a **centred disc**
filling ~84% of the frame, transparent outside it — the orrery and system view
both place these on a black background and scale them.
| Frame | Type | Look |
|---|---|---|
| 0 | Terran | Blue oceans, green continents, white cloud |
| 1 | Ocean | Almost entirely water, scattered islands |
| 2 | Jungle | Deep green, permanent cloud banding |
| 3 | Steppe | Olive grassland, thin atmosphere |
| 4 | Arid | Tan, dry riverbeds |
| 5 | Desert | Orange sand seas, no water |
| 6 | Tundra | Pale blue-grey, heavy ice caps |
| 7 | Minimal | Grey-green, very thin air |
| 8 | Barren | Airless grey rock, craters |
| 9 | Dead | Darker, cracked, lifeless |
| 10 | Inferno | Molten orange, volcanic |
| 11 | Toxic | Sickly yellow-green fog |
| 12 | Radiated | Livid yellow-green, glowing |
| 13 | Gas Giant | Banded, a storm spot — **not colonisable** |
| 14 | Asteroid Belt | Scattered rocks, no disc — **not colonisable** |
---
## 3. `stars` — ⬜ `assets/images/vega/stars.png`
| | |
|---|---|
| Sheet size | **576 × 576** |
| Frame | **192 × 192** |
| Grid | 3 cols × 3 rows = 9 frames |
One frame per star class, in rules order: 0 Blue, 1 White, 2 Yellow, 3 Orange,
4 Red, 5 Brown Dwarf, 6 Binary, 7 Pulsar, 8 Black Hole.
Drawn with **ADD blending** on the star map (except the black hole, which is
drawn normally) — so paint them **glowing on transparent black**, with the
corona fading to fully transparent at the frame edge. The star map rotates the
pulsar frame continuously and orbits a scaled copy of the binary frame, so keep
the pulsar's beam axis vertical and the binary's companion at the frame centre.
---
## 4. `portraits` — ⬜ `assets/images/vega/portraits.png`
| | |
|---|---|
| Sheet size | **1280 × 512** |
| Frame | **256 × 256** |
| Grid | 5 cols × 2 rows = 10 frames |
One frame per species (`species.portraitFrame`), same order as the ship rows.
Head-and-shoulders, facing the viewer. Shown at 8496 px on the species picker
and the diplomacy screen, so read at small size.
These are **species portraits, not characters** — this game deliberately does
not use the `data/opponents.json` roster or its videos.
| Frame | Species | Identity |
|---|---|---|
| 0 | Human | Traders and diplomats |
| 1 | Kestrelli | Avian aristocrats, superb pilots |
| 2 | Ursaal | Ursine heavy-worlders |
| 3 | Umbrix | Shapeshifters, spies, universally distrusted |
| 4 | Kkrix | Insect hive, tireless industry |
| 5 | Mekhan | Cyborgs, factories everywhere, filthy |
| 6 | Rrashaa | Feline gunners |
| 7 | Cerebrai | Vast fragile intellects |
| 8 | Ssakar | Reptilian broodmothers |
| 9 | Lithox | Crystalline, breathe nothing, colonise anywhere |
---
## 5. `leaders` — ⬜ `assets/images/vega/leaders.png`
| | |
|---|---|
| Sheet size | **640 × 640** |
| Frame | **160 × 160** |
| Grid | 4 cols × 4 rows = 16 frames |
One frame per leader (`leader.portraitFrame`), rules order. Frames 07 are
colony administrators, 815 are ship captains. Shown at 4060 px.
---
## 6. `buildings` — ⬜ `assets/images/vega/buildings.png`
| | |
|---|---|
| Sheet size | **512 × 128** |
| Frame | **64 × 64** |
| Grid | 8 cols × 2 rows = 16 frames |
One frame per building (`building.frame`), rules order: Automated Factory,
Research Laboratory, Missile Base, Pollution Processor, Space Port, Cloning
Center, Ground Battery, Spy Center, Planetary Shield, Terraforming Plant, Stock
Exchange, Holo Simulator, Robotic Miners, Super Computer, Artemis System Net,
Enrichment Facility.
---
## 7. `techicons` — ⬜ `assets/images/vega/techicons.png`
| | |
|---|---|
| Sheet size | **480 × 336** |
| Frame | **48 × 48** |
| Grid | 10 cols × 7 rows = 70 frames |
- Frames **05**: the six tech *fields* — Computers, Construction, Force Fields,
Planetology, Propulsion, Weapons.
- Frames **665**: the sixty individual techs, in the order they appear in
`data/mastervega-rules.json` (`techs[].iconFrame`).
- Frames 6669: spare.
---
## 8. Menu icon — ✅ `assets/images/game-icons.png` frame **92**
The shared 660 × 660 sheet, 44 × 44 cells, 15 per row. Frame 92 is row 6,
column 2 → pixel origin **(88, 264)**. **Painted.**
---
## Already supplied
| File | Size | Used for |
|---|---|---|
| `assets/images/vega/background-menu.png` | 1920 × 1080 | Setup-screen background (darkened 62% so the cards stay legible) |
| `assets/images/vega/menu-title.png` | 1368 × 768 | Setup-screen title, scaled to fit a 760 × 132 header box (aspect preserved) |
## Sound
Already wired, no new audio needed: `laser-zap.mp3`, `scifi-explode.mp3`,
`ta-rocket-1.mp3`, `ta-rocket-2.mp3`. Soundtrack is the `hacker` set
(`src/services/soundtrack.js`).

View File

@ -103,6 +103,7 @@ import BloxorzGame from './games/bloxorz/BloxorzGame.js';
import GooTowerGame from './games/gootower/GooTowerGame.js'; import GooTowerGame from './games/gootower/GooTowerGame.js';
import GooTowerEditor from './games/gootower/GooTowerEditor.js'; import GooTowerEditor from './games/gootower/GooTowerEditor.js';
import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js'; import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js';
import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -218,6 +219,7 @@ const config = {
BloxorzGame, BloxorzGame,
GooTowerGame, GooTowerGame,
ExcitebikeGame, ExcitebikeGame,
MasterOfVegaGame,
GooTowerEditor, GooTowerEditor,
], ],
}; };

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame' }; const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug]; const sceneKey = slugDispatch[this.game.slug];
const startData = { const startData = {

View File

@ -76,6 +76,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('superkart-artwork', 'data/superkart-artwork.json'); this.load.json('superkart-artwork', 'data/superkart-artwork.json');
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json'); this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
this.load.json('star-control-ships', 'data/star-control-ships.json'); this.load.json('star-control-ships', 'data/star-control-ships.json');
this.load.json('mastervega-artwork', 'data/mastervega-artwork.json');
this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3'); this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3');
this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3'); this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3');

View File

@ -34,6 +34,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
mahjong: 'chinese', mahjong: 'chinese',
mahjongmatch: 'chinese', mahjongmatch: 'chinese',
zuma: 'zuma', zuma: 'zuma',
mastervega: 'hacker',
}; };
// Resolve the track list (and optional volume override) a game scene's // Resolve the track list (and optional volume override) a game scene's

799
tools/verifyMasterOfVega.js Normal file
View File

@ -0,0 +1,799 @@
// Headless verification for Master of Vega (Master of Orion clone).
// node tools/verifyMasterOfVega.js [--quick] [--games=N]
// Exits non-zero on any failure.
//
// 1. Rules integrity: ids unique, tech chains acyclic and fully ranked, every
// tech matters, hull/weapon/building bounds, frame indexes in range.
// 2. Procedural art: run the real painters against a fake canvas and assert
// every frame the rules reference was registered.
// 3. Galaxy generation: determinism, star counts, lane connectivity, homeworld
// spacing and habitability, opening-range fairness.
// 4. Ship Marks: damage and hull monotonic in tech; no NaN anywhere.
// 5. Combat: determinism, mirror-match fairness, tech/number advantage,
// auto-resolve agrees with playing it out, no battle hits the round cap.
// 6. Colony economy: slider normalisation, mandatory ecology, spillover,
// factory cap, growth, waste recovery.
// 7. Diplomacy and the Galactic Council: vote arithmetic, refusal, no deadlock.
// 8. Leaders: hiring, postings, upkeep bounds.
// 9. Serialisation: round-trip byte-identical, hash stable, version rejected.
// 10. AI self-play soak: full games terminate, both victory kinds occur,
// invariants hold, AI turn-time budget.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { compileRules, techCost, markNumeral } from '../src/games/mastervega/VegaRules.js';
import { generateGalaxy, isConnected, parsecs, mulberry32, PARSEC_PX } from '../src/games/mastervega/VegaGalaxyGen.js';
import * as Ships from '../src/games/mastervega/VegaShips.js';
import * as Combat from '../src/games/mastervega/VegaCombat.js';
import * as Logic from '../src/games/mastervega/VegaLogic.js';
import * as AI from '../src/games/mastervega/VegaAI.js';
import * as Diplo from '../src/games/mastervega/VegaDiplomacy.js';
import * as Leaders from '../src/games/mastervega/VegaLeaders.js';
// Pure art module: the painters need a canvas, but the sheet bookkeeping around
// them is checkable headlessly and worth checking.
import { ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame } from '../src/games/mastervega/VegaArt.js';
const QUICK = process.argv.includes('--quick');
const gamesArg = process.argv.find((a) => a.startsWith('--games='));
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const rulesJson = JSON.parse(readFileSync(join(root, 'data/mastervega-rules.json'), 'utf8'));
const artJson = JSON.parse(readFileSync(join(root, 'data/mastervega-artwork.json'), 'utf8'));
let failures = 0;
let passes = 0;
function check(name, cond, detail = '') {
if (cond) { passes += 1; return; }
failures += 1;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
function section(name) { console.log(`\n== ${name}`); }
const RULES = compileRules(rulesJson);
// ---------------------------------------------------------------------------
section('1. Rules integrity');
// ---------------------------------------------------------------------------
{
check('compileRules accepted the shipped rules', !!RULES);
check('six tech fields', RULES.techFieldList.length === 6, `${RULES.techFieldList.length}`);
check('ten species', RULES.speciesList.length === 10, `${RULES.speciesList.length}`);
// Every tech ranked (proves the chains are acyclic and fully reachable).
check('every tech is ranked', Object.keys(RULES.techRank).length === RULES.techList.length);
for (const t of RULES.techList) {
check(`tech ${t.id} rank equals its tier`, RULES.techRank[t.id] === t.tier);
}
// "Every tech matters": it either gates a building, feeds a later tech, or
// carries an effect of its own.
for (const t of RULES.techList) {
const g = RULES.techGates[t.id];
const matters = g.buildings.length > 0 || g.prereqOf.length > 0 || g.effects.length > 0;
check(`tech ${t.id} matters`, matters);
}
// Frame indexes must be unique per sheet and inside it.
const techFrames = RULES.techList.map((t) => t.iconFrame);
check('tech icon frames unique', new Set(techFrames).size === techFrames.length);
const techSheet = artJson.sheets.techicons;
const techCap = techSheet.cols * techSheet.rows;
check('tech icon frames fit the sheet', Math.max(...techFrames) < techCap,
`max ${Math.max(...techFrames)} of ${techCap}`);
const buildFrames = RULES.buildingList.map((b) => b.frame);
check('building frames unique', new Set(buildFrames).size === buildFrames.length);
check('building frames fit the sheet',
Math.max(...buildFrames) < artJson.sheets.buildings.cols * artJson.sheets.buildings.rows);
const portraitFrames = RULES.speciesList.map((s) => s.portraitFrame);
check('species portrait frames unique', new Set(portraitFrames).size === portraitFrames.length);
check('species ship rows fit the ship sheet',
Math.max(...RULES.speciesList.map((s) => s.shipFrame)) < artJson.sheets.ships.rows);
check('hull columns fit the ship sheet',
Math.max(...RULES.hullList.map((h) => h.frame)) < artJson.sheets.ships.cols);
check('planet frames fit the sheet',
Math.max(...RULES.planetTypeList.map((p) => p.frame))
< artJson.sheets.planets.cols * artJson.sheets.planets.rows);
check('leader frames fit the sheet',
Math.max(...RULES.leaderList.map((l) => l.portraitFrame))
< artJson.sheets.leaders.cols * artJson.sheets.leaders.rows);
// Weapons must be mountable on something.
const smallest = Math.min(...RULES.hullList.filter((h) => h.space > 0).map((h) => h.space));
for (const t of RULES.techList) {
const w = t.effects?.weapon;
if (!w) continue;
check(`weapon ${w.id} fits some hull`, w.space <= Math.max(...RULES.hullList.map((h) => h.space)));
check(`weapon ${w.id} has sane damage`, w.min > 0 && w.max >= w.min);
if (w.kind === 'missile') check(`missile ${w.id} has salvoes`, w.shots > 0);
}
check('the smallest warship can mount the starting beam',
RULES.techs.lasercannon.effects.weapon.space <= smallest);
// Research cost must climb.
const c0 = techCost(RULES, RULES.techs.lasercannon, 0);
const c9 = techCost(RULES, RULES.techs.stellarconverter, 9);
check('research costs climb across a field', c9 > c0 * 50, `${c0} -> ${c9}`);
check('markNumeral covers Mark VII', markNumeral(7) === 'VII');
// Species sanity: at least one clear strength each, and no species is
// strictly better than another on every axis.
for (const s of RULES.speciesList) {
const t = s.traits;
const good = [t.industryMult > 1, t.researchMult > 1, t.tradeMult > 1, t.growthMult > 1,
t.shipAttack > 0, t.shipDefense > 0, t.groundAttack > 0, t.espionage > 0,
t.factoriesPerPop > 2, t.colonizeAnything, t.hostileImmune, t.diplomacy > 0].filter(Boolean).length;
check(`species ${s.id} has a real strength`, good > 0);
}
}
// ---------------------------------------------------------------------------
section('2. Procedural art');
// ---------------------------------------------------------------------------
{
// A Proxy canvas that records every value the painters push at it. A NaN
// reaching a real canvas surfaces as an opaque WebGL error far from the
// mistake, so it is caught here instead.
const bad = [];
const num = (where, ...vals) => {
for (const v of vals) if (typeof v === 'number' && !Number.isFinite(v)) bad.push(where);
};
const mkCtx = () => new Proxy({}, {
get: (_t, prop) => {
if (prop === 'createLinearGradient' || prop === 'createRadialGradient') {
return (...args) => { num(String(prop), ...args); return { addColorStop: () => {} }; };
}
if (typeof prop === 'string') return (...args) => num(prop, ...args);
return () => {};
},
set: (_t, prop, value) => { num(String(prop), value); return true; },
});
const made = new Map();
const scene = {
textures: {
exists: (k) => made.has(k),
createCanvas(key, w, h) {
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
bad.push(`createCanvas(${key}, ${w}, ${h})`);
}
const frames = [];
const tex = {
width: w, height: h, frames,
getContext: () => mkCtx(),
refresh() {},
add(f, _s, fx, fy, fw, fh) {
num(`add(${key})`, fx, fy, fw, fh);
if (fx + fw > w || fy + fh > h) bad.push(`frame ${f} outside ${key}`);
frames.push(f);
},
};
made.set(key, tex);
return tex;
},
},
};
const { keys, procedural } = ensureSheets(scene, RULES, artJson);
const sheetNames = Object.keys(artJson.sheets ?? {});
for (const name of sheetNames) {
check(`sheet ${name} resolves to a key`, !!keys[name]);
const tex = made.get(keys[name]);
check(`sheet ${name} painted a non-empty canvas`, !!tex && tex.width > 0 && tex.height > 0,
tex ? `${tex.width}x${tex.height}` : 'no texture');
check(`sheet ${name} registered frames`, !!tex && tex.frames.length > 0);
}
check('every sheet fell back to a painted stand-in',
procedural.length === sheetNames.length, `${procedural.length}/${sheetNames.length}`);
check('no painter emitted a non-finite value', bad.length === 0,
[...new Set(bad)].slice(0, 5).join(', '));
// Every frame the rules point at must actually have been painted.
const shipsTex = made.get(keys.ships);
for (const s of RULES.speciesList) {
for (const h of RULES.hullList) {
check(`ship frame ${s.id}/${h.id} painted`,
shipsTex?.frames.includes(shipFrame(RULES, s.id, h.id)));
}
}
const planetsTex = made.get(keys.planets);
for (const p of RULES.planetTypeList) {
check(`planet frame ${p.id} painted`, planetsTex?.frames.includes(planetFrame(RULES, p.id)));
}
const techTex = made.get(keys.techicons);
for (const t of RULES.techList) {
check(`tech icon ${t.id} painted`, techTex?.frames.includes(techFrame(RULES, t.id)));
}
const buildTex = made.get(keys.buildings);
for (const b of RULES.buildingList) {
check(`building icon ${b.id} painted`, buildTex?.frames.includes(buildingFrame(RULES, b.id)));
}
}
// ---------------------------------------------------------------------------
section('3. Galaxy generation');
// ---------------------------------------------------------------------------
{
const shapes = RULES.galaxyShapeList.map((s) => s.id);
const sizes = RULES.galaxySizeList.map((s) => s.id);
const allSpecies = RULES.speciesList.map((s) => s.id);
for (const shapeId of shapes) {
for (const sizeId of sizes) {
const size = RULES.galaxySizes[sizeId];
const speciesIds = allSpecies.slice(0, size.maxEmpires);
const g = generateGalaxy(RULES, { sizeId, shapeId, seed: 99, speciesIds });
check(`${shapeId}/${sizeId} star count`, g.stars.length === size.stars,
`${g.stars.length} of ${size.stars}`);
check(`${shapeId}/${sizeId} lane graph connected`, isConnected(g.stars, g.adj));
check(`${shapeId}/${sizeId} stars inside bounds`,
g.stars.every((s) => s.x >= 0 && s.y >= 0 && s.x <= g.width && s.y <= g.height));
check(`${shapeId}/${sizeId} star names unique`,
new Set(g.stars.map((s) => s.name)).size === g.stars.length);
check(`${shapeId}/${sizeId} one homeworld per empire`,
new Set(g.homeIdx).size === speciesIds.length);
// Each empire must start on its own species' native world.
speciesIds.forEach((sid, e) => {
const home = g.stars[g.homeIdx[e]];
check(`${shapeId}/${sizeId} ${sid} starts on its homeworld type`,
home.planets[0]?.typeId === RULES.species[sid].homeworld);
check(`${shapeId}/${sizeId} ${sid} homeworld is habitable for it`,
RULES.planetTypes[home.planets[0].typeId].colonizable);
});
// Fairness: no empire may start meaningfully closer to a rival than the
// rest — that decides the game before turn one.
if (speciesIds.length > 1) {
const nearest = g.homeIdx.map((a, i) => Math.min(
...g.homeIdx.filter((_, j) => j !== i).map((b) => parsecs(g, a, b)),
));
const spread = Math.max(...nearest) / Math.max(0.001, Math.min(...nearest));
check(`${shapeId}/${sizeId} homeworld spacing fair`, spread < 3.2, `spread ${spread.toFixed(2)}`);
}
// Opening range must reach something worth settling.
const openRange = RULES.economy.baseFuelRange + 1.5;
for (let e = 0; e < speciesIds.length; e += 1) {
let open = 0;
for (let i = 0; i < g.stars.length; i += 1) {
if (i === g.homeIdx[e]) continue;
if (parsecs(g, g.homeIdx[e], i) > openRange) continue;
open += g.stars[i].planets.filter((p) => RULES.planetTypes[p.typeId].hostility === 0
&& RULES.planetTypes[p.typeId].colonizable).length;
}
check(`${shapeId}/${sizeId} empire ${e} has room to expand`, open >= 2, `${open} open worlds`);
}
}
}
// Determinism.
const a = generateGalaxy(RULES, { sizeId: 'medium', shapeId: 'spiral', seed: 4242, speciesIds: ['human', 'kkrix', 'lithox'] });
const b = generateGalaxy(RULES, { sizeId: 'medium', shapeId: 'spiral', seed: 4242, speciesIds: ['human', 'kkrix', 'lithox'] });
check('galaxy generation is deterministic', JSON.stringify(a) === JSON.stringify(b));
const c = generateGalaxy(RULES, { sizeId: 'medium', shapeId: 'spiral', seed: 4243, speciesIds: ['human', 'kkrix', 'lithox'] });
check('a different seed gives a different galaxy', JSON.stringify(a) !== JSON.stringify(c));
check('too many empires for the galaxy is rejected', (() => {
try {
generateGalaxy(RULES, { sizeId: 'small', shapeId: 'spiral', seed: 1, speciesIds: RULES.speciesList.map((s) => s.id) });
return false;
} catch (err) { return true; }
})());
}
// ---------------------------------------------------------------------------
section('4. Ship Marks');
// ---------------------------------------------------------------------------
{
// Learn the whole tree tier by tier and assert a refit never makes a ship
// worse. This is the property the knapsack loadout exists to guarantee: with
// preset hulls and no designer, the Mark is the ONLY way research shows up in
// the fleet, so a regression here makes the tech tree feel inert.
const known = {};
const order = [];
for (let tier = 0; tier < 10; tier += 1) {
for (const f of Object.keys(RULES.techFields)) {
const t = RULES.techsByField[f].find((x) => x.tier === tier);
if (t) order.push(t);
}
}
const hulls = ['frigate', 'destroyer', 'cruiser', 'battleship', 'starbase'];
let prev = null;
let drops = 0;
let nan = 0;
let hadMissiles = false;
for (const t of order) {
known[t.id] = true;
// Learning the FIRST missile tech reserves part of every large hull for
// missile racks (see MISSILE_SHARE in VegaShips), which trades a little
// sustained beam damage for an opening salvo. That is a deliberate
// one-time step down and the only sanctioned exception; it happens on the
// second rung of the weapons tree, long before it could matter. Every
// other transition must be non-decreasing.
const nowHasMissiles = Ships.bestComponents(RULES, known).missiles.length > 0;
const missileTransition = nowHasMissiles && !hadMissiles;
hadMissiles = nowHasMissiles;
const now = {};
for (const h of hulls) {
const d = Ships.designFor(RULES, known, h, RULES.species.human.traits);
now[h] = d;
for (const v of Object.values(d)) {
if (typeof v === 'number' && !Number.isFinite(v)) { nan += 1; }
}
if (prev && !missileTransition) {
if (d.damage < prev[h].damage - 1e-6) drops += 1;
if (d.hp < prev[h].hp) drops += 1;
}
}
prev = now;
}
check('no ship stat regresses as tech is learned', drops === 0, `${drops} regressions`);
check('the missile-share transition was actually exercised', hadMissiles);
check('no design produced a non-finite stat', nan === 0);
const full = Ships.designFor(RULES, known, 'battleship', RULES.species.human.traits);
check('a fully teched hull reaches Mark VII', full.mark === Ships.MAX_MARK, `Mark ${full.mark}`);
check('a fully teched warship mounts weapons', full.mounts.length > 0);
check('warships carry both beams and missiles across the game',
order.some(() => true) && full.mounts.some((m) => m.weapon.kind === 'beam'));
// Every warship must still have a beam — an all-missile ship empties its
// racks and then cannot fight at all.
const mid = {};
for (const t of RULES.techList) if (t.tier <= 6) mid[t.id] = true;
for (const h of ['destroyer', 'cruiser', 'battleship']) {
const d = Ships.designFor(RULES, mid, h, RULES.species.human.traits);
check(`${h} keeps a sustained beam battery`, d.beamDamage > 0, `beam ${d.beamDamage}`);
}
// Unarmed hulls stay unarmed; immobile hulls stay immobile.
const scout = Ships.designFor(RULES, known, 'scout', RULES.species.human.traits);
check('scout is unarmed', scout.mounts.length === 0 && scout.damage === 0);
check('scout outranges a warship', scout.range > full.range);
const base = Ships.designFor(RULES, known, 'starbase', RULES.species.human.traits);
check('star base is immobile', base.immobile && base.speed === 0);
check('refit costs something and is finite', (() => {
const c = Ships.refitCost(RULES, known, 'cruiser', 1, RULES.species.human.traits);
return Number.isFinite(c) && c > 0;
})());
check('refitting to the same Mark is free',
Ships.refitCost(RULES, known, 'cruiser', Ships.MAX_MARK, RULES.species.human.traits) === 0);
}
// ---------------------------------------------------------------------------
section('5. Combat');
// ---------------------------------------------------------------------------
{
const techsUpTo = (tier) => {
const k = {};
for (const t of RULES.techList) if (t.tier <= tier) k[t.id] = true;
return k;
};
const mkEmp = (sid, tier) => ({ known: techsUpTo(tier), traits: RULES.species[sid].traits });
const battle = (aS, aT, aShips, dS, dT, dShips, seed, colony = null) => Combat.runBattle(
Combat.createBattle(RULES, {
attacker: { empireIdx: 0, name: aS, empire: mkEmp(aS, aT), ships: aShips },
defender: { empireIdx: 1, name: dS, empire: mkEmp(dS, dT), ships: dShips },
colony, rnd: mulberry32(seed),
}),
);
const N = QUICK ? 120 : 400;
// Mirror matches must be a coin flip. Any systematic edge here means every
// other balance number measured against a mirror is meaningless.
let worstBias = 0;
let capped = 0;
let total = 0;
for (const tier of [0, 2, 4, 6, 8, 9]) {
let atk = 0;
for (let s = 1; s <= N; s += 1) {
const r = battle('human', tier, [{ hullId: 'cruiser', count: 5 }],
'human', tier, [{ hullId: 'cruiser', count: 5 }], s * 7919);
if (r.winner === 'attacker') atk += 1;
if (r.rounds >= RULES.combat.maxRounds) capped += 1;
total += 1;
}
const bias = Math.abs(atk / N - 0.5);
worstBias = Math.max(worstBias, bias);
check(`mirror match at tier ${tier} is fair`, bias < 0.12, `attacker ${(atk / N * 100).toFixed(1)}%`);
}
check('no battle ends on the round cap', capped === 0, `${capped}/${total}`);
check('worst mirror bias within tolerance', worstBias < 0.12, `${(worstBias * 100).toFixed(1)}pp`);
const rate = (fn, n = QUICK ? 80 : 200) => {
let w = 0;
for (let s = 1; s <= n; s += 1) if (fn(s * 7919).winner === 'attacker') w += 1;
return w / n;
};
check('a two-tier tech lead is decisive',
rate((s) => battle('human', 6, [{ hullId: 'cruiser', count: 5 }], 'human', 4, [{ hullId: 'cruiser', count: 5 }], s)) > 0.8);
check('numbers matter',
rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 7 }], 'human', 5, [{ hullId: 'cruiser', count: 5 }], s)) > 0.8);
check('a ship-attack species beats a neutral one',
rate((s) => battle('rrashaa', 5, [{ hullId: 'cruiser', count: 5 }], 'human', 5, [{ hullId: 'cruiser', count: 5 }], s)) > 0.7);
check('a ship-defence species beats a neutral one',
rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 5 }], 'kestrelli', 5, [{ hullId: 'cruiser', count: 5 }], s)) < 0.3);
// The two opposite racial bonuses must cancel — if they do not, one of them
// is being applied on the wrong side of the hit formula.
const cancel = rate((s) => battle('kestrelli', 5, [{ hullId: 'cruiser', count: 5 }], 'rrashaa', 5, [{ hullId: 'cruiser', count: 5 }], s));
check('opposing attack and defence bonuses cancel', Math.abs(cancel - 0.5) < 0.15, `${(cancel * 100).toFixed(1)}%`);
// Determinism, and auto-resolve agreeing with a played-out battle. They run
// the same stepper, so this is a structural guarantee rather than a tuning
// one — but it is exactly the kind of thing a refactor silently breaks.
const mk = (seed) => Combat.createBattle(RULES, {
attacker: { empireIdx: 0, name: 'a', empire: mkEmp('human', 5), ships: [{ hullId: 'cruiser', count: 4 }] },
defender: { empireIdx: 1, name: 'd', empire: mkEmp('ursaal', 5), ships: [{ hullId: 'destroyer', count: 9 }] },
colony: null, rnd: mulberry32(seed),
});
const r1 = Combat.runBattle(mk(1234));
const r2 = Combat.runBattle(mk(1234));
check('battles are deterministic', JSON.stringify(r1) === JSON.stringify(r2));
const stepped = mk(4321);
let guard = 0;
while (!stepped.done && guard < RULES.combat.maxRounds + 2) { guard += 1; Combat.stepRound(stepped, {}); }
const autoNoRetreat = Combat.runBattle(mk(4321), { allowRetreat: false });
check('stepping a battle out matches auto-resolve',
Combat.battleResult(stepped).winner === autoNoRetreat.winner);
// A colony's defences must matter without being unassailable.
const undefended = rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 4 }], 'human', 5, [], s, null), 60);
const defended = rate((s) => battle('human', 5, [{ hullId: 'cruiser', count: 4 }], 'human', 5, [], s,
{ defenseHp: 600, shieldBonus: 5 }), 60);
check('planetary defences make a difference', defended <= undefended, `${defended} vs ${undefended}`);
// Ground combat.
const inv = Combat.resolveInvasion(RULES, mulberry32(7), 60, 50, { groundDefense: 0 }, 0, 40);
check('a large invasion force takes a lightly held world', inv.captured);
const inv2 = Combat.resolveInvasion(RULES, mulberry32(7), 4, 0, { groundDefense: 200 }, 200, 300);
check('a token force fails against a fortress', !inv2.captured);
}
// ---------------------------------------------------------------------------
section('6. Colony economy');
// ---------------------------------------------------------------------------
{
const st = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'spiral', seed: 31, difficultyId: 'normal',
speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: -1,
});
st.rules = RULES;
const colony = st.colonies[0];
// Sliders always normalise to 1.
Logic.setSlider(RULES, st, colony, 'industry', 0.8);
const sum = Logic.CHANNELS.reduce((t, ch) => t + colony.sliders[ch], 0);
check('sliders normalise to 1', Math.abs(sum - 1) < 1e-6, `${sum}`);
check('the set channel takes the value it was given', Math.abs(colony.sliders.industry - 0.8) < 1e-6);
Logic.setSlider(RULES, st, colony, 'industry', 5);
check('slider values are clamped', colony.sliders.industry <= 1);
// Run a long stretch and assert the colony stays healthy without any AI.
for (let i = 0; i < 200 * st.empires.length; i += 1) {
Logic.beginEmpireTurn(RULES, st, st.current);
Logic.endEmpireTurn(RULES, st, st.current);
}
for (const c of st.colonies) {
check(`colony at ${c.starIdx} has non-negative population`, c.pop >= 0);
check(`colony at ${c.starIdx} respects its population cap`,
c.pop <= Logic.colonyMaxPop(RULES, st, c) + 1e-6);
check(`colony at ${c.starIdx} never exceeds its worked-factory cap`,
Logic.effectiveFactories(RULES, st, c) <= Logic.colonyFactoryCap(RULES, st, c) + 1e-6);
check(`colony at ${c.starIdx} stays within its defence cap`,
c.defenseHp <= Logic.colonyDefenseCap(RULES, st, c) + 1e-6);
// Ecology is funded off the top, so waste must never run away.
check(`colony at ${c.starIdx} is not drowning in waste`, c.waste < 5, `${c.waste.toFixed(1)}`);
}
// Absolute population totals are not comparable across species — a Lithox
// start on a barren world supports a fraction of a Human terran one, by
// design. Measure each colony against its OWN ceiling instead.
check('every colony grows toward its own ceiling', st.colonies.every(
(c) => c.pop >= Logic.colonyMaxPop(RULES, st, c) * 0.6,
), st.colonies.map((c) => `${(c.pop / Logic.colonyMaxPop(RULES, st, c)).toFixed(2)}`).join(' '));
check('treasuries are never negative', st.empires.every((e) => e.bc >= 0));
// Lithox generate no waste at all — the trait must reach the economy.
const lith = st.colonies.find((c) => st.empires[c.empireIdx].speciesId === 'lithox');
if (lith) check('a pollution-immune species generates no waste', lith.waste === 0);
// Waste recovery: dump a backlog on a colony and confirm it cleans up.
const dirty = st.colonies[0];
dirty.waste = 400;
for (let i = 0; i < 60 * st.empires.length; i += 1) {
Logic.beginEmpireTurn(RULES, st, st.current);
Logic.endEmpireTurn(RULES, st, st.current);
}
check('a colony recovers from a waste backlog', dirty.waste < 5, `${dirty.waste.toFixed(1)}`);
}
// ---------------------------------------------------------------------------
section('7. Diplomacy and the Galactic Council');
// ---------------------------------------------------------------------------
{
const st = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'elliptical', seed: 77, difficultyId: 'normal',
speciesIds: ['human', 'kkrix', 'rrashaa', 'lithox'], humanIndex: -1,
});
st.rules = RULES;
check('a species with no diplomacy cannot negotiate', (() => {
st.empires[0].contacted[3] = true;
st.empires[3].contacted[0] = true;
return !Diplo.canNegotiate(RULES, st, 0, 3);
})());
Diplo.declareWar(RULES, st, 0, 1);
check('war is mutual', Logic.atWar(st, 0, 1) && Logic.atWar(st, 1, 0));
check('being attacked is resented', st.empires[1].attitude[0] < 0);
Diplo.makePeace(RULES, st, 0, 1);
check('peace is mutual', !Logic.atWar(st, 0, 1) && !Logic.atWar(st, 1, 0));
check('attitudes stay in range', (() => {
for (let i = 0; i < 400; i += 1) {
for (const e of st.empires) Diplo.driftAttitudes(RULES, st, e.idx);
}
return st.empires.every((e) => Object.values(e.attitude)
.every((v) => v >= -100 && v <= 100 && Number.isFinite(v)));
})());
// Council arithmetic.
for (const e of st.empires) { e.totalPop = 100; for (const o of st.empires) if (o.idx !== e.idx) e.attitude[o.idx] = 60; }
st.empires[0].totalPop = 400;
const result = Logic.runCouncil(RULES, st);
check('the council names exactly two candidates', result.candidates.length === 2);
check('every vote is accounted for', (() => {
const cast = Object.values(result.votes).reduce((t, v) => t + v, 0);
return Math.abs(cast + result.abstained - result.totalPop) < 1e-6;
})(), `${JSON.stringify(result.votes)} + ${result.abstained} vs ${result.totalPop}`);
check('a landslide elects a High Guardian or is refused',
result.winner >= 0 || result.refused);
// Refusal: a candidate at war with the winner walks out.
const st2 = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'elliptical', seed: 78, difficultyId: 'normal',
speciesIds: ['human', 'kkrix', 'rrashaa'], humanIndex: -1,
});
st2.rules = RULES;
for (const e of st2.empires) {
e.totalPop = 100;
for (const o of st2.empires) if (o.idx !== e.idx) { e.contacted[o.idx] = true; e.attitude[o.idx] = 80; }
}
st2.empires[0].totalPop = 900;
Diplo.declareWar(RULES, st2, 0, 1);
const r2 = Logic.runCouncil(RULES, st2);
check('a candidate at war refuses to submit', r2.refused === true && r2.winner === -1);
check('a refusal is never also a victory', !(r2.refused && st2.over));
check('the council reschedules itself', st2.council.nextTurn > st2.turn);
check('powerOf is finite for every empire',
st2.empires.every((e) => Number.isFinite(Diplo.powerOf(RULES, st2, e.idx))));
}
// ---------------------------------------------------------------------------
section('8. Leaders');
// ---------------------------------------------------------------------------
{
const st = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'cluster', seed: 5, difficultyId: 'normal',
speciesIds: ['human', 'ssakar'], humanIndex: -1,
});
st.rules = RULES;
const emp = st.empires[0];
emp.bc = 10000;
const offers = Leaders.leaderOffers(RULES, st, 0);
check('leaders are offered', offers.length > 0);
check('offers are stable within a turn',
JSON.stringify(Leaders.leaderOffers(RULES, st, 0)) === JSON.stringify(offers));
const hired = Logic.hireLeader(RULES, st, 0, offers[0].id);
check('a leader can be hired', hired && emp.leaders.length === 1);
check('hiring costs the treasury', emp.bc < 10000);
check('the same leader cannot be hired twice', !Logic.hireLeader(RULES, st, 0, offers[0].id));
check('a hired leader leaves the shared pool', Leaders.leaderTaken(st, offers[0].id));
check('another empire cannot hire them',
!Leaders.availableLeaders(RULES, st).some((l) => l.id === offers[0].id));
const def = RULES.leaders[offers[0].id];
const colony = Logic.empireColonies(st, 0)[0];
if (def.kind === 'admin') {
check('an admin can take a colony posting',
Logic.assignLeader(RULES, st, 0, def.id, 'colony', colony.id));
check('an admin cannot command a fleet',
!Logic.assignLeader(RULES, st, 0, def.id, 'fleet', 1));
check('a posted admin is found by the colony', !!Object.keys(
Logic.colonyLeaderSkills(RULES, st, colony)).length);
} else {
check('a captain cannot govern a colony',
!Logic.assignLeader(RULES, st, 0, def.id, 'colony', colony.id));
}
check('every leader skill is a finite number', RULES.leaderList.every((l) => Object.values(l.skills)
.every((v) => typeof v === 'number' && Number.isFinite(v))));
check('every leader costs upkeep', RULES.leaderList.every((l) => l.upkeep > 0));
// Postings must survive a turn and be cleaned up when their target dies.
Leaders.runLeaderTurn(RULES, st, 0);
check('leader postings stay valid after a turn', emp.leaders.every((l) => l.assignKind === null
|| l.assignId >= 0));
}
// ---------------------------------------------------------------------------
section('9. Serialisation');
// ---------------------------------------------------------------------------
{
const st = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'ring', seed: 909, difficultyId: 'hard',
speciesIds: ['umbrix', 'mekhan', 'cerebrai'], humanIndex: 0,
});
st.rules = RULES;
for (let i = 0; i < 20 * 3; i += 1) {
Logic.beginEmpireTurn(RULES, st, st.current);
AI.runAITurn(RULES, st, st.current);
Logic.endEmpireTurn(RULES, st, st.current);
}
const json = Logic.serialize(st);
const back = Logic.deserialize(json);
check('a save round-trips byte-identically', Logic.serialize(back) === json);
check('the hash survives a round-trip', Logic.hashState(back) === Logic.hashState(st));
check('rules are never serialised', !json.includes('"rules"') || !JSON.parse(json).rules);
check('derived caches are never serialised', !json.includes('_comps') && !json.includes('_range')
&& !json.includes('_designs'));
const bad = JSON.parse(json);
bad.version = 99;
check('a save from another version is rejected', Logic.deserialize(JSON.stringify(bad)) === null);
// Same seed, same game.
const mk = () => {
const s = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'ring', seed: 2024, difficultyId: 'normal',
speciesIds: ['ssakar', 'lithox', 'kestrelli'], humanIndex: -1,
});
s.rules = RULES;
for (let i = 0; i < 60 * 3; i += 1) {
Logic.beginEmpireTurn(RULES, s, s.current);
AI.runAITurn(RULES, s, s.current);
Logic.endEmpireTurn(RULES, s, s.current);
}
return Logic.hashState(s);
};
check('replaying a seed reproduces the game exactly', mk() === mk());
}
// ---------------------------------------------------------------------------
section('10. AI self-play soak');
// ---------------------------------------------------------------------------
{
const ALL = RULES.speciesList.map((s) => s.id);
const SIZES = ['small', 'medium', 'large'];
const DIFFS = ['easy', 'normal', 'hard'];
const SHAPES = ['spiral', 'elliptical', 'cluster', 'ring'];
const N = gamesArg ? Number(gamesArg.split('=')[1]) : (QUICK ? 6 : 27);
// Returns a string on violation, else null. Checked at intervals rather than
// every turn — the point is to catch corruption, not to profile.
function checkInvariants(st, label) {
for (const e of st.empires) {
if (!Number.isFinite(e.bc) || e.bc < 0) return `${label}: empire ${e.idx} bc ${e.bc}`;
if (!Number.isFinite(e.totalPop) || e.totalPop < 0) return `${label}: empire ${e.idx} pop ${e.totalPop}`;
for (const f of Object.keys(RULES.techFields)) {
if (!Number.isFinite(e.beakers[f]) || e.beakers[f] < 0) return `${label}: empire ${e.idx} beakers ${f}`;
}
if (!e.alive && Logic.empireColonies(st, e.idx).length > 0) {
return `${label}: dead empire ${e.idx} still holds colonies`;
}
}
for (const c of st.colonies) {
if (!Number.isFinite(c.pop) || c.pop < 0) return `${label}: colony ${c.id} pop ${c.pop}`;
if (!st.empires[c.empireIdx]?.alive) return `${label}: colony ${c.id} owned by a dead empire`;
if (c.starIdx < 0 || c.starIdx >= st.galaxy.stars.length) return `${label}: colony ${c.id} off-map`;
if (!st.galaxy.stars[c.starIdx].planets[c.orbit]) return `${label}: colony ${c.id} on no planet`;
if (c.waste > 5000) return `${label}: colony ${c.id} waste ${c.waste}`;
}
// No two colonies may share an orbit.
const seen = new Set();
for (const c of st.colonies) {
const key = `${c.starIdx}:${c.orbit}`;
if (seen.has(key)) return `${label}: two colonies in orbit ${key}`;
seen.add(key);
}
for (const f of st.fleets) {
if (!f.ships.length) return `${label}: empty fleet ${f.id}`;
if (f.ships.some((s) => s.count <= 0)) return `${label}: fleet ${f.id} has an empty stack`;
if (f.starIdx < 0 && (f.toStar < 0 || f.fromStar < 0)) return `${label}: fleet ${f.id} nowhere`;
if (f.starIdx >= st.galaxy.stars.length) return `${label}: fleet ${f.id} off-map`;
if (!st.empires[f.empireIdx]?.alive) return `${label}: fleet ${f.id} of a dead empire`;
}
return null;
}
function runGame(idx) {
const sizeId = SIZES[idx % SIZES.length];
const difficultyId = DIFFS[Math.floor(idx / SIZES.length) % DIFFS.length];
const shapeId = SHAPES[idx % SHAPES.length];
const count = Math.min(RULES.galaxySizes[sizeId].maxEmpires, 3 + (idx % 3));
const speciesIds = [];
for (let i = 0; i < count; i += 1) speciesIds.push(ALL[(idx * 3 + i) % ALL.length]);
const st = Logic.createGame(RULES, {
sizeId, shapeId, difficultyId, seed: 1000 + idx, speciesIds, humanIndex: -1,
});
st.rules = RULES;
let aiMs = 0;
let aiTurns = 0;
let invariantErr = null;
while (!st.over && st.turn < RULES.victory.turnCap) {
Logic.beginEmpireTurn(RULES, st, st.current);
const t0 = performance.now();
AI.runAITurn(RULES, st, st.current);
aiMs += performance.now() - t0;
aiTurns += 1;
Logic.endEmpireTurn(RULES, st, st.current);
if (st.turn % 50 === 0 && !invariantErr) {
invariantErr = checkInvariants(st, `game ${idx} turn ${st.turn}`);
}
}
if (!invariantErr) invariantErr = checkInvariants(st, `game ${idx} end`);
return { st, invariantErr, avgAiMs: aiTurns ? aiMs / aiTurns : 0 };
}
const games = [];
for (let i = 0; i < N; i += 1) games.push(runGame(i));
const firstErr = games.find((g) => g.invariantErr);
check('game invariants hold throughout', !firstErr, firstErr?.invariantErr ?? '');
const decided = games.filter((g) => g.st.over);
check('every game terminates', decided.length === games.length,
`${decided.length}/${games.length}`);
const kinds = {};
for (const g of games) kinds[g.st.victoryKind] = (kinds[g.st.victoryKind] ?? 0) + 1;
check('most games reach a real victory', (kinds.conquest ?? 0) + (kinds.council ?? 0) >= games.length * 0.6,
JSON.stringify(kinds));
check('conquest victories occur', (kinds.conquest ?? 0) > 0, JSON.stringify(kinds));
if (!QUICK) {
check('council victories occur', (kinds.council ?? 0) > 0, JSON.stringify(kinds));
}
const turns = games.map((g) => g.st.turn).sort((a, b) => a - b);
const median = turns[Math.floor(turns.length / 2)];
check('games are decided in a reasonable window', median >= 60 && median <= 650, `median ${median}`);
// Performance budget. The scene runs every AI empire synchronously between
// the player's turns, so this is what the player waits for.
const worst = Math.max(...games.map((g) => g.avgAiMs));
check('AI turn time budget (<=50ms avg)', worst <= 50, `worst ${worst.toFixed(2)}ms`);
// No single species should dominate the whole sweep.
const wins = {};
for (const g of games) {
if (g.st.winnerIdx >= 0) {
const sid = g.st.empires[g.st.winnerIdx].speciesId;
wins[sid] = (wins[sid] ?? 0) + 1;
}
}
const topShare = Math.max(0, ...Object.values(wins)) / Math.max(1, decided.length);
check('no species wins everything', topShare < 0.6, `${JSON.stringify(wins)}`);
// Wars must actually happen, and colonies must actually change hands.
const anyWar = games.some((g) => g.st.empires.some((e) => Object.values(e.treaties).includes('war')
|| g.st.empires.some((o) => o.idx !== e.idx && !o.alive)));
check('empires go to war', anyWar);
const totalEliminated = games.reduce((t, g) => t + g.st.empires.filter((e) => !e.alive).length, 0);
check('empires are eliminated in war', totalEliminated > 0, `${totalEliminated}`);
}
// ---------------------------------------------------------------------------
console.log(`\n${passes} passed, ${failures} failed`);
if (failures > 0) process.exit(1);