feat: add Master of Vega (Master of Orion clone)
Introduces Master of Vega, a turn-based 4X strategy game built on MOO1 rules with MOO2 conveniences. The implementation features a fully headless, deterministic engine (VegaLogic) with zero Phaser dependencies, enabling complete Node-based verification (809 checks) and reproducible galaxy generation via mulberry32 RNG. Key features: - Preset ship hulls with an auto-refitting Mark system and provably monotonic knapsack loadouts. - Tactical grid combat with banked damage resolution to eliminate first-strike bias. - Galactic Council diplomacy, stalemate-breaking, and invasion forecasting. - Drop-in artwork compatibility with procedural stand-ins for all spritesheets. - Hireable leaders (admin/captain) and colony building queues. Architecture splits cleanly into headless modules (GalaxyGen, Logic, AI, Combat, Diplomacy, Ships, Leaders) and a render tier (StarMap, SystemView, CombatView, Screens, Nebula, Art, Fx). A comprehensive build plan documents 19 architectural traps discovered and resolved during development. Registers the game in the registry, asset manifest, preload scene, and soundtrack service. Includes tools/verifyMasterOfVega.js for automated rule validation, art verification, economy balancing, and AI self-play soaking.
This commit is contained in:
parent
390dadbc14
commit
91f63b76a2
Binary file not shown.
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 349 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 299 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -71,6 +71,20 @@ export const MANIFEST = {
|
||||||
excitebike: [
|
excitebike: [
|
||||||
(scene) => musicFrom(scene, 'nintendo-music'),
|
(scene) => musicFrom(scene, 'nintendo-music'),
|
||||||
],
|
],
|
||||||
|
// Every sheet in mastervega-artwork.json starts with path:null and is painted
|
||||||
|
// procedurally by VegaArt.js, so this entry needs no edit when art arrives —
|
||||||
|
// only the JSON does.
|
||||||
|
mastervega: [
|
||||||
|
{ type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' },
|
||||||
|
(scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']),
|
||||||
|
image('vega-menu-bg', 'assets/images/vega/background-menu.png'),
|
||||||
|
image('vega-menu-title', 'assets/images/vega/menu-title.png'),
|
||||||
|
{ type: 'audio', key: 'laser-zap', path: 'assets/fx/laser-zap.mp3' },
|
||||||
|
{ type: 'audio', key: 'scifi-explode', path: 'assets/fx/scifi-explode.mp3' },
|
||||||
|
{ type: 'audio', key: 'ta-rocket-1', path: 'assets/fx/ta-rocket-1.mp3' },
|
||||||
|
{ type: 'audio', key: 'ta-rocket-2', path: 'assets/fx/ta-rocket-2.mp3' },
|
||||||
|
(scene) => musicFrom(scene, 'hacker-music'),
|
||||||
|
],
|
||||||
forbiddenisland: [
|
forbiddenisland: [
|
||||||
// Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded
|
// Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded
|
||||||
// frame 2i+1 (see IslandData.TILE_FRAME_ROW).
|
// frame 2i+1 (see IslandData.TILE_FRAME_ROW).
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,4 @@ registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category:
|
||||||
registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 89 });
|
registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 89 });
|
||||||
registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 });
|
registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 });
|
||||||
registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 });
|
registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 });
|
||||||
|
registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 });
|
||||||
|
|
|
||||||
|
|
@ -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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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(); } };
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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(); }
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
// Master of Vega — leaders (the MOO2 convenience).
|
||||||
|
//
|
||||||
|
// A leader is hired once, costs upkeep forever, and does nothing at all until
|
||||||
|
// posted. Admins run a colony; captains run a fleet. Headless.
|
||||||
|
|
||||||
|
import { empireColonies, empireFleets, hireLeader, assignLeader, fleetPower } from './VegaLogic.js';
|
||||||
|
|
||||||
|
// Leaders are a shared galactic pool — once an empire hires Nyx Holt, nobody
|
||||||
|
// else can. That makes the offer worth taking when it appears.
|
||||||
|
export function leaderTaken(state, leaderId) {
|
||||||
|
return state.empires.some((e) => e.leaders.some((l) => l.leaderId === leaderId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function availableLeaders(rules, state) {
|
||||||
|
return rules.leaderList.filter((l) => !leaderTaken(state, l.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which leaders are on offer to an empire this turn. Offers rotate on a fixed
|
||||||
|
// cycle derived from the turn and the empire index, so they are stable within a
|
||||||
|
// turn (the UI can render them repeatedly) without needing to live in state.
|
||||||
|
export function leaderOffers(rules, state, e, count = 3) {
|
||||||
|
const pool = availableLeaders(rules, state);
|
||||||
|
if (!pool.length) return [];
|
||||||
|
const cycle = Math.floor(state.turn / 10);
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < Math.min(count, pool.length); i += 1) {
|
||||||
|
out.push(pool[(cycle * 7 + e * 3 + i * 5) % pool.length]);
|
||||||
|
}
|
||||||
|
// De-duplicate in case the stride collides on a small pool.
|
||||||
|
return [...new Set(out)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leaderOf(rules, state, e, kind, id) {
|
||||||
|
const emp = state.empires[e];
|
||||||
|
const l = emp.leaders.find((x) => x.assignKind === kind && x.assignId === id);
|
||||||
|
return l ? rules.leaders[l.leaderId] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unassignedLeaders(rules, state, e) {
|
||||||
|
return state.empires[e].leaders.filter((l) => l.assignKind === null || l.assignId < 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI leader management: hire what is affordable and clearly useful, then post
|
||||||
|
// admins to the biggest colonies and captains to the strongest fleets.
|
||||||
|
export function runLeaderTurn(rules, state, e) {
|
||||||
|
const emp = state.empires[e];
|
||||||
|
// Never spend the last of the treasury on staff — ships and colonies first.
|
||||||
|
const budget = emp.bc * 0.35;
|
||||||
|
for (const offer of leaderOffers(rules, state, e)) {
|
||||||
|
if (emp.leaders.length >= 6) break;
|
||||||
|
if (offer.hireCost > budget) continue;
|
||||||
|
if (hireLeader(rules, state, e, offer.id)) break; // one hire per turn
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonies = empireColonies(state, e).slice().sort((a, b) => b.pop - a.pop);
|
||||||
|
const fleets = empireFleets(state, e)
|
||||||
|
.filter((f) => f.starIdx >= 0 || f.toStar >= 0)
|
||||||
|
.sort((a, b) => fleetPower(rules, state, b) - fleetPower(rules, state, a));
|
||||||
|
|
||||||
|
for (const l of emp.leaders) {
|
||||||
|
const def = rules.leaders[l.leaderId];
|
||||||
|
if (def.kind === 'admin') {
|
||||||
|
const posted = new Set(emp.leaders.filter((x) => x !== l && x.assignKind === 'colony').map((x) => x.assignId));
|
||||||
|
const target = colonies.find((c) => !posted.has(c.id));
|
||||||
|
if (target && l.assignId !== target.id) assignLeader(rules, state, e, l.leaderId, 'colony', target.id);
|
||||||
|
} else {
|
||||||
|
const posted = new Set(emp.leaders.filter((x) => x !== l && x.assignKind === 'fleet').map((x) => x.assignId));
|
||||||
|
const target = fleets.find((f) => !posted.has(f.id));
|
||||||
|
if (target && l.assignId !== target.id) assignLeader(rules, state, e, l.leaderId, 'fleet', target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A posting can go stale when a colony falls or a fleet is destroyed.
|
||||||
|
const colonyIds = new Set(colonies.map((c) => c.id));
|
||||||
|
const fleetIds = new Set(empireFleets(state, e).map((f) => f.id));
|
||||||
|
for (const l of emp.leaders) {
|
||||||
|
if (l.assignKind === 'colony' && !colonyIds.has(l.assignId)) { l.assignKind = null; l.assignId = -1; }
|
||||||
|
if (l.assignKind === 'fleet' && !fleetIds.has(l.assignId)) { l.assignKind = null; l.assignId = -1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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); }
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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`).
|
||||||
|
|
@ -103,6 +103,7 @@ import BloxorzGame from './games/bloxorz/BloxorzGame.js';
|
||||||
import GooTowerGame from './games/gootower/GooTowerGame.js';
|
import GooTowerGame from './games/gootower/GooTowerGame.js';
|
||||||
import GooTowerEditor from './games/gootower/GooTowerEditor.js';
|
import GooTowerEditor from './games/gootower/GooTowerEditor.js';
|
||||||
import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js';
|
import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js';
|
||||||
|
import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -218,6 +219,7 @@ const config = {
|
||||||
BloxorzGame,
|
BloxorzGame,
|
||||||
GooTowerGame,
|
GooTowerGame,
|
||||||
ExcitebikeGame,
|
ExcitebikeGame,
|
||||||
|
MasterOfVegaGame,
|
||||||
GooTowerEditor,
|
GooTowerEditor,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
const sceneKey = slugDispatch[this.game.slug];
|
const sceneKey = slugDispatch[this.game.slug];
|
||||||
const startData = {
|
const startData = {
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.json('superkart-artwork', 'data/superkart-artwork.json');
|
this.load.json('superkart-artwork', 'data/superkart-artwork.json');
|
||||||
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
|
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
|
||||||
this.load.json('star-control-ships', 'data/star-control-ships.json');
|
this.load.json('star-control-ships', 'data/star-control-ships.json');
|
||||||
|
this.load.json('mastervega-artwork', 'data/mastervega-artwork.json');
|
||||||
|
|
||||||
this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3');
|
this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3');
|
||||||
this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3');
|
this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3');
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
|
||||||
mahjong: 'chinese',
|
mahjong: 'chinese',
|
||||||
mahjongmatch: 'chinese',
|
mahjongmatch: 'chinese',
|
||||||
zuma: 'zuma',
|
zuma: 'zuma',
|
||||||
|
mastervega: 'hacker',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve the track list (and optional volume override) a game scene's
|
// Resolve the track list (and optional volume override) a game scene's
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
Loading…
Reference in New Issue