diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 78863a6..0f3435a 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index 610ad3c..597864c 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/assets/images/vega/background-menu.png b/assets/images/vega/background-menu.png new file mode 100644 index 0000000..0cc983a Binary files /dev/null and b/assets/images/vega/background-menu.png differ diff --git a/assets/images/vega/menu-title.png b/assets/images/vega/menu-title.png new file mode 100644 index 0000000..ce229ca Binary files /dev/null and b/assets/images/vega/menu-title.png differ diff --git a/assets/music/vega/track-01.mp3 b/assets/music/vega/track-01.mp3 new file mode 100644 index 0000000..ede1c20 Binary files /dev/null and b/assets/music/vega/track-01.mp3 differ diff --git a/assets/music/vega/track-02.mp3 b/assets/music/vega/track-02.mp3 new file mode 100644 index 0000000..6d520e9 Binary files /dev/null and b/assets/music/vega/track-02.mp3 differ diff --git a/assets/music/vega/track-03.mp3 b/assets/music/vega/track-03.mp3 new file mode 100644 index 0000000..c6e1815 Binary files /dev/null and b/assets/music/vega/track-03.mp3 differ diff --git a/assets/music/vega/track-04.mp3 b/assets/music/vega/track-04.mp3 new file mode 100644 index 0000000..a8dcd01 Binary files /dev/null and b/assets/music/vega/track-04.mp3 differ diff --git a/assets/music/vega/track-05.mp3 b/assets/music/vega/track-05.mp3 new file mode 100644 index 0000000..aad2e0a Binary files /dev/null and b/assets/music/vega/track-05.mp3 differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json new file mode 100644 index 0000000..ae49ec8 --- /dev/null +++ b/data/mastervega-artwork.json @@ -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." + } + } +} diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json new file mode 100644 index 0000000..43507a9 --- /dev/null +++ b/data/mastervega-rules.json @@ -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" + ] +} diff --git a/docs/mastervega-build-plan.md b/docs/mastervega-build-plan.md new file mode 100644 index 0000000..a2c705f --- /dev/null +++ b/docs/mastervega-build-plan.md @@ -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. diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index c264829..9a198f1 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -71,6 +71,20 @@ export const MANIFEST = { excitebike: [ (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: [ // Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded // frame 2i+1 (see IslandData.TILE_FRAME_ROW). diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 0904458..adca781 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -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: '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: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 }); diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js new file mode 100644 index 0000000..e04c1bd --- /dev/null +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -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 */ } + } +} diff --git a/src/games/mastervega/VegaAI.js b/src/games/mastervega/VegaAI.js new file mode 100644 index 0000000..82ad56e --- /dev/null +++ b/src/games/mastervega/VegaAI.js @@ -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); +} diff --git a/src/games/mastervega/VegaArt.js b/src/games/mastervega/VegaArt.js new file mode 100644 index 0000000..660afde --- /dev/null +++ b/src/games/mastervega/VegaArt.js @@ -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; diff --git a/src/games/mastervega/VegaCombat.js b/src/games/mastervega/VegaCombat.js new file mode 100644 index 0000000..7029eee --- /dev/null +++ b/src/games/mastervega/VegaCombat.js @@ -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 }; +} diff --git a/src/games/mastervega/VegaCombatView.js b/src/games/mastervega/VegaCombatView.js new file mode 100644 index 0000000..e64dfb3 --- /dev/null +++ b/src/games/mastervega/VegaCombatView.js @@ -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(); } }; +} diff --git a/src/games/mastervega/VegaDiplomacy.js b/src/games/mastervega/VegaDiplomacy.js new file mode 100644 index 0000000..8d518e3 --- /dev/null +++ b/src/games/mastervega/VegaDiplomacy.js @@ -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; +} diff --git a/src/games/mastervega/VegaFx.js b/src/games/mastervega/VegaFx.js new file mode 100644 index 0000000..14cba10 --- /dev/null +++ b/src/games/mastervega/VegaFx.js @@ -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(); } +} diff --git a/src/games/mastervega/VegaGalaxyGen.js b/src/games/mastervega/VegaGalaxyGen.js new file mode 100644 index 0000000..2cc1901 --- /dev/null +++ b/src/games/mastervega/VegaGalaxyGen.js @@ -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; +} diff --git a/src/games/mastervega/VegaLeaders.js b/src/games/mastervega/VegaLeaders.js new file mode 100644 index 0000000..fce42d7 --- /dev/null +++ b/src/games/mastervega/VegaLeaders.js @@ -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; } + } +} diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js new file mode 100644 index 0000000..9075c62 --- /dev/null +++ b/src/games/mastervega/VegaLogic.js @@ -0,0 +1,1521 @@ +// Master of Vega — the engine. Headless: no Phaser, importable by Node, so +// tools/verifyMasterOfVega.js can play whole games without a browser. +// +// Every function takes (rules, state, ...). `state` is plain JSON with the RNG +// cursor inside it, so serialising the state serialises the future too: replay +// a seed and you get the same galaxy, the same battles, the same winner. +// +// Economy model: MOO1's five allocation sliders, with MOO2 colony buildings +// acting as multipliers on the channel they name. The "Construction" channel +// funds the colony build queue (ships AND buildings); Industry builds factories; +// Ecology cleans industrial waste; Defence raises planetary defences; Research +// splits into the six tech fields. + +import { generateGalaxy, parsecs } from './VegaGalaxyGen.js'; +import { techCost } from './VegaRules.js'; +import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js'; +import { createBattle, runBattle, resolveInvasion } from './VegaCombat.js'; +import { breakStalemate, declareWar } from './VegaDiplomacy.js'; + +export const CHANNELS = ['ships', 'defense', 'industry', 'ecology', 'research']; + +// -------------------------------------------------------------------------- +// RNG — explicit state so it serialises with the game. + +export function rand(state) { + let a = state.rngState | 0; + a = (a + 0x6d2b79f5) | 0; + state.rngState = a; + 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; +} +export function randInt(state, n) { return Math.floor(rand(state) * n); } + +export function attachRules(state, rules) { state.rules = rules; return state; } + +const pushEvent = (state, ev) => { + state.events.push(ev); + if (state.events.length > 600) state.events = state.events.slice(-300); +}; + +// -------------------------------------------------------------------------- +// Lookups + +export const empireColonies = (state, e) => state.colonies.filter((c) => c.empireIdx === e); +export const empireFleets = (state, e) => state.fleets.filter((f) => f.empireIdx === e); +export const colonyAt = (state, starIdx) => state.colonies.find((c) => c.starIdx === starIdx); +export const coloniesAt = (state, starIdx) => state.colonies.filter((c) => c.starIdx === starIdx); +export const fleetsAt = (state, starIdx) => state.fleets.filter((f) => f.starIdx === starIdx); +export const starOf = (state, i) => state.galaxy.stars[i]; +export const planetOf = (state, colony) => state.galaxy.stars[colony.starIdx].planets[colony.orbit]; + +// designFor() runs a knapsack per call. The AI, the upkeep pass and the fleet +// panel all ask for the same handful of designs many times per turn, so they +// are memoised per empire and invalidated whenever a tech lands. +export function empireDesign(rules, state, e, hullId, skills = null) { + const emp = state.empires[e]; + if (skills) return designFor(rules, emp.known, hullId, rules.species[emp.speciesId].traits, skills); + if (!emp._designs || emp._designsAt !== emp.techsKnown) { + emp._designs = {}; + emp._designsAt = emp.techsKnown; + } + if (!emp._designs[hullId]) { + emp._designs[hullId] = designFor(rules, emp.known, hullId, rules.species[emp.speciesId].traits); + } + return emp._designs[hullId]; +} + +export function empireComponents(rules, state, e) { + const emp = state.empires[e]; + if (!emp._comps || emp._compsAt !== emp.techsKnown) { + emp._comps = bestComponents(rules, emp.known); + emp._compsAt = emp.techsKnown; + } + return emp._comps; +} + +// Leader skill bags, merged. Admins bind to one colony; captains bind to a +// fleet. Anything unassigned still costs upkeep but does nothing, which is the +// player's problem. +export function colonyLeaderSkills(rules, state, colony) { + const emp = state.empires[colony.empireIdx]; + const l = emp.leaders.find((x) => x.assignKind === 'colony' && x.assignId === colony.id); + return l ? rules.leaders[l.leaderId].skills : {}; +} +export function fleetLeaderSkills(rules, state, fleet) { + const emp = state.empires[fleet.empireIdx]; + const l = emp.leaders.find((x) => x.assignKind === 'fleet' && x.assignId === fleet.id); + return l ? rules.leaders[l.leaderId].skills : {}; +} + +// -------------------------------------------------------------------------- +// Colony derived numbers + +export function buildingMult(rules, colony, channel) { + let m = 1; + for (const bid of colony.buildings) { + const b = rules.buildings[bid]; + if (b && b.channel === channel) m *= b.mult; + } + return m; +} +function buildingEffect(rules, colony, key) { + let total = 0; + for (const bid of colony.buildings) { + const v = rules.buildings[bid]?.effects?.[key]; + if (typeof v === 'number') total += v; + } + return total; +} +function buildingEffectMult(rules, colony, key) { + let m = 1; + for (const bid of colony.buildings) { + const v = rules.buildings[bid]?.effects?.[key]; + if (typeof v === 'number') m *= v; + } + return m; +} + +export function colonyMaxPop(rules, state, colony) { + const emp = state.empires[colony.empireIdx]; + const spec = rules.species[emp.speciesId]; + const planet = planetOf(state, colony); + const comps = empireComponents(rules, state, colony.empireIdx); + const skills = colonyLeaderSkills(rules, state, colony); + const base = planet.basePop * spec.traits.maxPopMult; + const bonus = comps.maxPopBonus + buildingEffect(rules, colony, 'maxPopBonus') + (skills.maxPopBonus ?? 0); + const raw = base + bonus; + // Uncleaned industrial waste poisons the biosphere; Lithox never generate any. + const penalty = Math.min(0.75, colony.waste / Math.max(1, planet.basePop * 2)); + return Math.max(1, Math.round(raw * (1 - penalty))); +} + +export function colonyFactoryCap(rules, state, colony) { + const emp = state.empires[colony.empireIdx]; + const spec = rules.species[emp.speciesId]; + return Math.round(colony.pop * spec.traits.factoriesPerPop); +} + +// Population runs a colony's factories, so a colony that shrinks cannot work +// all of them. The surplus is mothballed rather than demolished — it neither +// produces nor pollutes, and comes back if the population recovers. Without +// this, a colony that loses population keeps generating waste from factories +// nobody is left to staff, which is a death spiral it can never escape. +export function effectiveFactories(rules, state, colony) { + return Math.min(colony.factories, colonyFactoryCap(rules, state, colony)); +} + +export function colonyProduction(rules, state, colony) { + const emp = state.empires[colony.empireIdx]; + const spec = rules.species[emp.speciesId]; + const planet = planetOf(state, colony); + const rich = rules.richness[planet.richId]?.industryMult ?? 1; + const skills = colonyLeaderSkills(rules, state, colony); + const eco = rules.economy; + const fromPop = colony.pop * eco.popOutput; + const fromFactories = effectiveFactories(rules, state, colony) * eco.factoryOutput * rich + * spec.traits.industryMult + * buildingMult(rules, colony, 'industry') + * (skills.industryMult ?? 1); + return fromPop + fromFactories; +} + +export function colonyDefenseCap(rules, state, colony) { + const comps = empireComponents(rules, state, colony.empireIdx); + return Math.round((100 + comps.planetaryShield * 30) * buildingMult(rules, colony, 'defense')); +} + +export function colonyTrade(rules, state, colony) { + const emp = state.empires[colony.empireIdx]; + const spec = rules.species[emp.speciesId]; + const skills = colonyLeaderSkills(rules, state, colony); + const eco = rules.economy; + return colony.pop * eco.tradePerPop + * spec.traits.tradeMult + * buildingEffectMult(rules, colony, 'tradeMult') + * (skills.tradeMult ?? 1) + + buildingEffect(rules, colony, 'tradeBonus'); +} + +export function colonyGroundDefense(rules, state, colony) { + const emp = state.empires[colony.empireIdx]; + const spec = rules.species[emp.speciesId]; + const comps = empireComponents(rules, state, colony.empireIdx); + const skills = colonyLeaderSkills(rules, state, colony); + const planet = planetOf(state, colony); + const grav = rules.gravity[planet.gravId]; + // High gravity punishes attackers and defenders alike unless you were born + // on a heavy world. + const gravMod = spec.traits.highGravityOk ? 0 : (grav?.combatMod ?? 0); + return (spec.traits.groundDefense ?? 0) + + (comps.groundDefense ?? 0) + + buildingEffect(rules, colony, 'groundDefense') + + (skills.groundDefense ?? 0) + + gravMod; +} + +// Can this empire settle this planet at all? +export function canColonize(rules, state, e, starIdx, orbit) { + const planet = state.galaxy.stars[starIdx]?.planets?.[orbit]; + if (!planet) return false; + const type = rules.planetTypes[planet.typeId]; + if (!type.colonizable) return false; + if (colonyAt(state, starIdx) && coloniesAt(state, starIdx).some((c) => c.orbit === orbit)) return false; + const emp = state.empires[e]; + const spec = rules.species[emp.speciesId]; + if (spec.traits.colonizeAnything) return true; + const comps = empireComponents(rules, state, e); + return type.hostility <= comps.colonizeHostility; +} + +// -------------------------------------------------------------------------- +// Range — the star map's "range as light", and the engine's movement rule. + +// Every star within fuel range of a colony or star base. Recomputed lazily and +// cached per turn; the AI asks for it constantly. +export function reachableStars(rules, state, e) { + const emp = state.empires[e]; + if (emp._rangeAt === state.turn && emp._range) return emp._range; + const comps = empireComponents(rules, state, e); + let range = comps.fuelRange; + const sources = []; + for (const c of empireColonies(state, e)) { + sources.push(c.starIdx); + if (c.buildings.includes('starbase')) range = Math.max(range, comps.fuelRange + (rules.economy.starbaseRangeBonus ?? 3)); + } + for (const f of empireFleets(state, e)) { + if (f.starIdx >= 0 && f.ships.some((s) => s.hullId === 'starbase')) sources.push(f.starIdx); + } + const out = {}; + for (const src of sources) { + for (let i = 0; i < state.galaxy.stars.length; i += 1) { + if (out[i]) continue; + if (parsecs(state.galaxy, src, i) <= range + 1e-9) out[i] = true; + } + } + emp._range = out; + emp._rangeAt = state.turn; + return out; +} + +export function fleetSpeed(rules, state, fleet) { + const emp = state.empires[fleet.empireIdx]; + const spec = rules.species[emp.speciesId]; + const skills = fleetLeaderSkills(rules, state, fleet); + // Immobile hulls (star bases) are SKIPPED, not disqualifying. + // + // Returning 0 for any fleet containing a star base looked reasonable and was + // catastrophic: a completed Star Base joins the fleet sitting over its own + // colony, that fleet is the empire's main battle fleet, and from that moment + // canSendFleet refused every order it was ever given. Measured over one game, + // 338 of 338 valid attacks — in range, strong enough, at war — were refused + // for this reason alone. No colony could ever be attacked, so no war could be + // won and conquest was unreachable. sendFleet() leaves the bases behind. + let speed = Infinity; + let mobile = 0; + for (const s of fleet.ships) { + if (s.count <= 0) continue; + const d = empireDesign(rules, state, fleet.empireIdx, s.hullId, Object.keys(skills).length ? skills : null); + if (d.immobile) continue; + mobile += 1; + speed = Math.min(speed, d.speed); + } + if (!mobile) return 0; + return Number.isFinite(speed) ? Math.max(1, speed) : 0; +} + +export function fleetPower(rules, state, fleet) { + const emp = state.empires[fleet.empireIdx]; + const spec = rules.species[emp.speciesId]; + const skills = fleetLeaderSkills(rules, state, fleet); + let p = 0; + for (const s of fleet.ships) { + if (s.count <= 0) continue; + const d = empireDesign(rules, state, fleet.empireIdx, s.hullId, Object.keys(skills).length ? skills : null); + if (d.role !== 'warship' && d.role !== 'base') continue; + p += s.count * (d.hp + d.damage * 4); + } + return Math.round(p); +} + +// -------------------------------------------------------------------------- +// Game creation + +function rollTechAvailability(state, rules, emp, spec) { + const available = {}; + // A species that "can research anything" gets the whole tree; everyone else + // rolls per tech, weighted by how naturally the field comes to them. Because + // the chains are linear, an unavailable tech does NOT block the rest of its + // field — research simply skips it (see nextResearchTarget), and the only way + // to ever own it is trade, conquest or espionage. That is MOO1's rule and it + // is what makes tech trading matter. + const openAll = spec.traits.researchMult >= 2; + for (const t of rules.techList) { + if (t.tier === 0 || openAll) { available[t.id] = true; continue; } + const affinity = spec.techAffinity[t.field] ?? 1; + const chance = Math.max(0.3, Math.min(1, 0.55 * affinity + 0.2)); + available[t.id] = rand(state) < chance; + } + return available; +} + +export function createGame(rules, opts) { + const { + sizeId = 'medium', shapeId = 'spiral', seed = 1, difficultyId = 'normal', + speciesIds = ['human', 'kkrix', 'rrashaa'], humanIndex = 0, + } = opts; + + const state = { + version: 1, + rules: null, + seed, + rngState: (seed * 2654435761) | 0, + sizeId, shapeId, difficultyId, + turn: 0, + current: 0, + humanIndex, + galaxy: null, + empires: [], + colonies: [], + fleets: [], + nextColonyId: 0, + nextFleetId: 0, + events: [], + council: { nextTurn: rules.council.firstTurn, lastResult: null, history: [] }, + over: false, + winnerIdx: -1, + victoryKind: null, + }; + + state.galaxy = generateGalaxy(rules, { sizeId, shapeId, seed, speciesIds }); + const diff = rules.difficulties[difficultyId]; + + speciesIds.forEach((sid, e) => { + const spec = rules.species[sid]; + const emp = { + idx: e, + speciesId: sid, + name: spec.name, + color: spec.color, + alive: true, + isHuman: e === humanIndex, + homeStar: state.galaxy.homeIdx[e], + known: {}, + available: {}, + techsKnown: 0, + knownInField: { computers: 0, construction: 0, forcefields: 0, planetology: 0, propulsion: 0, weapons: 0 }, + researching: {}, + alloc: {}, + beakers: {}, + bc: rules.economy.startingBC, + leaders: [], + contacted: {}, + treaties: {}, + attitude: {}, + explored: {}, + spyPoints: 0, + totalPop: 0, + _comps: null, _compsAt: -1, _range: null, _rangeAt: -1, _designs: null, _designsAt: -1, + }; + // Research starts spread evenly across the six fields. + const fields = Object.keys(rules.techFields); + for (const f of fields) { emp.alloc[f] = 1 / fields.length; emp.beakers[f] = 0; emp.researching[f] = null; } + emp.available = rollTechAvailability(state, rules, emp, spec); + state.empires.push(emp); + }); + + // Mutual ignorance to start; attitudes are neutral except where a species is + // simply disliked on sight. + for (const a of state.empires) { + for (const b of state.empires) { + if (a.idx === b.idx) continue; + a.treaties[b.idx] = 'none'; + const spec = rules.species[a.speciesId]; + a.attitude[b.idx] = Math.round((spec.traits.diplomacy ?? 0) / 4); + } + } + + // Homeworlds. + speciesIds.forEach((sid, e) => { + const starIdx = state.galaxy.homeIdx[e]; + const colony = foundColony(rules, state, e, starIdx, 0, rules.economy.startingPop); + colony.factories = rules.economy.startingFactories; + colony.capital = true; + state.empires[e].explored[starIdx] = true; + // Neighbours are charted from the start — nobody begins truly blind. + for (let i = 0; i < state.galaxy.stars.length; i += 1) { + if (parsecs(state.galaxy, starIdx, i) <= (rules.economy.baseFuelRange ?? 4)) { + state.empires[e].explored[i] = true; + } + } + // Starting fleet: a scout and a colony ship, plus difficulty handicap ships. + const bonus = e === humanIndex ? 0 : diff.aiStartBonus; + addFleet(rules, state, e, starIdx, [ + { hullId: 'scout', mark: 1, count: 1 + bonus }, + { hullId: 'colonyship', mark: 1, count: 1 }, + ...(bonus > 0 ? [{ hullId: 'frigate', mark: 1, count: bonus * 2 }] : []), + ]); + }); + + recomputeTotals(rules, state); + return state; +} + +export function foundColony(rules, state, e, starIdx, orbit, pop) { + const colony = { + id: state.nextColonyId += 1, + empireIdx: e, + starIdx, + orbit, + pop, + factories: 0, + waste: 0, + defenseHp: 0, + buildings: [], + queue: [], + capital: false, + sliders: { ships: 0.2, defense: 0.1, industry: 0.4, ecology: 0.1, research: 0.2 }, + locked: {}, + founded: state.turn, + }; + state.colonies.push(colony); + state.empires[e].explored[starIdx] = true; + return colony; +} + +export function addFleet(rules, state, e, starIdx, ships) { + // Merge into an existing fleet at the same star rather than littering the map + // with one-ship fleets. + const existing = state.fleets.find((f) => f.empireIdx === e && f.starIdx === starIdx && f.toStar < 0); + if (existing) { + for (const s of ships) { + const m = existing.ships.find((x) => x.hullId === s.hullId && x.mark === s.mark); + if (m) m.count += s.count; + else existing.ships.push({ ...s }); + } + return existing; + } + const fleet = { + id: state.nextFleetId += 1, + empireIdx: e, + starIdx, + fromStar: -1, + toStar: -1, + progress: 0, + total: 0, + ships: ships.map((s) => ({ ...s })), + }; + state.fleets.push(fleet); + return fleet; +} + +function cleanFleets(state) { + for (const f of state.fleets) f.ships = f.ships.filter((s) => s.count > 0); + state.fleets = state.fleets.filter((f) => f.ships.length > 0); +} + +// Merge every idle fleet an empire has sitting in the same system. Ships +// completing while the local fleet happens to be in transit each spawn a new +// fleet, and over a long game that compounds into hundreds of one-ship stacks — +// which is both unreadable on the star map and quadratic work for the AI. +function consolidateFleets(rules, state, e) { + const byStar = new Map(); + for (const f of state.fleets) { + if (f.empireIdx !== e || f.starIdx < 0 || f.toStar >= 0) continue; + // Key on mobility as well as location, so a star-base garrison never gets + // folded back into the battle fleet it was just split out of. + const mobile = fleetSpeed(rules, state, f) > 0; + const key = `${f.starIdx}|${mobile ? 'm' : 'g'}`; + const head = byStar.get(key); + if (!head) { byStar.set(key, f); continue; } + for (const s of f.ships) { + const m = head.ships.find((x) => x.hullId === s.hullId && x.mark === s.mark); + if (m) m.count += s.count; + else head.ships.push({ ...s }); + } + f.ships = []; + } + cleanFleets(state); +} + +// -------------------------------------------------------------------------- +// Research + +export function canResearch(rules, state, e, tech) { + const emp = state.empires[e]; + if (emp.known[tech.id] || !emp.available[tech.id]) return false; + // Every lower tier in the field must be resolved — known, or rolled + // unavailable and therefore skipped. + for (const t of rules.techsByField[tech.field]) { + if (t.tier >= tech.tier) break; + if (!emp.known[t.id] && emp.available[t.id]) return false; + } + return true; +} + +export function nextResearchTarget(rules, state, e, field) { + for (const t of rules.techsByField[field]) { + if (canResearch(rules, state, e, t)) return t.id; + } + return null; +} + +export function grantTech(rules, state, e, techId, source = 'research') { + const emp = state.empires[e]; + if (emp.known[techId]) return false; + const tech = rules.techs[techId]; + emp.known[techId] = true; + emp.available[techId] = true; + emp.techsKnown += 1; + emp.knownInField[tech.field] += 1; + emp._comps = null; + emp._compsAt = -1; + emp._range = null; + emp._rangeAt = -1; + emp._designs = null; + emp._designsAt = -1; + pushEvent(state, { type: 'techDone', empire: e, techId, source, turn: state.turn }); + return true; +} + +function processResearch(rules, state, e, beakers) { + const emp = state.empires[e]; + const spec = rules.species[emp.speciesId]; + for (const field of Object.keys(rules.techFields)) { + if (!emp.researching[field]) emp.researching[field] = nextResearchTarget(rules, state, e, field); + const targetId = emp.researching[field]; + if (!targetId) continue; + emp.beakers[field] += beakers * (emp.alloc[field] ?? 0); + const tech = rules.techs[targetId]; + const cost = techCost(rules, tech, emp.knownInField[field]); + if (emp.beakers[field] >= cost) { + emp.beakers[field] -= cost; + grantTech(rules, state, e, targetId); + emp.researching[field] = nextResearchTarget(rules, state, e, field); + } + } +} + +// -------------------------------------------------------------------------- +// Colony turn + +// Allocation with MOO1 spillover: a channel that cannot use its share passes it +// on rather than burning it. Industry that has maxed its factories feeds the +// build queue; a clean colony's ecology money becomes research. Without this, +// a developed colony quietly wastes most of its output. +function processColony(rules, state, colony) { + const e = colony.empireIdx; + const emp = state.empires[e]; + const spec = rules.species[emp.speciesId]; + const comps = empireComponents(rules, state, e); + const skills = colonyLeaderSkills(rules, state, colony); + const eco = rules.economy; + const prod = colonyProduction(rules, state, colony); + + const share = {}; + for (const ch of CHANNELS) share[ch] = prod * (colony.sliders[ch] ?? 0); + + // --- Ecology: clean this turn's waste plus any backlog. + // + // Cleanup is MANDATORY. If the ecology slider does not cover it, the + // shortfall is taken pro-rata out of the other four channels — exactly the + // way MOO1's eco slider snaps up to the minimum on its own. Letting a colony + // simply not pay is a death spiral with no exit: unpaid waste cuts maximum + // population, the smaller population mothballs factories and produces less, + // which leaves even less to spend on the waste that is still there. + const wasteGen = effectiveFactories(rules, state, colony) * eco.wastePerFactory * spec.traits.ecologyMult; + const needUnits = colony.waste + wasteGen; + const costPerUnit = eco.wasteCleanupCost * comps.wasteMult + * buildingMult(rules, colony, 'ecology') * (skills.ecologyMult ?? 1); + let ecoSpill = 0; + if (needUnits <= 0 || costPerUnit <= 0) { + colony.waste = 0; + ecoSpill = share.ecology; + } else { + const cleanupCost = needUnits * costPerUnit; + if (share.ecology >= cleanupCost) { + colony.waste = 0; + ecoSpill = share.ecology - cleanupCost; + } else { + let shortfall = cleanupCost - share.ecology; + const donors = ['industry', 'ships', 'defense', 'research']; + const pool = donors.reduce((t, ch) => t + share[ch], 0); + if (pool > 0) { + const take = Math.min(shortfall, pool); + for (const ch of donors) { + share[ch] -= take * (share[ch] / pool); + } + shortfall -= take; + } + const paid = cleanupCost - shortfall; + colony.waste = Math.max(0, needUnits - paid / costPerUnit); + colony.ecoForced = Math.round(paid - share.ecology > 0 ? paid - Math.min(paid, prod * (colony.sliders.ecology ?? 0)) : 0); + } + } + + // --- Industry: factories, capped by population. + const factoryCost = eco.factoryCost * comps.factoryCostMult; + const capF = colonyFactoryCap(rules, state, colony); + let indSpill = 0; + if (colony.factories >= capF) { + indSpill = share.industry; + } else { + const built = Math.min(capF - colony.factories, share.industry / factoryCost); + colony.factories += built; + indSpill = Math.max(0, share.industry - built * factoryCost); + } + + // --- Defence: planetary batteries, capped by tech. + const capD = colonyDefenseCap(rules, state, colony); + let defSpill = 0; + if (colony.defenseHp >= capD) { + defSpill = share.defense; + } else { + const added = Math.min(capD - colony.defenseHp, share.defense); + colony.defenseHp += added; + defSpill = Math.max(0, share.defense - added); + } + + // --- Construction: the build queue (ships and buildings). + let build = share.ships + indSpill + defSpill; + let buildSpill = 0; + let guard = 0; + while (build > 0 && colony.queue.length > 0 && guard < 20) { + guard += 1; + const item = colony.queue[0]; + const cost = queueItemCost(rules, state, colony, item); + const need = cost - item.progress; + if (build >= need) { + build -= need; + completeQueueItem(rules, state, colony, item); + colony.queue.shift(); + } else { + item.progress += build; + build = 0; + } + } + if (colony.queue.length === 0) buildSpill = build; + + // --- Research absorbs everything left over. + const research = share.research + ecoSpill + buildSpill; + + // --- Population. + const maxPop = colonyMaxPop(rules, state, colony); + const growth = colony.pop * eco.growthRateBase + * (1 - colony.pop / Math.max(1, maxPop)) + * spec.traits.growthMult + * buildingEffectMult(rules, colony, 'growthMult') + * (skills.growthMult ?? 1); + colony.pop = Math.max(0.5, Math.min(maxPop, colony.pop + growth)); + + return { research, trade: colonyTrade(rules, state, colony) }; +} + +export function queueItemCost(rules, state, colony, item) { + const emp = state.empires[colony.empireIdx]; + const spec = rules.species[emp.speciesId]; + if (item.kind === 'building') return rules.buildings[item.id].cost; + return empireDesign(rules, state, colony.empireIdx, item.id).cost; +} + +function completeQueueItem(rules, state, colony, item) { + const e = colony.empireIdx; + if (item.kind === 'building') { + if (!colony.buildings.includes(item.id)) colony.buildings.push(item.id); + pushEvent(state, { type: 'buildingDone', empire: e, colonyId: colony.id, buildingId: item.id, starIdx: colony.starIdx, turn: state.turn }); + return; + } + const emp = state.empires[e]; + const mark = markFor(rules, emp.known); + if (item.id === 'starbase') { + if (!colony.buildings.includes('starbase')) colony.buildings.push('starbase'); + emp._range = null; emp._rangeAt = -1; + } + addFleet(rules, state, e, colony.starIdx, [{ hullId: item.id, mark, count: 1 }]); + pushEvent(state, { type: 'shipDone', empire: e, colonyId: colony.id, hullId: item.id, starIdx: colony.starIdx, turn: state.turn }); +} + +// -------------------------------------------------------------------------- +// Empire turn + +export function beginEmpireTurn(rules, state, e) { + const emp = state.empires[e]; + if (!emp.alive) return; + const spec = rules.species[emp.speciesId]; + const diff = rules.difficulties[state.difficultyId]; + const comps = empireComponents(rules, state, e); + + let research = 0; + let trade = 0; + let upkeep = 0; + for (const colony of empireColonies(state, e)) { + const r = processColony(rules, state, colony); + research += r.research; + trade += r.trade; + for (const bid of colony.buildings) upkeep += rules.buildings[bid]?.upkeep ?? 0; + } + // Fleets cost a fraction of their build cost every turn. + for (const f of empireFleets(state, e)) { + for (const s of f.ships) { + upkeep += empireDesign(rules, state, e, s.hullId).cost + * (rules.economy.shipUpkeepFraction ?? 0.02) * s.count; + } + } + for (const l of emp.leaders) upkeep += rules.leaders[l.leaderId].upkeep; + + emp.bc = Math.max(0, emp.bc + trade - upkeep); + emp.lastIncome = trade - upkeep; + + const researchMult = spec.traits.researchMult + * comps.researchMult + * (emp.isHuman ? diff.humanResearchMult : diff.aiResearchMult); + processResearch(rules, state, e, research * researchMult); + + consolidateFleets(rules, state, e); + updateContacts(rules, state); + autoRefit(rules, state, e); + runEspionage(rules, state, e); + recomputeTotals(rules, state); +} + +// Ships sitting over a friendly colony are brought up to the current Mark, paid +// for out of the reserve. This is the whole reason preset hulls still feel +// connected to the tech tree: research a better gun and your existing fleet +// visibly improves, with a report line to say so. +function autoRefit(rules, state, e) { + const emp = state.empires[e]; + const spec = rules.species[emp.speciesId]; + const mark = markFor(rules, emp.known); + for (const f of empireFleets(state, e)) { + if (f.starIdx < 0 || f.toStar >= 0) continue; + const colony = state.colonies.find((c) => c.starIdx === f.starIdx && c.empireIdx === e); + if (!colony) continue; + for (const s of f.ships) { + if (s.mark >= mark) continue; + const cost = refitCost(rules, emp.known, s.hullId, s.mark, spec.traits) * s.count; + if (emp.bc < cost) continue; + emp.bc -= cost; + const from = s.mark; + s.mark = mark; + pushEvent(state, { + type: 'refit', empire: e, starIdx: f.starIdx, hullId: s.hullId, + count: s.count, fromMark: from, toMark: mark, cost: Math.round(cost), turn: state.turn, + }); + } + } +} + +// Espionage is passive: a species with spies gets periodic attempts against +// empires it has met, and steals a tech it could not research itself. +function runEspionage(rules, state, e) { + const emp = state.empires[e]; + const spec = rules.species[emp.speciesId]; + const comps = empireComponents(rules, state, e); + let power = (spec.traits.espionage ?? 0) + comps.espionage; + for (const c of empireColonies(state, e)) power += buildingEffect(rules, c, 'espionage'); + if (power <= 0) return; + + // Only the surplus over a baseline counts. A Battle-Scanner-and-nothing-else + // empire is not a spy agency: crediting its raw score let EVERY empire steal + // its way to the full tech tree over a long game, which made the per-species + // availability roll — the whole reason tech trading exists — meaningless. + const net = power - 25; + if (net <= 0) return; + emp.spyPoints += net / 300; + if (emp.spyPoints < 1) return; + emp.spyPoints -= 1; + + const targets = state.empires.filter((o) => o.alive && o.idx !== e + && emp.contacted[o.idx] && emp.treaties[o.idx] !== 'alliance'); + if (!targets.length) return; + const target = targets[randInt(state, targets.length)]; + const tspec = rules.species[target.speciesId]; + let defence = (tspec.traits.counterEspionage ?? 0) + empireComponents(rules, state, target.idx).counterEspionage; + for (const c of empireColonies(state, target.idx)) defence += buildingEffect(rules, c, 'espionage'); + + const odds = Math.max(0.05, Math.min(0.8, 0.4 + (power - defence) / 200)); + if (rand(state) > odds) { + pushEvent(state, { type: 'spyCaught', empire: e, target: target.idx, turn: state.turn }); + target.attitude[e] = (target.attitude[e] ?? 0) - 12; + return; + } + const stealable = rules.techList.filter((t) => target.known[t.id] && !emp.known[t.id]); + if (!stealable.length) return; + const tech = stealable[randInt(state, stealable.length)]; + grantTech(rules, state, e, tech.id, 'espionage'); + pushEvent(state, { type: 'techStolen', empire: e, target: target.idx, techId: tech.id, turn: state.turn }); + target.attitude[e] = (target.attitude[e] ?? 0) - 8; +} + +function recomputeTotals(rules, state) { + for (const emp of state.empires) emp.totalPop = 0; + for (const c of state.colonies) state.empires[c.empireIdx].totalPop += c.pop; +} + +// -------------------------------------------------------------------------- +// Movement, arrival, combat + +export function canSendFleet(rules, state, fleet, toStar) { + if (fleet.toStar >= 0) return false; + if (fleet.starIdx === toStar) return false; + if (fleetSpeed(rules, state, fleet) <= 0) return false; + const reach = reachableStars(rules, state, fleet.empireIdx); + return !!reach[toStar]; +} + +export function sendFleet(rules, state, fleet, toStar) { + if (!canSendFleet(rules, state, fleet, toStar)) return false; + // Star bases defend the system they were built in and do not sail with the + // fleet; split them into a garrison that stays put. + const garrison = []; + fleet.ships = fleet.ships.filter((s) => { + const d = empireDesign(rules, state, fleet.empireIdx, s.hullId); + if (!d.immobile) return true; + garrison.push(s); + return false; + }); + if (garrison.length) { + state.fleets.push({ + id: state.nextFleetId += 1, + empireIdx: fleet.empireIdx, + starIdx: fleet.starIdx, + fromStar: -1, toStar: -1, progress: 0, total: 0, + ships: garrison, + }); + } + fleet.fromStar = fleet.starIdx; + fleet.toStar = toStar; + fleet.total = parsecs(state.galaxy, fleet.starIdx, toStar); + fleet.progress = 0; + fleet.starIdx = -1; + return true; +} + +export function fleetEta(rules, state, fleet) { + if (fleet.toStar < 0) return 0; + const speed = fleetSpeed(rules, state, fleet); + if (speed <= 0) return Infinity; + return Math.max(1, Math.ceil((fleet.total - fleet.progress) / speed)); +} + +function moveFleets(rules, state, e) { + for (const f of empireFleets(state, e)) { + if (f.toStar < 0) continue; + f.progress += fleetSpeed(rules, state, f); + if (f.progress >= f.total - 1e-9) { + f.starIdx = f.toStar; + f.toStar = -1; + f.fromStar = -1; + f.progress = 0; + f.total = 0; + state.empires[e].explored[f.starIdx] = true; + pushEvent(state, { type: 'arrive', empire: e, starIdx: f.starIdx, fleetId: f.id, turn: state.turn }); + makeContactAt(rules, state, e, f.starIdx); + } + } +} + +// Empires also notice each other simply by living nearby — you do not need to +// fly a ship into someone's home system to know they are there. Contact used to +// require exactly that, so in most galaxies nobody ever met anybody, which meant +// no diplomacy, no wars, and every game grinding out to the turn cap. +export function updateContacts(rules, state) { + const range = rules.economy.contactRange ?? 12; + const alive = state.empires.filter((e) => e.alive); + for (let i = 0; i < alive.length; i += 1) { + for (let j = i + 1; j < alive.length; j += 1) { + const a = alive[i]; + const b = alive[j]; + if (a.contacted[b.idx]) continue; + let met = false; + for (const ca of empireColonies(state, a.idx)) { + for (const cb of empireColonies(state, b.idx)) { + if (parsecs(state.galaxy, ca.starIdx, cb.starIdx) <= range) { met = true; break; } + } + if (met) break; + } + if (!met) continue; + a.contacted[b.idx] = true; + b.contacted[a.idx] = true; + pushEvent(state, { type: 'contact', empire: a.idx, other: b.idx, starIdx: -1, turn: state.turn }); + } + } +} + +function makeContactAt(rules, state, e, starIdx) { + const others = new Set(); + for (const c of coloniesAt(state, starIdx)) if (c.empireIdx !== e) others.add(c.empireIdx); + for (const f of fleetsAt(state, starIdx)) if (f.empireIdx !== e) others.add(f.empireIdx); + for (const o of others) { + if (state.empires[e].contacted[o]) continue; + state.empires[e].contacted[o] = true; + state.empires[o].contacted[e] = true; + pushEvent(state, { type: 'contact', empire: e, other: o, starIdx, turn: state.turn }); + } +} + +export function atWar(state, a, b) { + return state.empires[a]?.treaties?.[b] === 'war'; +} + +// Resolve every star where hostile forces now share orbit. Called once per +// empire turn after movement, so a fleet that arrives is engaged immediately. +export function resolveCombats(rules, state) { + const results = []; + const byStar = new Map(); + for (const f of state.fleets) { + if (f.starIdx < 0) continue; + if (!byStar.has(f.starIdx)) byStar.set(f.starIdx, []); + byStar.get(f.starIdx).push(f); + } + for (const [starIdx, fleets] of byStar) { + const empires = [...new Set(fleets.map((f) => f.empireIdx))]; + const colony = colonyAt(state, starIdx); + const defenderIdx = colony ? colony.empireIdx : null; + + for (const a of empires) { + for (const b of empires) { + if (a >= b) continue; + if (!atWar(state, a, b)) continue; + const result = fightAt(rules, state, starIdx, a, b); + if (result) results.push(result); + } + } + // A hostile fleet in orbit of a defended colony must also fight the planet. + if (colony) { + for (const a of empires) { + if (a === defenderIdx) continue; + if (!atWar(state, a, defenderIdx)) continue; + if (state.fleets.some((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx && f.ships.length)) continue; + if (colony.defenseHp <= 0) continue; + const result = fightAt(rules, state, starIdx, a, defenderIdx); + if (result) results.push(result); + } + } + } + cleanFleets(state); + return results; +} + +function collectShips(rules, state, starIdx, e) { + const ships = []; + for (const f of state.fleets) { + if (f.starIdx !== starIdx || f.empireIdx !== e) continue; + for (const s of f.ships) { + const m = ships.find((x) => x.hullId === s.hullId && x.mark === s.mark); + if (m) m.count += s.count; + else ships.push({ hullId: s.hullId, mark: s.mark, count: s.count }); + } + } + return ships; +} + +function applyBattleLosses(rules, state, starIdx, e, survivors) { + const want = new Map(); + for (const s of survivors) want.set(`${s.hullId}|${s.mark}`, s.count); + for (const f of state.fleets) { + if (f.starIdx !== starIdx || f.empireIdx !== e) continue; + for (const s of f.ships) { + const key = `${s.hullId}|${s.mark}`; + const left = want.get(key) ?? 0; + const keep = Math.min(s.count, left); + want.set(key, left - keep); + s.count = keep; + } + } +} + +// Build the battle object for a pair at a system, WITHOUT resolving it. +// Split out of fightAt so the player can drive a battle round by round through +// VegaCombatView and then hand the outcome back — the interactive battle and +// auto-resolve therefore run the same engine and cannot diverge. +export function prepareBattleAt(rules, state, starIdx, a, b) { + const colony = colonyAt(state, starIdx); + const defenderIdx = colony && (colony.empireIdx === a || colony.empireIdx === b) ? colony.empireIdx : b; + const attackerIdx = defenderIdx === a ? b : a; + + const mkSide = (idx) => { + const emp = state.empires[idx]; + return { + empireIdx: idx, + name: emp.name, + empire: { known: emp.known, traits: rules.species[emp.speciesId].traits }, + ships: collectShips(rules, state, starIdx, idx), + }; + }; + const attacker = mkSide(attackerIdx); + const defender = mkSide(defenderIdx); + const defColony = colony && colony.empireIdx === defenderIdx ? colony : null; + if (!attacker.ships.length) return null; + if (!defender.ships.length && !(defColony && defColony.defenseHp > 0)) return null; + + const battle = createBattle(rules, { + attacker, + defender, + colony: defColony ? { defenseHp: defColony.defenseHp, shieldBonus: 0 } : null, + starIdx, + rnd: () => rand(state), + }); + return { battle, starIdx, attackerIdx, defenderIdx, colony: defColony }; +} + +// Write a finished battle's result back into the game state. +export function applyBattleOutcome(rules, state, prepared, result) { + const { starIdx, attackerIdx, defenderIdx, colony } = prepared; + applyBattleLosses(rules, state, starIdx, attackerIdx, result.attackerSurvivors); + applyBattleLosses(rules, state, starIdx, defenderIdx, result.defenderSurvivors); + if (colony) colony.defenseHp = result.planetDefenseLeft; + + const loser = result.winner === 'attacker' ? defenderIdx : attackerIdx; + if (result.winner !== 'draw') retreatFrom(rules, state, starIdx, loser); + + pushEvent(state, { + type: 'combat', starIdx, attacker: attackerIdx, defender: defenderIdx, + winner: result.winner, rounds: result.rounds, + attackerLosses: result.attackerLosses, defenderLosses: result.defenderLosses, + turn: state.turn, + }); + cleanFleets(state); + return result; +} + +// Systems where `e` is about to fight this turn. The scene calls this after +// movement so it can hand the player's own battles to the tactical view. +export function pendingBattlesFor(rules, state, e) { + const out = []; + const seen = new Set(); + for (const f of state.fleets) { + if (f.starIdx < 0 || f.empireIdx !== e) continue; + if (seen.has(f.starIdx)) continue; + seen.add(f.starIdx); + const foes = new Set(); + for (const g of state.fleets) { + if (g.starIdx === f.starIdx && g.empireIdx !== e && atWar(state, e, g.empireIdx)) foes.add(g.empireIdx); + } + const colony = colonyAt(state, f.starIdx); + if (colony && colony.empireIdx !== e && atWar(state, e, colony.empireIdx) && colony.defenseHp > 0) { + foes.add(colony.empireIdx); + } + for (const other of foes) out.push({ starIdx: f.starIdx, other }); + } + return out; +} + +function fightAt(rules, state, starIdx, a, b) { + const prepared = prepareBattleAt(rules, state, starIdx, a, b); + if (!prepared) return null; + return applyBattleOutcome(rules, state, prepared, runBattle(prepared.battle)); +} + +function retreatFrom(rules, state, starIdx, e) { + const own = empireColonies(state, e); + if (!own.length) { + for (const f of state.fleets) { + if (f.starIdx === starIdx && f.empireIdx === e) f.ships = []; + } + return; + } + let best = own[0].starIdx; + let bestD = Infinity; + for (const c of own) { + const d = parsecs(state.galaxy, starIdx, c.starIdx); + if (d < bestD) { bestD = d; best = c.starIdx; } + } + for (const f of state.fleets) { + if (f.starIdx !== starIdx || f.empireIdx !== e) continue; + if (best === starIdx) continue; + f.fromStar = starIdx; + f.toStar = best; + f.total = bestD; + f.progress = 0; + f.starIdx = -1; + } +} + +// -------------------------------------------------------------------------- +// Colonisation and invasion + +export function colonize(rules, state, e, starIdx, orbit) { + if (!canColonize(rules, state, e, starIdx, orbit)) return false; + const fleet = state.fleets.find((f) => f.starIdx === starIdx && f.empireIdx === e + && f.ships.some((s) => s.hullId === 'colonyship' && s.count > 0)); + if (!fleet) return false; + const stack = fleet.ships.find((s) => s.hullId === 'colonyship' && s.count > 0); + stack.count -= 1; + cleanFleets(state); + const colony = foundColony(rules, state, e, starIdx, orbit, 5); + pushEvent(state, { type: 'colonised', empire: e, starIdx, orbit, colonyId: colony.id, turn: state.turn }); + state.empires[e]._range = null; + state.empires[e]._rangeAt = -1; + return true; +} + +// Orbital superiority at a system: our combat power there exceeds theirs. +// Bombardment and invasion both require it. +export function holdsOrbit(rules, state, e, starIdx, defenderIdx) { + const defPower = state.fleets + .filter((f) => f.starIdx === starIdx && f.empireIdx === defenderIdx) + .reduce((t, f) => t + fleetPower(rules, state, f), 0); + const attPower = state.fleets + .filter((f) => f.starIdx === starIdx && f.empireIdx === e) + .reduce((t, f) => t + fleetPower(rules, state, f), 0); + if (attPower <= 0) return false; + return defPower <= 0 || attPower > defPower; +} + +// Bombard a colony from orbit, killing population. +// +// This is MOO1's answer to a cornered empire, and without it conquest is +// literally unreachable: an empire reduced to one fortified homeworld survives +// forever, because its beaten fleet always retreats back to that same world and +// denies the attacker the clean orbit an invasion needs. Soaked to 2500 turns, +// not one empire in eight games was ever eliminated. Bombing also thins the +// defenders for a subsequent landing, so the two mechanics work together. +export function bombard(rules, state, e, starIdx) { + const colony = colonyAt(state, starIdx); + if (!colony || colony.empireIdx === e) return null; + if (!atWar(state, e, colony.empireIdx)) return null; + if (!holdsOrbit(rules, state, e, starIdx, colony.empireIdx)) return null; + + const emp = state.empires[e]; + const spec = rules.species[emp.speciesId]; + let damage = 0; + let cracker = false; + for (const f of state.fleets) { + if (f.starIdx !== starIdx || f.empireIdx !== e) continue; + for (const st of f.ships) { + const d = empireDesign(rules, state, e, st.hullId); + if (d.role !== 'warship') continue; + damage += d.damage * st.count; + if (d.planetCracker) cracker = true; + } + } + if (damage <= 0) return null; + + const comps = empireComponents(rules, state, e); + const shield = colony.buildings.includes('planetaryshield') + ? comps.planetaryShield + 5 : comps.planetaryShield; + const kill = (damage * (rules.combat.bombardPopKill ?? 0.5)) / (1 + shield * 0.15); + const before = colony.pop; + colony.pop = Math.max(0, colony.pop - kill); + // A Stellar Converter cracks the crust; nothing is left to invade. + if (cracker) colony.pop = Math.max(0, colony.pop - kill); + + pushEvent(state, { + type: 'bombard', empire: e, target: colony.empireIdx, starIdx, + killed: Math.round(before - colony.pop), cracker, turn: state.turn, + }); + + if (colony.pop < 1) { + const owner = colony.empireIdx; + state.colonies = state.colonies.filter((c) => c !== colony); + pushEvent(state, { type: 'colonyDestroyed', empire: e, target: owner, starIdx, turn: state.turn }); + recomputeTotals(rules, state); + checkElimination(rules, state, owner); + return { destroyed: true, killed: Math.round(before) }; + } + recomputeTotals(rules, state); + return { destroyed: false, killed: Math.round(before - colony.pop) }; +} + +// What a landing at this system would look like right now. Exported because +// both the AI and the human's invade button need the same forecast — the AI +// was committing every transport it had to hopeless landings (1971 failures +// against 130 successes) purely because nothing told it the odds. +export function invasionForecast(rules, state, e, starIdx) { + const colony = colonyAt(state, starIdx); + if (!colony || colony.empireIdx === e) return null; + const troops = state.fleets + .filter((f) => f.starIdx === starIdx && f.empireIdx === e) + .reduce((t, f) => t + f.ships + .filter((s) => s.hullId === 'transport') + .reduce((n, s) => n + s.count, 0), 0) * (rules.hulls.transport.troops ?? 4); + if (troops <= 0) return { troops: 0, defenders: 0, odds: 0, favourable: false }; + + const spec = rules.species[state.empires[e].speciesId]; + const comps = empireComponents(rules, state, e); + const attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0); + const defenceBonus = colonyGroundDefense(rules, state, colony) + colony.defenseHp / 45; + const defenders = Math.max(1, Math.round(colony.pop / 8)) + Math.round(defenceBonus / 10); + const odds = Math.max(0.1, Math.min(0.9, + 0.5 + (attackBonus - defenceBonus) * (rules.combat.groundOddsScale ?? 0.01))); + // Each round is one duel; the attacker needs `defenders` wins before it takes + // `troops` losses. Expected exchange favours the landing when this holds. + const favourable = troops * odds > defenders * (1 - odds) * 1.25; + return { troops, defenders, odds, favourable }; +} + +export function invade(rules, state, e, starIdx) { + const colony = colonyAt(state, starIdx); + if (!colony || colony.empireIdx === e) return null; + if (!atWar(state, e, colony.empireIdx)) return null; + // Holding orbit means orbital SUPERIORITY, not an empty sky. + // + // Two versions of this check killed conquest outright. Requiring defenceHp to + // be zero never opened a window at all — combat resolves on the attacker's + // turn, and the defender's own turn rebuilds the batteries before the + // attacker acts again. Requiring literally no enemy hull present failed the + // same way for the same reason: a besieged colony finishes a ship every few + // turns, and that one fresh hull blocked the landing indefinitely. Measured + // over six games there were 1268 colony-turns under hostile orbit and exactly + // two invasion attempts. + const defPower = state.fleets + .filter((f) => f.starIdx === starIdx && f.empireIdx === colony.empireIdx) + .reduce((t, f) => t + fleetPower(rules, state, f), 0); + const attPower = state.fleets + .filter((f) => f.starIdx === starIdx && f.empireIdx === e) + .reduce((t, f) => t + fleetPower(rules, state, f), 0); + if (defPower > 0 && attPower <= defPower) return null; + + const fleet = state.fleets.find((f) => f.starIdx === starIdx && f.empireIdx === e + && f.ships.some((s) => s.hullId === 'transport' && s.count > 0)); + if (!fleet) return null; + const stack = fleet.ships.find((s) => s.hullId === 'transport' && s.count > 0); + + const emp = state.empires[e]; + const spec = rules.species[emp.speciesId]; + const comps = empireComponents(rules, state, e); + const troops = stack.count * (rules.hulls.transport.troops ?? 4); + const attackBonus = (spec.traits.groundAttack ?? 0) + (comps.groundAttack ?? 0); + // Intact orbital batteries shell the landing zones, but only as a modifier — + // at a twelfth of their hit points they alone drove the attacker's odds to + // the 10% floor and only 21 of 268 landings succeeded. + const defenceBonus = colonyGroundDefense(rules, state, colony) + colony.defenseHp / 45; + + const result = resolveInvasion(rules, () => rand(state), troops, attackBonus, + { groundDefense: defenceBonus }, defenceBonus, colony.pop); + + stack.count = 0; + cleanFleets(state); + + if (result.captured) { + const from = colony.empireIdx; + colony.empireIdx = e; + colony.capital = false; + colony.pop = Math.max(1, colony.pop * 0.5); + colony.queue = []; + colony.defenseHp = 0; + pushEvent(state, { type: 'captured', empire: e, from, starIdx, colonyId: colony.id, turn: state.turn }); + state.empires[from].attitude[e] = (state.empires[from].attitude[e] ?? 0) - 40; + state.empires[e]._range = null; state.empires[e]._rangeAt = -1; + checkElimination(rules, state, from); + } else { + pushEvent(state, { type: 'invasionFailed', empire: e, starIdx, turn: state.turn }); + } + recomputeTotals(rules, state); + return result; +} + +function checkElimination(rules, state, e) { + const emp = state.empires[e]; + if (!emp.alive) return; + if (empireColonies(state, e).length > 0) return; + emp.alive = false; + // An empire with no colonies has nothing to resupply from, so its ships are + // scuttled. Leaving them on the map produced fleets belonging to a dead + // empire that nothing would ever clean up. + state.fleets = state.fleets.filter((f) => f.empireIdx !== e); + pushEvent(state, { type: 'eliminated', empire: e, turn: state.turn }); +} + +// -------------------------------------------------------------------------- +// Galactic Council + +// Share of the SETTLEABLE galaxy that is settled. Measuring against every star +// made the Council unreachable: a third of systems hold nothing but gas giants +// and asteroid belts, so "half the galaxy colonised" could never be true no +// matter how completely the map was carved up. +export function colonizedFraction(state) { + const rules = state.rules; + let habitable = 0; + for (const star of state.galaxy.stars) { + const ok = star.planets.some((p) => (rules + ? rules.planetTypes[p.typeId].colonizable + : true)); + if (ok) habitable += 1; + } + const owned = new Set(state.colonies.map((c) => c.starIdx)); + return owned.size / Math.max(1, habitable); +} + +export function runCouncil(rules, state) { + const alive = state.empires.filter((e) => e.alive); + if (alive.length < 2) return null; + const totalPop = alive.reduce((t, e) => t + e.totalPop, 0); + if (totalPop <= 0) return null; + + // MOO1's rule: the two largest empires stand for High Guardian and everyone + // else votes between them. Letting every empire stand meant every empire + // simply voted for itself, so the Council convened forever and elected nobody + // — thirty sessions in an 800-turn game, all of them null. + const candidates = alive.slice().sort((a, b) => b.totalPop - a.totalPop || a.idx - b.idx).slice(0, 2); + const votes = {}; + for (const c of candidates) votes[c.idx] = 0; + let abstained = 0; + + for (const voter of alive) { + const weight = voter.totalPop; + const own = candidates.find((c) => c.idx === voter.idx); + if (own) { votes[own.idx] += weight; continue; } + let best = null; + let bestScore = -Infinity; + for (const cand of candidates) { + // Being at war with a candidate is disqualifying on its own. + const score = (voter.attitude[cand.idx] ?? 0) - (atWar(state, voter.idx, cand.idx) ? 60 : 0); + if (score > bestScore) { bestScore = score; best = cand; } + } + if (!best || bestScore < (rules.council.abstainAttitude ?? -20)) { abstained += weight; continue; } + votes[best.idx] += weight; + } + + let winner = -1; + for (const c of candidates) { + if (votes[c.idx] / totalPop >= rules.council.winFraction) winner = c.idx; + } + + // MOO1's rule: the defeated candidate may REFUSE TO SUBMIT. An empire that is + // at war with the winner, or simply hates them, walks out of the chamber and + // the election is void — and everyone who refused is now at war with the + // presumptive High Guardian. + // + // This is what keeps conquest reachable. Without it a dominant empire always + // won its own council vote a hundred turns before it could finish a war, and + // no game in a 24-game soak ever ended by conquest. + let refused = false; + if (winner >= 0) { + const loser = candidates.find((c) => c.idx !== winner); + const bitter = loser && (atWar(state, loser.idx, winner) + || (loser.attitude[winner] ?? 0) < (rules.council.submitAttitude ?? -25)); + if (bitter) { + refused = true; + pushEvent(state, { type: 'councilRefused', empire: loser.idx, winner, turn: state.turn }); + if (!atWar(state, loser.idx, winner)) declareWar(rules, state, loser.idx, winner); + // Everyone who withheld their vote resents the near-coronation. + for (const o of alive) { + if (o.idx === winner) continue; + o.attitude[winner] = Math.max(-100, (o.attitude[winner] ?? 0) - 15); + } + winner = -1; + } + } + + const result = { turn: state.turn, votes, totalPop, abstained, winner, refused, candidates: candidates.map((c) => c.idx) }; + state.council.lastResult = result; + state.council.history.push(result); + state.council.nextTurn = state.turn + rules.council.interval; + pushEvent(state, { type: 'council', ...result }); + if (winner >= 0) { + state.over = true; + state.winnerIdx = winner; + state.victoryKind = 'council'; + pushEvent(state, { type: 'victory', empire: winner, kind: 'council', turn: state.turn }); + } + return result; +} + +function checkVictory(rules, state) { + if (state.over) return; + const alive = state.empires.filter((e) => e.alive); + if (alive.length <= 1) { + state.over = true; + state.winnerIdx = alive.length ? alive[0].idx : -1; + state.victoryKind = 'conquest'; + pushEvent(state, { type: 'victory', empire: state.winnerIdx, kind: 'conquest', turn: state.turn }); + return; + } + if (state.turn >= (rules.victory.turnCap ?? 800)) { + // Out of time: the largest empire is declared dominant so a game always + // terminates. The soak relies on this. + const best = alive.reduce((x, y) => (y.totalPop > x.totalPop ? y : x), alive[0]); + state.over = true; + state.winnerIdx = best.idx; + state.victoryKind = 'timeout'; + pushEvent(state, { type: 'victory', empire: best.idx, kind: 'timeout', turn: state.turn }); + } +} + +// -------------------------------------------------------------------------- +// Turn sequencing + +export function moveFleetsFor(rules, state, e) { moveFleets(rules, state, e); } + +export function endEmpireTurn(rules, state, e, { skipMove = false } = {}) { + if (state.empires[e].alive) { + if (!skipMove) moveFleets(rules, state, e); + resolveCombats(rules, state); + for (const emp of state.empires) checkElimination(rules, state, emp.idx); + } + + let next = e; + for (let i = 0; i < state.empires.length; i += 1) { + next = (next + 1) % state.empires.length; + if (next === 0) { + state.turn += 1; + state.rules = rules; + if (rules.council.council !== false + && state.turn >= state.council.nextTurn + && colonizedFraction(state) >= rules.council.minColonizedFraction) { + runCouncil(rules, state); + } + // A galaxy where nobody ever fights and no Council vote carries will run + // to the turn cap and be decided by a tiebreak, which is the least + // interesting outcome available. Past a point, force the issue. + const nudge = rules.victory.stalemateTurn ?? 150; + if (!state.over && state.turn > nudge && state.turn % 40 === 0) breakStalemate(rules, state); + checkVictory(rules, state); + } + if (state.empires[next].alive) break; + } + state.current = next; +} + +// -------------------------------------------------------------------------- +// Player actions + +export function setSlider(rules, state, colony, channel, value) { + const v = Math.max(0, Math.min(1, value)); + const others = CHANNELS.filter((c) => c !== channel && !colony.locked[c]); + const lockedSum = CHANNELS.filter((c) => c !== channel && colony.locked[c]) + .reduce((t, c) => t + colony.sliders[c], 0); + const room = Math.max(0, 1 - lockedSum); + const target = Math.min(v, room); + colony.sliders[channel] = target; + const rest = room - target; + const otherSum = others.reduce((t, c) => t + colony.sliders[c], 0); + if (others.length === 0) return; + if (otherSum <= 0) { + for (const c of others) colony.sliders[c] = rest / others.length; + } else { + for (const c of others) colony.sliders[c] = (colony.sliders[c] / otherSum) * rest; + } +} + +export function setResearchAlloc(rules, state, e, field, value) { + const emp = state.empires[e]; + const fields = Object.keys(rules.techFields); + const v = Math.max(0, Math.min(1, value)); + emp.alloc[field] = v; + const others = fields.filter((f) => f !== field); + const rest = 1 - v; + const sum = others.reduce((t, f) => t + emp.alloc[f], 0); + if (sum <= 0) for (const f of others) emp.alloc[f] = rest / others.length; + else for (const f of others) emp.alloc[f] = (emp.alloc[f] / sum) * rest; +} + +export function enqueue(rules, state, colony, kind, id) { + if (kind === 'building') { + if (colony.buildings.includes(id)) return false; + if (colony.queue.some((q) => q.kind === 'building' && q.id === id)) return false; + const b = rules.buildings[id]; + const emp = state.empires[colony.empireIdx]; + if (b.prereq && !emp.known[b.prereq]) return false; + } + colony.queue.push({ kind, id, progress: 0 }); + return true; +} + +export function dequeue(rules, state, colony, index) { + if (index < 0 || index >= colony.queue.length) return false; + colony.queue.splice(index, 1); + return true; +} + +export function hireLeader(rules, state, e, leaderId) { + const emp = state.empires[e]; + const leader = rules.leaders[leaderId]; + if (!leader || emp.leaders.some((l) => l.leaderId === leaderId)) return false; + if (emp.bc < leader.hireCost) return false; + emp.bc -= leader.hireCost; + emp.leaders.push({ leaderId, assignKind: null, assignId: -1 }); + pushEvent(state, { type: 'leaderHired', empire: e, leaderId, turn: state.turn }); + return true; +} + +export function assignLeader(rules, state, e, leaderId, kind, id) { + const emp = state.empires[e]; + const l = emp.leaders.find((x) => x.leaderId === leaderId); + if (!l) return false; + const leader = rules.leaders[leaderId]; + if (leader.kind === 'admin' && kind !== 'colony') return false; + if (leader.kind === 'captain' && kind !== 'fleet') return false; + // One leader per posting. + for (const other of emp.leaders) { + if (other !== l && other.assignKind === kind && other.assignId === id) other.assignId = -1, other.assignKind = null; + } + l.assignKind = kind; + l.assignId = id; + return true; +} + +// -------------------------------------------------------------------------- +// Serialisation + +export function serialize(state) { + const { rules, ...rest } = state; + // Every underscore-prefixed field is a derived memo cache (component bags, + // range sets and the turn stamps that invalidate them). Dropping the values + // but keeping the stamps would make a reloaded game serialise differently + // from the one it was saved from, so the whole prefix goes. + return JSON.stringify(rest, (key, value) => (key.startsWith('_') ? undefined : value)); +} + +export function deserialize(json) { + const state = JSON.parse(json); + if (state.version !== 1) return null; + for (const emp of state.empires) { + emp._comps = null; emp._compsAt = -1; emp._range = null; emp._rangeAt = -1; + emp._designs = null; emp._designsAt = -1; + } + return state; +} + +export function hashState(state) { + const str = serialize(state); + let h = 2166136261; + for (let i = 0; i < str.length; i += 1) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return (h >>> 0).toString(16); +} diff --git a/src/games/mastervega/VegaNebula.js b/src/games/mastervega/VegaNebula.js new file mode 100644 index 0000000..445b267 --- /dev/null +++ b/src/games/mastervega/VegaNebula.js @@ -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(); }, + }; +} diff --git a/src/games/mastervega/VegaRules.js b/src/games/mastervega/VegaRules.js new file mode 100644 index 0000000..2413078 --- /dev/null +++ b/src/games/mastervega/VegaRules.js @@ -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); } diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js new file mode 100644 index 0000000..d34cc59 --- /dev/null +++ b/src/games/mastervega/VegaScreens.js @@ -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; +} diff --git a/src/games/mastervega/VegaShips.js b/src/games/mastervega/VegaShips.js new file mode 100644 index 0000000..92bf990 --- /dev/null +++ b/src/games/mastervega/VegaShips.js @@ -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)); +} diff --git a/src/games/mastervega/VegaStarMap.js b/src/games/mastervega/VegaStarMap.js new file mode 100644 index 0000000..1b06831 --- /dev/null +++ b/src/games/mastervega/VegaStarMap.js @@ -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(); + } +} diff --git a/src/games/mastervega/VegaSystemView.js b/src/games/mastervega/VegaSystemView.js new file mode 100644 index 0000000..b3a7f1f --- /dev/null +++ b/src/games/mastervega/VegaSystemView.js @@ -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; +} diff --git a/src/games/mastervega/sprites.md b/src/games/mastervega/sprites.md new file mode 100644 index 0000000..826f5f4 --- /dev/null +++ b/src/games/mastervega/sprites.md @@ -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 ~24–58 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 84–96 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 0–7 are +colony administrators, 8–15 are ship captains. Shown at 40–60 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 **0–5**: the six tech *fields* — Computers, Construction, Force Fields, + Planetology, Propulsion, Weapons. +- Frames **6–65**: the sixty individual techs, in the order they appear in + `data/mastervega-rules.json` (`techs[].iconFrame`). +- Frames 66–69: spare. + +--- + +## 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`). diff --git a/src/main.js b/src/main.js index 5e7c900..5b210d6 100644 --- a/src/main.js +++ b/src/main.js @@ -103,6 +103,7 @@ import BloxorzGame from './games/bloxorz/BloxorzGame.js'; import GooTowerGame from './games/gootower/GooTowerGame.js'; import GooTowerEditor from './games/gootower/GooTowerEditor.js'; import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js'; +import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js'; const config = { type: Phaser.AUTO, @@ -218,6 +219,7 @@ const config = { BloxorzGame, GooTowerGame, ExcitebikeGame, + MasterOfVegaGame, GooTowerEditor, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 8d664c6..1c6e38a 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } 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]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 05e77d7..77db825 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -76,6 +76,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('superkart-artwork', 'data/superkart-artwork.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('mastervega-artwork', 'data/mastervega-artwork.json'); this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3'); this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3'); diff --git a/src/services/soundtrack.js b/src/services/soundtrack.js index bc96c65..40dd62c 100644 --- a/src/services/soundtrack.js +++ b/src/services/soundtrack.js @@ -34,6 +34,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = { mahjong: 'chinese', mahjongmatch: 'chinese', zuma: 'zuma', + mastervega: 'hacker', }; // Resolve the track list (and optional volume override) a game scene's diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js new file mode 100644 index 0000000..3201d0a --- /dev/null +++ b/tools/verifyMasterOfVega.js @@ -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);