diff --git a/assets/fx/vega/vega-counter.mp3 b/assets/fx/vega/vega-counter.mp3 new file mode 100644 index 0000000..b0b71db Binary files /dev/null and b/assets/fx/vega/vega-counter.mp3 differ diff --git a/assets/fx/vega/vega-gnn.mp3 b/assets/fx/vega/vega-gnn.mp3 new file mode 100644 index 0000000..0324846 Binary files /dev/null and b/assets/fx/vega/vega-gnn.mp3 differ diff --git a/assets/music/vega/vega-gnn.mp3 b/assets/music/vega/vega-gnn.mp3 new file mode 100644 index 0000000..db309f7 Binary files /dev/null and b/assets/music/vega/vega-gnn.mp3 differ diff --git a/assets/videos/vega/colad-cerebrai.mp4 b/assets/videos/vega/colad-cerebrai.mp4 new file mode 100644 index 0000000..fb12580 Binary files /dev/null and b/assets/videos/vega/colad-cerebrai.mp4 differ diff --git a/assets/videos/vega/colad-human.mp4 b/assets/videos/vega/colad-human.mp4 new file mode 100644 index 0000000..5fc302a Binary files /dev/null and b/assets/videos/vega/colad-human.mp4 differ diff --git a/assets/videos/vega/colad-kestrelli.mp4 b/assets/videos/vega/colad-kestrelli.mp4 new file mode 100644 index 0000000..83992bb Binary files /dev/null and b/assets/videos/vega/colad-kestrelli.mp4 differ diff --git a/assets/videos/vega/colad-kkrix.mp4 b/assets/videos/vega/colad-kkrix.mp4 new file mode 100644 index 0000000..f9f4ec0 Binary files /dev/null and b/assets/videos/vega/colad-kkrix.mp4 differ diff --git a/assets/videos/vega/colad-lithox.mp4 b/assets/videos/vega/colad-lithox.mp4 new file mode 100644 index 0000000..f8baedf Binary files /dev/null and b/assets/videos/vega/colad-lithox.mp4 differ diff --git a/assets/videos/vega/colad-mekhan.mp4 b/assets/videos/vega/colad-mekhan.mp4 new file mode 100644 index 0000000..445655a Binary files /dev/null and b/assets/videos/vega/colad-mekhan.mp4 differ diff --git a/assets/videos/vega/colad-rrashaa.mp4 b/assets/videos/vega/colad-rrashaa.mp4 new file mode 100644 index 0000000..38035ed Binary files /dev/null and b/assets/videos/vega/colad-rrashaa.mp4 differ diff --git a/assets/videos/vega/colad-ssakar.mp4 b/assets/videos/vega/colad-ssakar.mp4 new file mode 100644 index 0000000..c93f220 Binary files /dev/null and b/assets/videos/vega/colad-ssakar.mp4 differ diff --git a/assets/videos/vega/colad-umbrix.mp4 b/assets/videos/vega/colad-umbrix.mp4 new file mode 100644 index 0000000..8406cad Binary files /dev/null and b/assets/videos/vega/colad-umbrix.mp4 differ diff --git a/assets/videos/vega/colad-ursaal.mp4 b/assets/videos/vega/colad-ursaal.mp4 new file mode 100644 index 0000000..6ec37a5 Binary files /dev/null and b/assets/videos/vega/colad-ursaal.mp4 differ diff --git a/assets/videos/vega/galactic-council.mp4 b/assets/videos/vega/galactic-council.mp4 new file mode 100644 index 0000000..b50280c Binary files /dev/null and b/assets/videos/vega/galactic-council.mp4 differ diff --git a/assets/videos/vega/gnn.mp4 b/assets/videos/vega/gnn.mp4 new file mode 100644 index 0000000..3237c01 Binary files /dev/null and b/assets/videos/vega/gnn.mp4 differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index d4862fb..3d59b1c 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -225,16 +225,16 @@ "_advisorVideosReadme": "Colonies-screen advisor loops (VegaColoniesScreen.js) — one per species, a distinct COLONIAL ADVISOR persona, not the leader portrait (portraitVideos) and not the research scientist (researchVideos). Muted, looping continuously. Recorded 1:1 (square), like the leader portraits, not the 2:3 research clips. JIT-loaded like colonyVideos/audienceVideos/researchVideos, NOT eager via assetManifest.js — the Colonies screen only ever shows the human player's own species. A species with no clip recorded falls back to its portraitVideos clip, then portraitStills — same three-tier fallback convention as makeSpeciesPortrait. To add or replace one: drop the file and set its path here; nothing else changes. Naming: assets/videos/vega/advisor-.mp4.", "advisorVideos": { - "human": { "key": "vega-advisor-human", "path": null }, - "kestrelli": { "key": "vega-advisor-kestrelli", "path": null }, - "ursaal": { "key": "vega-advisor-ursaal", "path": null }, - "umbrix": { "key": "vega-advisor-umbrix", "path": null }, - "kkrix": { "key": "vega-advisor-kkrix", "path": null }, - "mekhan": { "key": "vega-advisor-mekhan", "path": null }, - "rrashaa": { "key": "vega-advisor-rrashaa", "path": null }, - "cerebrai": { "key": "vega-advisor-cerebrai", "path": null }, - "ssakar": { "key": "vega-advisor-ssakar", "path": null }, - "lithox": { "key": "vega-advisor-lithox", "path": null } + "human": { "key": "vega-advisor-human", "path": "assets/videos/vega/colad-human.mp4" }, + "kestrelli": { "key": "vega-advisor-kestrelli", "path": "assets/videos/vega/colad-kestrelli.mp4" }, + "ursaal": { "key": "vega-advisor-ursaal", "path": "assets/videos/vega/colad-ursaal.mp4" }, + "umbrix": { "key": "vega-advisor-umbrix", "path": "assets/videos/vega/colad-umbrix.mp4" }, + "kkrix": { "key": "vega-advisor-kkrix", "path": "assets/videos/vega/colad-kkrix.mp4" }, + "mekhan": { "key": "vega-advisor-mekhan", "path": "assets/videos/vega/colad-mekhan.mp4" }, + "rrashaa": { "key": "vega-advisor-rrashaa", "path": "assets/videos/vega/colad-rrashaa.mp4" }, + "cerebrai": { "key": "vega-advisor-cerebrai", "path": "assets/videos/vega/colad-cerebrai.mp4" }, + "ssakar": { "key": "vega-advisor-ssakar", "path": "assets/videos/vega/colad-ssakar.mp4" }, + "lithox": { "key": "vega-advisor-lithox", "path": "assets/videos/vega/colad-lithox.mp4" } }, "sheets": { diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index e7a7cf8..1a47694 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -19,15 +19,15 @@ { "id": "optroniccomputer", "name": "Optronic Computer", "field": "computers", "tier": 2, "prereqs": ["battlescanner"], "cost": 400, "iconFrame": 8, "desc": "Mark II battle computer. +2 to hit.", "effects": { "targeting": 2 } }, { "id": "neuralscanner", "name": "Neural Scanner", "field": "computers", "tier": 3, "prereqs": ["optroniccomputer"], "cost": 900, "iconFrame": 9, "desc": "Deep-scan interrogation. Espionage missions far more likely.", "effects": { "espionage": 15 } }, { "id": "tachyoncomputer", "name": "Tachyon Computer", "field": "computers", "tier": 3, "prereqs": ["optroniccomputer"], "cost": 900, "iconFrame": 66, "desc": "Overclocked targeting matrix, no spycraft. +4 to hit.", "effects": { "targeting": 4 } }, - { "id": "positroniccomputer", "name": "Positronic Computer", "field": "computers", "tier": 4, "prereqs": ["neuralscanner"], "cost": 1800, "iconFrame": 10, "desc": "Mark III battle computer. +3 to hit.", "effects": { "targeting": 3 } }, + { "id": "positroniccomputer", "name": "Positronic Computer", "field": "computers", "tier": 4, "prereqs": ["neuralscanner"], "cost": 1800, "iconFrame": 10, "desc": "Mark III battle computer. +3 to hit.", "effects": { "targeting": 3 }, "gnnHeadline": true }, { "id": "cybersecuritylink", "name": "Cyber Security Link", "field": "computers", "tier": 5, "prereqs": ["positroniccomputer"], "cost": 3200, "iconFrame": 11, "desc": "Hardened colony networks. Enemy spies rarely get through.", "effects": { "counterEspionage": 25 } }, { "id": "adaptiveresearchalgorithms", "name": "Adaptive Research Algorithms", "field": "computers", "tier": 5, "prereqs": ["positroniccomputer"], "cost": 3200, "iconFrame": 79, "desc": "Machine-learning heuristics comb through lab data around the clock. Research output up 10%, though the network offers no extra hardening against spies.", "effects": { "researchMult": 1.1 } }, { "id": "emissionsguidance", "name": "Emissions Guidance", "field": "computers", "tier": 6, "prereqs": ["cybersecuritylink"], "cost": 5400, "iconFrame": 12, "desc": "Mark IV battle computer. +4 to hit.", "effects": { "targeting": 4 } }, { "id": "oracleinterface", "name": "Oracle Interface", "field": "computers", "tier": 7, "prereqs": ["emissionsguidance"], "cost": 8600, "iconFrame": 13, "desc": "Mark V battle computer, and research output climbs sharply.", "effects": { "targeting": 5, "researchMult": 1.15 } }, { "id": "omniscanarray", "name": "Omniscan Array", "field": "computers", "tier": 7, "prereqs": ["emissionsguidance"], "cost": 8600, "iconFrame": 67, "desc": "Full-spectrum battle sensors. +5 to hit and always strikes first.", "effects": { "targeting": 5, "initiative": 5 } }, - { "id": "cybertroniccomputer","name": "Cybertronic Computer", "field": "computers", "tier": 8, "prereqs": ["oracleinterface"], "cost": 13000, "iconFrame": 14, "desc": "Mark VI battle computer. +6 to hit.", "effects": { "targeting": 6 } }, + { "id": "cybertroniccomputer","name": "Cybertronic Computer", "field": "computers", "tier": 8, "prereqs": ["oracleinterface"], "cost": 13000, "iconFrame": 14, "desc": "Mark VI battle computer. +6 to hit.", "effects": { "targeting": 6 }, "gnnHeadline": true }, { "id": "deepfieldsensorarray","name": "Deep Field Sensor Array", "field": "computers", "tier": 8, "prereqs": ["oracleinterface"], "cost": 13000, "iconFrame": 80, "desc": "Long-baseline sensor net. Fleets see further and react faster, at the cost of raw fire-control precision.", "effects": { "scanRange": 2, "initiative": 2 } }, - { "id": "galacticcybernet", "name": "Galactic Cybernet", "field": "computers", "tier": 9, "prereqs": ["cybertroniccomputer"], "cost": 20000, "iconFrame": 15, "desc": "Mark VII battle computer and an empire-wide research grid.", "effects": { "targeting": 7, "researchMult": 1.3 } }, + { "id": "galacticcybernet", "name": "Galactic Cybernet", "field": "computers", "tier": 9, "prereqs": ["cybertroniccomputer"], "cost": 20000, "iconFrame": 15, "desc": "Mark VII battle computer and an empire-wide research grid.", "effects": { "targeting": 7, "researchMult": 1.3 }, "gnnHeadline": true }, { "id": "reinforcedhull", "name": "Reinforced Hull", "field": "construction", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 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 } } }, @@ -36,14 +36,14 @@ { "id": "nanitehullsealant", "name": "Nanite Hull Sealant", "field": "construction", "tier": 2, "prereqs": ["duralloyarmor"], "cost": 420, "iconFrame": 68, "desc": "Self-sealing microfractures. Ships repair 2% of their hull between combat rounds.", "effects": { "repairPerRound": 0.02 } }, { "id": "zortriumarmor", "name": "Zortrium Armour", "field": "construction", "tier": 3, "prereqs": ["improvedindustrial"], "cost": 950, "iconFrame": 19, "desc": "Zortrium plating. Hulls absorb 80% more damage.", "effects": { "armor": { "name": "Zortrium", "hpMult": 1.8 } } }, { "id": "autorepairunit", "name": "Automated Repair Unit", "field": "construction", "tier": 4, "prereqs": ["zortriumarmor"], "cost": 1900, "iconFrame": 20, "desc": "Ships repair 3% of their hull between combat rounds.", "effects": { "repairPerRound": 0.03 } }, - { "id": "roboticassemblylines","name": "Robotic Assembly Lines", "field": "construction", "tier": 4, "prereqs": ["zortriumarmor"], "cost": 1900, "iconFrame": 82, "desc": "Fully robotic production lines undercut even Improved Industrial Tech. Factories cost 25% less to build and run, though the yards spend no effort on hull repair drones.", "effects": { "factoryCostMult": 0.75 } }, + { "id": "roboticassemblylines","name": "Robotic Assembly Lines", "field": "construction", "tier": 4, "prereqs": ["zortriumarmor"], "cost": 1900, "iconFrame": 82, "desc": "Fully robotic production lines undercut even Improved Industrial Tech. Factories cost 25% less to build and run, though the yards spend no effort on hull repair drones.", "effects": { "factoryCostMult": 0.75 }, "gnnHeadline": true }, { "id": "andriumarmor", "name": "Andrium Armour", "field": "construction", "tier": 5, "prereqs": ["autorepairunit"], "cost": 3400, "iconFrame": 21, "desc": "Andrium plating. Hulls absorb 110% more damage.", "effects": { "armor": { "name": "Andrium", "hpMult": 2.1 } } }, { "id": "damagecontrol", "name": "Advanced Damage Control","field": "construction","tier": 6, "prereqs": ["andriumarmor"], "cost": 5600, "iconFrame": 22, "desc": "Damaged ships rejoin the line faster and cost less to refit.", "effects": { "repairPerRound": 0.06, "refitCostMult": 0.7 } }, { "id": "redundantlifesupport","name": "Redundant Life Support","field": "construction", "tier": 6, "prereqs": ["andriumarmor"], "cost": 5600, "iconFrame": 69, "desc": "Backup systems patch hulls faster in the heat of battle. Ships repair 7% each round.", "effects": { "repairPerRound": 0.07 } }, { "id": "tritaniumarmor", "name": "Tritanium Armour", "field": "construction", "tier": 7, "prereqs": ["damagecontrol"], "cost": 9000, "iconFrame": 23, "desc": "Tritanium plating. Hulls absorb 150% more damage.", "effects": { "armor": { "name": "Tritanium", "hpMult": 2.5 } } }, - { "id": "adamantiumarmor", "name": "Adamantium Armour", "field": "construction", "tier": 8, "prereqs": ["tritaniumarmor"], "cost": 14000, "iconFrame": 24, "desc": "Adamantium plating. Hulls absorb 200% more damage.", "effects": { "armor": { "name": "Adamantium", "hpMult": 3.0 } } }, + { "id": "adamantiumarmor", "name": "Adamantium Armour", "field": "construction", "tier": 8, "prereqs": ["tritaniumarmor"], "cost": 14000, "iconFrame": 24, "desc": "Adamantium plating. Hulls absorb 200% more damage.", "effects": { "armor": { "name": "Adamantium", "hpMult": 3.0 } }, "gnnHeadline": true }, { "id": "autonomousrepairswarms","name": "Autonomous Repair Swarms", "field": "construction", "tier": 8, "prereqs": ["tritaniumarmor"], "cost": 14000, "iconFrame": 83, "desc": "Self-directing nanite swarms patch hull breaches faster than any yard crew. Ships repair 9% of their hull between combat rounds, though plating stays at Tritanium grade.", "effects": { "repairPerRound": 0.09 } }, - { "id": "neutroniumarmor", "name": "Neutronium Armour", "field": "construction", "tier": 9, "prereqs": ["adamantiumarmor"], "cost": 21000, "iconFrame": 25, "desc": "Degenerate-matter plating. Hulls absorb 260% more damage.", "effects": { "armor": { "name": "Neutronium", "hpMult": 3.6 } } }, + { "id": "neutroniumarmor", "name": "Neutronium Armour", "field": "construction", "tier": 9, "prereqs": ["adamantiumarmor"], "cost": 21000, "iconFrame": 25, "desc": "Degenerate-matter plating. Hulls absorb 260% more damage.", "effects": { "armor": { "name": "Neutronium", "hpMult": 3.6 } }, "gnnHeadline": true }, { "id": "deflectori", "name": "Class I Deflector Shield","field": "forcefields","tier": 0, "prereqs": [], "cost": 80, "iconFrame": 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 } }, @@ -52,14 +52,14 @@ { "id": "modulardeflectorcoils","name": "Modular Deflector Coils","field": "forcefields", "tier": 2, "prereqs": ["personalshield"], "cost": 500, "iconFrame": 84, "desc": "Standardised shield generators drop into any hull. Fleets refit for 15% less, though the coils themselves absorb no extra damage.", "effects": { "refitCostMult": 0.85 } }, { "id": "planetaryshield5", "name": "Planetary Shield V", "field": "forcefields", "tier": 3, "prereqs": ["deflectoriii"], "cost": 1100, "iconFrame": 29, "desc": "Colonies shrug off 5 points from every orbital bombardment.", "effects": { "planetaryShield": 5 } }, { "id": "deflectorv", "name": "Class V Deflector", "field": "forcefields", "tier": 4, "prereqs": ["planetaryshield5"], "cost": 2100, "iconFrame": 30, "desc": "Absorbs 3 points from every incoming hit.", "effects": { "shield": 3 } }, - { "id": "stealthfield", "name": "Stealth Field", "field": "forcefields", "tier": 5, "prereqs": ["deflectorv"], "cost": 3600, "iconFrame": 31, "desc": "Cloaked hulls. Fleets move unseen and strike first.", "effects": { "cloaked": true, "initiative": 3 } }, + { "id": "stealthfield", "name": "Stealth Field", "field": "forcefields", "tier": 5, "prereqs": ["deflectorv"], "cost": 3600, "iconFrame": 31, "desc": "Cloaked hulls. Fleets move unseen and strike first.", "effects": { "cloaked": true, "initiative": 3 }, "gnnHeadline": true }, { "id": "colonialdeflectorgrid","name": "Colonial Deflector Grid","field": "forcefields", "tier": 5, "prereqs": ["deflectorv"], "cost": 3600, "iconFrame": 85, "desc": "Extends deflector coverage down to the colony surface. Absorbs 8 points from every orbital bombardment, though fleets stay visible on enemy scopes.", "effects": { "planetaryShield": 8 } }, { "id": "deflectorvii", "name": "Class VII Deflector", "field": "forcefields", "tier": 6, "prereqs": ["stealthfield"], "cost": 6000, "iconFrame": 32, "desc": "Absorbs 5 points from every incoming hit.", "effects": { "shield": 5 } }, { "id": "planetaryshield15", "name": "Planetary Shield XV", "field": "forcefields", "tier": 7, "prereqs": ["deflectorvii"], "cost": 9500, "iconFrame": 33, "desc": "Colonies shrug off 15 points from every orbital bombardment.", "effects": { "planetaryShield": 15 } }, { "id": "deepshieldharmonics","name": "Deep Shield Harmonics", "field": "forcefields", "tier": 7, "prereqs": ["deflectorvii"], "cost": 9500, "iconFrame": 71, "desc": "Ship-side shield regenerators. Absorbs 6 points from every incoming hit.", "effects": { "shield": 6 } }, - { "id": "deflectorx", "name": "Class X Deflector", "field": "forcefields", "tier": 8, "prereqs": ["planetaryshield15"], "cost": 15000, "iconFrame": 34, "desc": "Absorbs 7 points from every incoming hit.", "effects": { "shield": 7 } }, + { "id": "deflectorx", "name": "Class X Deflector", "field": "forcefields", "tier": 8, "prereqs": ["planetaryshield15"], "cost": 15000, "iconFrame": 34, "desc": "Absorbs 7 points from every incoming hit.", "effects": { "shield": 7 }, "gnnHeadline": true }, { "id": "harmonicdeflectorarray","name": "Harmonic Deflector Array","field": "forcefields","tier": 8, "prereqs": ["planetaryshield15"], "cost": 15000, "iconFrame": 86, "desc": "Shield harmonics tuned to scramble infiltration attempts. +18 counter-espionage empire-wide, though the array trades depth of coverage for breadth.", "effects": { "counterEspionage": 18 } }, - { "id": "blackholegenerator", "name": "Black Hole Generator", "field": "forcefields", "tier": 9, "prereqs": ["deflectorx"], "cost": 22000, "iconFrame": 35, "desc": "Folds enemy hulls into a singularity. Devastating in battle.", "effects": { "shield": 9, "singularity": true } }, + { "id": "blackholegenerator", "name": "Black Hole Generator", "field": "forcefields", "tier": 9, "prereqs": ["deflectorx"], "cost": 22000, "iconFrame": 35, "desc": "Folds enemy hulls into a singularity. Devastating in battle.", "effects": { "shield": 9, "singularity": true }, "gnnHeadline": true }, { "id": "ecorestoration", "name": "Ecological Restoration","field": "planetology", "tier": 0, "prereqs": [], "cost": 70, "iconFrame": 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 } }, @@ -70,12 +70,12 @@ { "id": "soilenrichment", "name": "Soil Enrichment", "field": "planetology", "tier": 4, "prereqs": ["controlleddead"], "cost": 2000, "iconFrame": 40, "desc": "Every colony supports 15 more population.", "effects": { "maxPopBonus": 15 } }, { "id": "efficientwastereclamation","name": "Efficient Waste Reclamation","field": "planetology","tier": 4,"prereqs": ["controlleddead"], "cost": 2000, "iconFrame": 72, "desc": "Industrial waste is recycled almost as fast as it's produced. Cleanup costs 40% less.", "effects": { "wasteMult": 0.6 } }, { "id": "controlledinferno", "name": "Controlled Inferno Environment","field": "planetology","tier": 5,"prereqs": ["soilenrichment"], "cost": 3500, "iconFrame": 41, "desc": "Colonise inferno worlds.", "effects": { "colonizeHostility": 3 } }, - { "id": "atmosphericterraforming","name": "Atmospheric Terraforming","field": "planetology","tier": 6,"prereqs": ["controlledinferno"], "cost": 5800, "iconFrame": 42, "desc": "Every colony supports 25 more population.", "effects": { "maxPopBonus": 25 } }, + { "id": "atmosphericterraforming","name": "Atmospheric Terraforming","field": "planetology","tier": 6,"prereqs": ["controlledinferno"], "cost": 5800, "iconFrame": 42, "desc": "Every colony supports 25 more population.", "effects": { "maxPopBonus": 25 }, "gnnHeadline": true }, { "id": "xenobotanicalresearchstations","name": "Xenobotanical Research Stations","field": "planetology","tier": 6,"prereqs": ["controlledinferno"], "cost": 5800, "iconFrame": 89, "desc": "Terraforming labs double as pure research outposts. Research output up 10%, though colonies grow no larger for it.", "effects": { "researchMult": 1.1 } }, { "id": "controlledtoxic", "name": "Controlled Toxic Environment","field": "planetology","tier": 7,"prereqs": ["atmosphericterraforming"],"cost": 9200,"iconFrame": 43,"desc": "Colonise toxic worlds.", "effects": { "colonizeHostility": 4 } }, { "id": "advancedsoil", "name": "Advanced Soil Enrichment","field": "planetology","tier": 8, "prereqs": ["controlledtoxic"], "cost": 14500, "iconFrame": 44, "desc": "Every colony supports 40 more population.", "effects": { "maxPopBonus": 40 } }, - { "id": "deepterraforming", "name": "Deep Terraforming", "field": "planetology", "tier": 8, "prereqs": ["controlledtoxic"], "cost": 14500, "iconFrame": 73, "desc": "Near-total reclamation. Industrial waste costs 65% less to clean up.", "effects": { "wasteMult": 0.35 } }, - { "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": "deepterraforming", "name": "Deep Terraforming", "field": "planetology", "tier": 8, "prereqs": ["controlledtoxic"], "cost": 14500, "iconFrame": 73, "desc": "Near-total reclamation. Industrial waste costs 65% less to clean up.", "effects": { "wasteMult": 0.35 }, "gnnHeadline": true }, + { "id": "controlledradiated", "name": "Controlled Radiated Environment","field": "planetology","tier": 9,"prereqs": ["advancedsoil"], "cost": 21500, "iconFrame": 45, "desc": "Colonise radiated worlds. Nowhere in the galaxy is closed.", "effects": { "colonizeHostility": 5, "maxPopBonus": 15 }, "gnnHeadline": true }, { "id": "nuclearengines", "name": "Nuclear Engines", "field": "propulsion", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 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 } }, @@ -89,11 +89,11 @@ { "id": "impulsedrive", "name": "Impulse Drive", "field": "propulsion", "tier": 6, "prereqs": ["uridiumcells"], "cost": 5900, "iconFrame": 52, "desc": "Warp 4 drives.", "effects": { "engine": { "name": "Impulse", "speed": 4 } } }, { "id": "deepscanrelays", "name": "Deep Scan Relays", "field": "propulsion", "tier": 6, "prereqs": ["uridiumcells"], "cost": 5900, "iconFrame": 91, "desc": "Automated relay buoys dropped along your travel lanes extend detection well past the fleet itself. +2 scan range empire-wide, though the drive itself stays at Warp 3.", "effects": { "scanRange": 2 } }, { "id": "thoriumcells", "name": "Thorium Fuel Cells", "field": "propulsion", "tier": 7, "prereqs": ["impulsedrive"], "cost": 9400, "iconFrame": 53, "desc": "Fuel range 18 parsecs. Range stops being a constraint.", "effects": { "fuelRange": 18 } }, - { "id": "interphaseddrive", "name": "Interphased Drive", "field": "propulsion", "tier": 8, "prereqs": ["thoriumcells"], "cost": 14800, "iconFrame": 54, "desc": "Warp 5 drives.", "effects": { "engine": { "name": "Interphased", "speed": 5 } } }, + { "id": "interphaseddrive", "name": "Interphased Drive", "field": "propulsion", "tier": 8, "prereqs": ["thoriumcells"], "cost": 14800, "iconFrame": 54, "desc": "Warp 5 drives.", "effects": { "engine": { "name": "Interphased", "speed": 5 } }, "gnnHeadline": true }, { "id": "deepspacefuelcells", "name": "Deep-Space Fuel Cells", "field": "propulsion", "tier": 8, "prereqs": ["thoriumcells"], "cost": 14800, "iconFrame": 92, "desc": "The last word in tankage before hyperspace renders range moot. Fuel range 22 parsecs, though the hull stays at Warp 4.", "effects": { "fuelRange": 22 } }, - { "id": "hyperspacecomms", "name": "Hyperspace Communications","field": "propulsion","tier": 9,"prereqs": ["interphaseddrive"], "cost": 21000, "iconFrame": 55, "desc": "Warp 6 drives and fleets redirected in mid-flight.", "effects": { "engine": { "name": "Hyperspace", "speed": 6 }, "redirectInFlight": true } }, + { "id": "hyperspacecomms", "name": "Hyperspace Communications","field": "propulsion","tier": 9,"prereqs": ["interphaseddrive"], "cost": 21000, "iconFrame": 55, "desc": "Warp 6 drives and fleets redirected in mid-flight.", "effects": { "engine": { "name": "Hyperspace", "speed": 6 }, "redirectInFlight": true }, "gnnHeadline": true }, - { "id": "lasercannon", "name": "Laser Cannon", "field": "weapons", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 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": "lasercannon", "name": "Laser Cannon", "field": "weapons", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 56, "desc": "The first real gun. 1-4 damage.", "effects": { "weapon": { "id": "laser", "name": "Laser Cannon", "kind": "beam", "min": 1, "max": 4, "shots": 1, "space": 2, "cost": 8 } }, "gnnHeadline": true }, { "id": "hypervrockets", "name": "Hyper-V Rockets", "field": "weapons", "tier": 1, "prereqs": ["lasercannon"], "cost": 160, "iconFrame": 57, "desc": "Cheap standoff missiles. 6 damage, 2 salvoes.", "effects": { "weapon": { "id": "hyperv", "name": "Hyper-V Rocket", "kind": "missile", "min": 6, "max": 6, "shots": 4, "space": 4, "cost": 10 } } }, { "id": "pulselaser", "name": "Pulse Laser", "field": "weapons", "tier": 1, "prereqs": ["lasercannon"], "cost": 160, "iconFrame": 93, "desc": "A faster-cycling beam. 2-5 damage every round, forever — no rack to run empty.", "effects": { "weapon": { "id": "pulselaser", "name": "Pulse Laser", "kind": "beam", "min": 2, "max": 5, "shots": 1, "space": 3, "cost": 9 } } }, { "id": "neutronpellet", "name": "Neutron Pellet Gun", "field": "weapons", "tier": 2, "prereqs": ["hypervrockets"], "cost": 430, "iconFrame": 58, "desc": "Shield-piercing pellets. 2-6 damage.", "effects": { "weapon": { "id": "pellet", "name": "Neutron Pellet Gun", "kind": "beam", "min": 2, "max": 6, "shots": 1, "space": 3, "cost": 12, "shieldPierce": 2 } } }, @@ -105,10 +105,10 @@ { "id": "hyperxrockets", "name": "Hyper-X Rockets", "field": "weapons", "tier": 6, "prereqs": ["neutronblaster"], "cost": 5700, "iconFrame": 62, "desc": "14 damage, 3 salvoes.", "effects": { "weapon": { "id": "hyperx", "name": "Hyper-X Rocket", "kind": "missile", "min": 16, "max": 16, "shots": 5, "space": 8, "cost": 32 } } }, { "id": "gravitonbeam", "name": "Graviton Beam", "field": "weapons", "tier": 6, "prereqs": ["neutronblaster"], "cost": 5700, "iconFrame": 77, "desc": "Warps space directly onto the target. 12-20 damage, punches through shields.", "effects": { "weapon": { "id": "gravitonbeam", "name": "Graviton Beam", "kind": "beam", "min": 12, "max": 20, "shots": 1, "space": 7, "cost": 34, "shieldPierce": 2 } } }, { "id": "fusionbeam", "name": "Fusion Beam", "field": "weapons", "tier": 7, "prereqs": ["hyperxrockets"], "cost": 9100, "iconFrame": 63, "desc": "8-16 damage.", "effects": { "weapon": { "id": "fusionbeam", "name": "Fusion Beam", "kind": "beam", "min": 8, "max": 16, "shots": 1, "space": 6, "cost": 45 } } }, - { "id": "deathray", "name": "Death Ray", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 64, "desc": "15-40 damage. Fleets evaporate.", "effects": { "weapon": { "id": "deathray", "name": "Death Ray", "kind": "beam", "min": 15, "max": 40, "shots": 1, "space": 12, "cost": 90 } } }, + { "id": "deathray", "name": "Death Ray", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 64, "desc": "15-40 damage. Fleets evaporate.", "effects": { "weapon": { "id": "deathray", "name": "Death Ray", "kind": "beam", "min": 15, "max": 40, "shots": 1, "space": 12, "cost": 90 } }, "gnnHeadline": true }, { "id": "antimattertorpedoes","name": "Antimatter Torpedoes", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 95, "desc": "Warhead payloads with a dozen years of R&D behind them. 22 damage, 4 salvoes, then the racks are empty.", "effects": { "weapon": { "id": "antimattertorpedo", "name": "Antimatter Torpedo", "kind": "missile", "min": 22, "max": 22, "shots": 4, "space": 10, "cost": 70 } } }, { "id": "disintegratorbeam", "name": "Disintegrator Beam", "field": "weapons", "tier": 8, "prereqs": ["fusionbeam"], "cost": 14200, "iconFrame": 96, "desc": "A narrower beam than Death Ray, but tuned to punch clean through deflector harmonics. 10-22 damage, ignores 3 points of shield.", "effects": { "weapon": { "id": "disintegratorbeam", "name": "Disintegrator Beam", "kind": "beam", "min": 10, "max": 22, "shots": 1, "space": 9, "cost": 60, "shieldPierce": 3 } } }, - { "id": "stellarconverter", "name": "Stellar Converter", "field": "weapons", "tier": 9, "prereqs": ["deathray"], "cost": 21000, "iconFrame": 65, "desc": "30-70 damage, and it can crack a colony from orbit.", "effects": { "weapon": { "id": "stellarconverter", "name": "Stellar Converter", "kind": "beam", "min": 30, "max": 70, "shots": 1, "space": 20, "cost": 150, "planetCracker": true } } } + { "id": "stellarconverter", "name": "Stellar Converter", "field": "weapons", "tier": 9, "prereqs": ["deathray"], "cost": 21000, "iconFrame": 65, "desc": "30-70 damage, and it can crack a colony from orbit.", "effects": { "weapon": { "id": "stellarconverter", "name": "Stellar Converter", "kind": "beam", "min": 30, "max": 70, "shots": 1, "space": 20, "cost": 150, "planetCracker": true } }, "gnnHeadline": true } ], "_hullsReadme": "Preset ship classes — there is no ship designer. space is the weapon capacity a Mark refit fills with the best available weapon; VegaShips.js derives every other stat from the owner's researched tech. sizeScale/sizeSpeedMult/turnRateBase (degrees/round)/brakeSeconds are only consumed by the VegaCombatV2 per-ship prototype (behind ?movsim's Live/V2 toggle) — the live engine (VegaCombat.js) ignores them entirely. brakeSeconds is how long (seconds) a hull takes to kill its own top speed under max thrust — small relative to a hull's speed means it can actually decelerate and hold position within weapon range (battleship/cruiser); large relative to speed means its stopping distance exceeds beamRange and it physically cannot stop before reaching a target, so it blows through and loops back for another pass instead (frigate/scout) — see VegaCombatV2.js's computeShipMove.", diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 88c4635..ddd9bf5 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -163,6 +163,11 @@ export const MANIFEST = { // vignette is already on screen". image('vega-menu-bg', 'assets/images/vega/background-menu.png'), image('vega-menu-title', 'assets/images/vega/menu-title.png'), + // The Galactic News Network anchor desk loop — one global clip (not + // per-species like portraitVideos), so it's a plain literal here rather + // than a mastervega-artwork.json map entry. Eager-loaded so the first + // post-turn GNN open never stalls on a JIT fetch. + { type: 'video', key: 'vega-gnn-anchor', path: 'assets/videos/vega/gnn.mp4' }, // The colony-founding cue. It is not part of the shuffled soundtrack — // VegaColonyIntro.js ducks that and plays this over the vignette instead. // Small, and it has to be ready the instant the vignette opens. @@ -186,6 +191,13 @@ export const MANIFEST = { { type: 'audio', key: 'sfx-vega-zoomout', path: 'assets/fx/vega/vega-zoomout.mp3' }, { type: 'audio', key: 'sfx-vega-viewcolony', path: 'assets/fx/vega/vega-viewcolony.mp3' }, { type: 'audio', key: 'sfx-vega-endturn', path: 'assets/fx/vega/vega-endturn.mp3' }, + { type: 'audio', key: 'sfx-vega-counter', path: 'assets/fx/vega/vega-counter.mp3' }, + // GNN's opening sting, and the looping ambience that takes over once it + // finishes and plays for as long as the GNN screen stays open (see + // VegaGnnScreen.js's openGnnScreen) — ducking the regular soundtrack + // the same way VegaColonyIntro.js's founding cue already does. + { type: 'audio', key: 'sfx-vega-gnn-sting', path: 'assets/fx/vega/vega-gnn.mp3' }, + { type: 'audio', key: 'sfx-vega-gnn-loop', path: 'assets/music/vega/vega-gnn.mp3' }, // Combat weapon cues, Mark-banded (see VegaCombat.js's weaponSfxKey()). { type: 'audio', key: 'sfx-vega-weapon-123', path: 'assets/fx/vega/vega-weapon-123.mp3' }, { type: 'audio', key: 'sfx-vega-weapon-45', path: 'assets/fx/vega/vega-weapon-45.mp3' }, diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index aecc7db..849b577 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -36,6 +36,8 @@ import { openResearchScreen } from './VegaResearchScreen.js'; import { openResearchChoiceScreen } from './VegaResearchChoiceScreen.js'; import { openTurnReportScreen } from './VegaTurnReportScreen.js'; import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js'; +import * as Gnn from './VegaGnn.js'; +import { openGnnScreen } from './VegaGnnScreen.js'; import { openAudienceScreen } from './VegaAudience.js'; import { playIntroVideo } from './VegaIntroVideo.js'; import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js'; @@ -687,6 +689,14 @@ export default class MasterOfVegaGame extends Phaser.Scene { this.uiClick(() => this.toggleEmpireMenu()), { width: 190, height: 42, fontSize: 18 }); hud.add(this.empireBtn); + // GNN — immediately left of Empire, same row. Label gains a bullet + // (refreshHud) whenever a story is waiting so there's a visible cue even + // between auto-opens (e.g. after a Load, or if the player dismissed the + // last broadcast mid-flow via ESC). + this.gnnBtn = new Button(this, GAME_WIDTH - 550, 31, 'GNN', + this.uiClick(() => this.openGnnOnDemand()), { width: 170, height: 42, fontSize: 18, variant: 'ghost' }); + hud.add(this.gnnBtn); + // Not this.uiClick — onEndTurn plays its own vega-endturn cue (shared with // combat's Next round button) rather than the generic click, and a no-op // press while busy/modal should stay silent. @@ -891,6 +901,10 @@ export default class MasterOfVegaGame extends Phaser.Scene { + `${Math.round(emp.bc)} BC (${emp.lastIncome >= 0 ? '+' : ''}${Math.round(emp.lastIncome ?? 0)}) · ` + `${emp.techsKnown} technologies`, ); + // Unread cue — visible even between auto-opens (e.g. right after a Load, + // or if the player ESC'd out of a broadcast mid-flow). + const hasNews = Gnn.pendingGnnStories(this.rules, this.state).length > 0; + this.gnnBtn?.setLabel(hasNews ? 'GNN •' : 'GNN'); } log(line) { @@ -969,6 +983,15 @@ export default class MasterOfVegaGame extends Phaser.Scene { this.refreshOfferNotifications(); } + // On-demand GNN — same modalOpen gate + openModal((done) => ...) shape as + // every Empire-menu entry above. Opens even with nothing new to report: + // VegaGnnScreen.js falls back to a random rankings page and lets Prev walk + // back into state.gnn.history, so it's never a dead click. + openGnnOnDemand() { + if (this.modalOpen) return; + this.openModal((done) => openGnnScreen(this, this.rules, this.state, this.art, { onClose: done })); + } + // -------------------------------------------------------------- modals openModal(factory) { @@ -1193,7 +1216,13 @@ export default class MasterOfVegaGame extends Phaser.Scene { }; this.runAudienceQueue(() => { const notable = this.processTurnEvents(); - if (notable.length) { + // GNN has its own event set (VegaGnn.js's GNN_STORY_TYPES) that + // isn't a subset of the Turn Report's NOTABLE_TYPES — `lastColony` + // has no Turn Report presence at all, and `spyCaught` is + // deliberately ticker-only there — so a turn can have zero + // `notable` rows and still owe the player a GNN broadcast. + const gnnPending = Gnn.pendingGnnStories(this.rules, this.state).length > 0; + if (notable.length || gnnPending) { // openModal's own done() (MasterOfVegaGame.js:openModal) clears // modalOpen and refreshes; finish() on top of that re-arms the End // Turn button, which openModal has no reason to touch on its own. @@ -1201,11 +1230,27 @@ export default class MasterOfVegaGame extends Phaser.Scene { // DIRECTLY (not via this.openModal — modalOpen is already true // for the duration of this whole outer call, same reasoning as // the turn report's own "View Star System" button), then the - // turn report, then done()+finish() exactly once at the end. + // turn report (if there's anything notable to show), then GNN + // (if there's a pending story — re-checked at call time since a + // research choice can itself grant a headline tech), then + // done()+finish() exactly once at the end. const pendingChoices = this.pendingResearchChoices(notable); this.openModal((done) => { const finishAll = () => { done(); finish(); }; - const openReport = () => openTurnReportScreen(this, this.rules, this.state, notable, finishAll); + const openGnnIfPending = () => { + if (Gnn.pendingGnnStories(this.rules, this.state).length) { + openGnnScreen(this, this.rules, this.state, this.art, { onClose: finishAll }); + } else { + finishAll(); + } + }; + const openReport = () => { + if (notable.length) { + openTurnReportScreen(this, this.rules, this.state, notable, openGnnIfPending); + } else { + openGnnIfPending(); + } + }; const runChoices = (queue) => { if (!queue.length) { openReport(); return; } const [next, ...rest] = queue; diff --git a/src/games/mastervega/VegaColoniesScreen.js b/src/games/mastervega/VegaColoniesScreen.js index 17d4585..fff2204 100644 --- a/src/games/mastervega/VegaColoniesScreen.js +++ b/src/games/mastervega/VegaColoniesScreen.js @@ -209,29 +209,36 @@ const CURSOR_CHAR = '█'; // clips its own content to the wrong part of the screen — invisible, not // just faint. Building everything here in absolute coordinates sidesteps // that instead of trying to keep a mask in sync with a container that moves. -function createAdvisorTerminal(scene, x, y, size) { +// +// Exported — originally private to this file (the Colony Advisor's own +// terminal), now also the green-phosphor "news ticker" box under GNN's +// anchor video (VegaGnnScreen.js). Takes width/height separately rather +// than one square `size` so a caller can size it to whatever's left over +// below its own video panel (GNN's is short and wide, not square). +export function createAdvisorTerminal(scene, x, y, w, h) { const container = scene.add.container(0, 0); - const inner = size - PAD * 2; + const innerW = w - PAD * 2; + const innerH = h - PAD * 2; - container.add(scene.add.rectangle(x, y, size, size, 0x02120a, 0.96) + container.add(scene.add.rectangle(x, y, w, h, 0x02120a, 0.96) .setOrigin(0, 0).setStrokeStyle(1.5, 0x39ff14, 0.4)); // --- scrollable text column, so a long report doesn't run off the box - const termScroll = scrollColumn(scene, container, x + PAD, y + PAD, inner, inner); + const termScroll = scrollColumn(scene, container, x + PAD, y + PAD, innerW, innerH); const text = scene.add.text(0, 0, '', { fontFamily: 'monospace', fontSize: '16px', color: '#39ff14', lineSpacing: 8, - wordWrap: { width: inner }, + wordWrap: { width: innerW }, }); termScroll.content.add(text); let ghost = null; // --- scanline overlay, added AFTER the scroll column so it sits on top - const scanKey = ensureScanTexture(scene, size); - const scan = scene.add.tileSprite(x + size / 2, y + size / 2, size - 6, size - 6, scanKey).setAlpha(0.55); + const scanKey = ensureScanTexture(scene, w); + const scan = scene.add.tileSprite(x + w / 2, y + h / 2, w - 6, h - 6, scanKey).setAlpha(0.55); scan.setBlendMode(Phaser.BlendModes.ADD); container.add(scan); const scanTween = scene.tweens.add({ - targets: scan, tilePositionY: size, duration: 5000, repeat: -1, ease: 'Linear', + targets: scan, tilePositionY: h, duration: 5000, repeat: -1, ease: 'Linear', }); function glitch() { @@ -244,7 +251,7 @@ function createAdvisorTerminal(scene, x, y, size) { ghost?.destroy(); ghost = scene.add.text(2, 1, text.text, { fontFamily: 'monospace', fontSize: '16px', color: '#9dffb0', lineSpacing: 8, - wordWrap: { width: inner }, + wordWrap: { width: innerW }, }).setAlpha(0.22); ghost.setBlendMode(Phaser.BlendModes.ADD); termScroll.content.add(ghost); @@ -272,7 +279,7 @@ function createAdvisorTerminal(scene, x, y, size) { function render(autoFollow) { text.setText(revealed + (cursorOn ? CURSOR_CHAR : ' ')); termScroll.contentHeight = text.height; - if (autoFollow) termScroll.setOffset(-Math.max(0, termScroll.contentHeight - inner)); + if (autoFollow) termScroll.setOffset(-Math.max(0, termScroll.contentHeight - innerH)); } const blinkTimer = scene.time.addEvent({ @@ -357,7 +364,7 @@ export function openColoniesScreen(scene, rules, state, e, art, opts = {}) { frame(scene, root, LEFT_X, CONTENT_TOP, LEFT_W, LEFT_W, 0); const termY = CONTENT_TOP + LEFT_W + VIDEO_GAP; - const terminal = createAdvisorTerminal(scene, LEFT_X, termY, LEFT_W); + const terminal = createAdvisorTerminal(scene, LEFT_X, termY, LEFT_W, LEFT_W); root.add(terminal.container); terminal.setLines(["Let's take a look at our colonies. Select a colony to the right."]); diff --git a/src/games/mastervega/VegaGnn.js b/src/games/mastervega/VegaGnn.js new file mode 100644 index 0000000..718f20d --- /dev/null +++ b/src/games/mastervega/VegaGnn.js @@ -0,0 +1,350 @@ +// Master of Vega — Galactic News Network: event classification, anchor +// copywriting, and ranking-page data. Headless, no Phaser imports, so it +// runs in Node (tools/verifyMasterOfVega.js) exactly like VegaTurnReport.js. +// +// GNN reads the same `state.events` bus VegaTurnReport.js's New Turn report +// does, but tracks its own consumption separately (`ev.gnnAnnounced`, not +// VegaTurnReport's `ev.announced`) so the two features never step on each +// other's flag despite watching the same array. GNN is additional to the +// New Turn report, not a replacement — Brian confirmed both stay (some +// double-announcement of war/peace/alliance/eliminated is intentional). + +import { empireColonies, colonyProduction, empireFleetPower } from './VegaLogic.js'; +import { pickLine } from './VegaChat.js'; + +// The seven event types GNN ever turns into a story page. Council/election +// events are deliberately excluded — Brian is handling that separately. +export const GNN_STORY_TYPES = new Set([ + 'warDeclared', 'peace', 'alliance', 'techDone', 'lastColony', 'eliminated', 'spyCaught', +]); + +/** + * Whether `ev` should ever become a GNN story. `techDone` is filtered twice: + * only research-sourced completions (never espionage-stolen or traded techs + * — nobody can headline a tech nobody publicly knows was taken) count, and + * only techs hand-flagged `gnnHeadline` in the rules data count as + * "breakthrough" news; the other ~75 techs stay ordinary. `sabotage`/ + * `techStolen` are deliberately NOT in GNN_STORY_TYPES at all — a spy + * mission that *succeeds* leaves nobody publicly able to name the culprit; + * only `spyCaught` ("exposed") is ever public. + */ +export function isGnnStory(rules, ev) { + if (!GNN_STORY_TYPES.has(ev.type)) return false; + if (ev.type === 'techDone') { + return ev.source === 'research' && !!rules.techs[ev.techId]?.gnnHeadline; + } + return true; +} + +/** Every GNN-worthy event not yet turned into a story, in chronological order. */ +export function pendingGnnStories(rules, state) { + return state.events.filter((ev) => !ev.gnnAnnounced && isGnnStory(rules, ev)); +} + +// Replay buffer for the on-demand HUD button when there's nothing new to +// report — capped so a long game doesn't grow it without bound. +const GNN_HISTORY_CAP = 30; + +/** + * Marks `events` consumed and appends them to the replay history. Deliberately + * separate bookkeeping from VegaTurnReport's `ev.announced` — both consumers + * read the same `state.events` array independently. + */ +export function consumeGnnStories(state, events) { + state.gnn ??= { history: [] }; + for (const ev of events) { + ev.gnnAnnounced = true; + state.gnn.history.push(ev); + } + if (state.gnn.history.length > GNN_HISTORY_CAP) { + state.gnn.history = state.gnn.history.slice(-GNN_HISTORY_CAP); + } +} + +/** + * Turns one event into third-person anchor copy — deliberately NOT + * VegaTurnReport's "we/us" phrasing, since GNN is a galaxy-wide broadcast, + * not a personal briefing. Returns + * `{type, kind, accent, headline, sub?, ...participant empire indices}`; + * `kind` picks the story renderer, `accent` picks the story-frame color, + * `type` is the raw event type — carried through so a second consumer (the + * anchor-line generator below) can dispatch on the exact event rather than + * re-deriving it from `kind`/`headline` text. + */ +export function describeGnnStory(rules, state, ev) { + const name = (i) => state.empires[i]?.name ?? 'An unknown empire'; + switch (ev.type) { + case 'warDeclared': return { + type: ev.type, kind: 'diplomacy', accent: 'war', a: ev.empire, b: ev.other, verb: 'WAR DECLARED', + headline: `${name(ev.empire)} declares war on ${name(ev.other)}.`, + }; + case 'peace': return { + type: ev.type, kind: 'diplomacy', accent: 'peace', a: ev.empire, b: ev.other, verb: 'PEACE', + headline: `${name(ev.empire)} and ${name(ev.other)} sign a peace treaty.`, + }; + case 'alliance': return { + type: ev.type, kind: 'diplomacy', accent: 'alliance', a: ev.empire, b: ev.other, verb: 'ALLIANCE', + headline: `${name(ev.empire)} and ${name(ev.other)} form an alliance.`, + }; + case 'techDone': { + const tech = rules.techs[ev.techId]; + return { + type: ev.type, kind: 'tech', accent: 'tech', empire: ev.empire, techId: ev.techId, + headline: `${name(ev.empire)} unveils ${tech?.name ?? 'a new technology'}.`, + sub: tech?.desc ?? '', + }; + } + case 'lastColony': return { + type: ev.type, kind: 'territory', accent: 'grim', victim: ev.empire, attacker: ev.attacker, + headline: ev.attacker >= 0 + ? `${name(ev.empire)} reduced to a single world by ${name(ev.attacker)}.` + : `${name(ev.empire)} has been reduced to a single world.`, + }; + case 'eliminated': return { + type: ev.type, kind: 'territory', accent: 'grim', victim: ev.empire, attacker: ev.attacker, + headline: ev.attacker >= 0 + ? `${name(ev.empire)} has been eliminated from the galaxy — by ${name(ev.attacker)}.` + : `${name(ev.empire)} has been eliminated from the galaxy.`, + }; + case 'spyCaught': return { + type: ev.type, kind: 'espionage', accent: 'espionage', perpetrator: ev.empire, victim: ev.target, + mission: ev.mission, + headline: `${name(ev.empire)} agents caught red-handed targeting ${name(ev.target)}.`, + sub: ev.mission === 'sabotage' ? 'Mission: sabotage.' : 'Mission: technology theft.', + }; + default: return { type: ev.type, kind: 'unknown', accent: 'tech', headline: ev.type }; + } +} + +// -------------------------------------------------------------------------- +// MOO1-style rankings + +export const RANKING_METRICS = [ + { id: 'population', label: 'POPULATION', valueFn: (rules, state, e) => state.empires[e].totalPop }, + { + id: 'production', + label: 'GNP / PRODUCTION', + valueFn: (rules, state, e) => empireColonies(state, e) + .reduce((t, c) => t + colonyProduction(rules, state, c), 0), + }, + // Total techs known, not a research rate (nothing in VegaLogic persists a + // per-turn rate) — already the same term VegaDiplomacy.js's powerOf() uses + // for its own research-strength component, and the one number a single + // bar-chart page can show without inventing a stacked per-field variant. + { id: 'research', label: 'RESEARCH', valueFn: (rules, state, e) => state.empires[e].techsKnown }, + { id: 'military', label: 'FLEET STRENGTH', valueFn: (rules, state, e) => empireFleetPower(rules, state, e) }, + { id: 'treasury', label: 'TREASURY', valueFn: (rules, state, e) => state.empires[e].bc }, +]; + +/** + * Ranking rows for one metric — the human plus every alive empire the human + * has met, sorted descending. Same contact-gating shape VegaScreens.js:164 + * already uses for its own "empires I know about" list. + */ +export function rankingRows(rules, state, metricId) { + const me = state.humanIndex; + const metric = RANKING_METRICS.find((m) => m.id === metricId); + return state.empires + .filter((o) => o.alive && (o.idx === me || state.empires[me].contacted[o.idx])) + .map((o) => ({ + idx: o.idx, name: o.name, color: o.color, speciesId: o.speciesId, + value: metric.valueFn(rules, state, o.idx), + })) + .sort((a, b) => b.value - a.value); +} + +// -------------------------------------------------------------------------- +// Diplomatic relations — the always-accessible page listing who's at war, +// who has a trade agreement, and who's allied with whom. Deliberately NOT +// contact-gated like rankingRows: war/peace/alliance are already broadcast +// galaxy-wide regardless of whether the human has met either party (see +// VegaTurnReport.js's PERSONAL_TYPES — diplomacy events are never personal), +// so this page is just that same "public knowledge" precedent laid out as a +// standing reference instead of one-off headlines. +export function relationsRows(rules, state) { + const name = (i) => state.empires[i]?.name ?? '?'; + return state.empires.filter((e) => e.alive).map((e) => { + const atWar = []; + const allied = []; + const trade = []; + for (const o of state.empires) { + if (o.idx === e.idx || !o.alive) continue; + const treaty = e.treaties[o.idx]; + if (treaty === 'war') atWar.push(name(o.idx)); + else if (treaty === 'alliance') allied.push(name(o.idx)); + // A trade agreement is a separate, coexisting relationship (see + // VegaDiplomacy.js's formTradeAgreement comment) — not another rung of + // the treaties[] ladder — so it's checked independently of the switch + // above rather than as another `else if`. + if (e.tradeAgreements[o.idx]) trade.push(name(o.idx)); + } + return { + idx: e.idx, name: e.name, color: e.color, speciesId: e.speciesId, atWar, allied, trade, + }; + }); +} + +// -------------------------------------------------------------------------- +// Anchor-desk narration — the green-screen "news ticker" line under GNN's +// looping anchor video. Deliberately separate text from the on-screen +// headline/rankings, phrased as spoken narration rather than a printed +// headline, and deliberately varied (pickLine, same random-template-plus- +// {token}-substitution convention VegaChat.js's diplomacy lines already +// use) so it doesn't read as the same sentence with the nouns swapped every +// time, and doesn't always open the same way either. + +const WAR_LINES = [ + 'This just in, {a} has declared war on {b}.', + 'Breaking: hostilities erupt as {a} declares war on {b}.', + 'Sources confirm {a} and {b} are now at war.', + 'Tensions boil over tonight — {a} declares war on {b}.', + 'From the frontier: {a} has opened hostilities against {b}.', +]; +const PEACE_LINES = [ + 'This just in, {a} and {b} have signed a peace treaty.', + 'Word from the negotiating table: {a} and {b} are now at peace.', + 'Breaking: {a} and {b} lay down arms in a new peace accord.', + 'Diplomats confirm {a} and {b} have ended hostilities.', + "Tonight's top story: peace returns between {a} and {b}.", +]; +const ALLIANCE_LINES = [ + 'This just in, {a} and {b} have formed an alliance.', + 'Breaking: {a} and {b} unite in a new alliance.', + 'Word from the Core Worlds: {a} and {b} have signed an alliance pact.', + 'Sources confirm a new alliance between {a} and {b}.', + "Tonight's top story: {a} and {b} now stand united.", +]; +const TECH_LINES = [ + 'This just in, {empire} scientists unveil {tech}.', + 'Breaking: {empire} announces the completion of {tech}.', + 'Word from the labs: {empire} has achieved {tech}.', + 'Sources confirm {empire} researchers have perfected {tech}.', + 'In technological news, {empire} unveils {tech} to the galaxy.', +]; +const LAST_COLONY_WITH_ATTACKER_LINES = [ + 'This just in, {victim} has been reduced to a single world after a crushing defeat by {attacker}.', + 'Breaking: {victim} clings to survival with just one colony left, following heavy losses to {attacker}.', + 'Word from the front: {attacker} forces have left {victim} with only one world standing.', +]; +const LAST_COLONY_LINES = [ + 'This just in, {victim} has been reduced to a single world.', + 'Breaking: {victim} now holds only one colony in the entire galaxy.', + 'Sources confirm {victim} clings to survival with just one world remaining.', +]; +const ELIMINATED_WITH_ATTACKER_LINES = [ + 'This just in, {victim} has been wiped from the galactic map by {attacker}.', + 'Breaking: {victim} is no more, eliminated by {attacker}.', + 'Sources confirm the fall of {victim} at the hands of {attacker}.', +]; +const ELIMINATED_LINES = [ + 'This just in, {victim} has been eliminated from the galaxy.', + 'Breaking: {victim} is no more.', + 'Word tonight: the {victim} are no longer among the stars.', +]; +const ESPIONAGE_LINES = [ + 'This just in, {perp} agents caught red-handed targeting {victim}.', + 'Breaking: an espionage scandal rocks the galaxy as {perp} operatives are exposed against {victim}.', + 'Sources confirm {perp} spies caught attempting {mission} against {victim}.', + 'Word from Galactic Security: {perp} agents apprehended mid-{mission} against {victim}.', +]; +const RANKING_LINES = { + population: [ + 'This just in, {leader} population reaches new galactic heights at {value}.', + 'Breaking: {leader} now leads the galaxy in population, {value} strong.', + 'Word from the census bureau: {leader} tops the population charts at {value}.', + '{leader} continues to grow, now boasting a population of {value} across their worlds.', + ], + production: [ + 'This just in, {leader} tops the charts in industrial output at {value}.', + 'Breaking: factories roar across {leader} space — {value} in total production.', + 'Sources confirm {leader} leads the galaxy in production with {value}.', + "Tonight's economic report: {leader} industry churns out {value} in output.", + ], + research: [ + 'This just in, {leader} leads the galaxy in technology with {value} advances mastered.', + 'Breaking: {leader} scientists have unlocked {value} technologies, the most in known space.', + 'Word from the laboratories: {leader} research leads the pack at {value} technologies.', + ], + military: [ + 'This just in, {leader} fields the mightiest fleet in known space, rated at {value}.', + 'Breaking: {leader} war fleets dominate the standings with a strength of {value}.', + 'Sources confirm {leader} holds the strongest military in the galaxy at {value}.', + ], + treasury: [ + 'This just in, {leader} banks the largest treasury in the galaxy — {value} BC and counting.', + 'Breaking: coffers overflow in {leader} space, a treasury of {value} BC.', + 'Word from the exchequer: {leader} leads the galaxy in wealth with {value} BC.', + ], +}; +const RELATIONS_QUIET_LINES = [ + 'This just in, an uneasy calm blankets the galaxy tonight — no wars, alliances, or trade pacts on record.', + 'Word tonight: the galaxy remains quiet, with no formal treaties of any kind to report.', +]; +const RELATIONS_LINES = [ + 'This just in, the galaxy currently counts {warCount} active conflicts and {allianceCount} standing alliances.', + 'Diplomatic climate check: {tradeCount} trade routes keep the galactic economy humming, while {warCount} wars rage on.', + 'Word from the Core Worlds: {allianceCount} alliances hold firm even as {warCount} empires remain at war.', + "Tonight's galactic report: {warCount} wars, {allianceCount} alliances, and {tradeCount} trade agreements crisscross known space.", +]; + +const fmtNum = (v) => Math.round(v).toLocaleString(); + +function anchorLineForStory(rules, state, desc) { + const empName = (i) => state.empires[i]?.name ?? 'an unknown empire'; + switch (desc.kind) { + case 'diplomacy': { + const vars = { a: empName(desc.a), b: empName(desc.b) }; + const pool = desc.type === 'warDeclared' ? WAR_LINES + : desc.type === 'peace' ? PEACE_LINES : ALLIANCE_LINES; + return pickLine(pool, vars); + } + case 'tech': { + const tech = rules.techs[desc.techId]?.name ?? 'a new technology'; + return pickLine(TECH_LINES, { empire: empName(desc.empire), tech }); + } + case 'territory': { + const victim = empName(desc.victim); + const hasAttacker = desc.attacker >= 0; + const attacker = hasAttacker ? empName(desc.attacker) : null; + if (desc.type === 'lastColony') { + return pickLine(hasAttacker ? LAST_COLONY_WITH_ATTACKER_LINES : LAST_COLONY_LINES, { victim, attacker }); + } + return pickLine(hasAttacker ? ELIMINATED_WITH_ATTACKER_LINES : ELIMINATED_LINES, { victim, attacker }); + } + case 'espionage': { + const mission = desc.mission === 'sabotage' ? 'sabotage' : 'technology theft'; + return pickLine(ESPIONAGE_LINES, { perp: empName(desc.perpetrator), victim: empName(desc.victim), mission }); + } + default: return desc.headline ?? ''; + } +} + +function anchorLineForRanking(rules, state, metric) { + const rows = rankingRows(rules, state, metric.id); + if (!rows.length) return 'This just in, no galactic data is currently available.'; + const leader = rows[0]; + const pool = RANKING_LINES[metric.id] ?? ['This just in, {leader} leads the way with {value}.']; + return pickLine(pool, { leader: leader.name, value: fmtNum(leader.value) }); +} + +function anchorLineForRelations(rules, state) { + const rows = relationsRows(rules, state); + const warCount = Math.round(rows.reduce((t, r) => t + r.atWar.length, 0) / 2); + const allianceCount = Math.round(rows.reduce((t, r) => t + r.allied.length, 0) / 2); + const tradeCount = Math.round(rows.reduce((t, r) => t + r.trade.length, 0) / 2); + if (warCount === 0 && allianceCount === 0 && tradeCount === 0) return pickLine(RELATIONS_QUIET_LINES, {}); + return pickLine(RELATIONS_LINES, { warCount, allianceCount, tradeCount }); +} + +/** + * The anchor-desk narration line for whatever page GNN is currently + * showing — `page` is the exact `{kind, desc?, metric?}` shape + * VegaGnnScreen.js's own pager builds. Re-picked (and re-typed on screen) + * every time the page changes, never cached, so revisiting a page can land + * on a different variant. + */ +export function anchorLine(rules, state, page) { + if (page.kind === 'story') return anchorLineForStory(rules, state, page.desc); + if (page.kind === 'ranking') return anchorLineForRanking(rules, state, page.metric); + return anchorLineForRelations(rules, state); +} diff --git a/src/games/mastervega/VegaGnnScreen.js b/src/games/mastervega/VegaGnnScreen.js new file mode 100644 index 0000000..bf6d3c3 --- /dev/null +++ b/src/games/mastervega/VegaGnnScreen.js @@ -0,0 +1,656 @@ +// Master of Vega — the Galactic News Network: a full-screen broadcast of +// diplomacy/tech/territorial/espionage headlines plus a MOO1-style rotating +// rankings browser. Layout, left to right: +// +// the anchor desk — a looping muted 2:3 video (assets/videos/vega/gnn.mp4), +// playing for the entire time the screen is open, +// regardless of which page is showing on the right, +// with a green-screen "ticker" terminal underneath +// (reused from the Colony Advisor's own terminal — +// VegaColoniesScreen.js's createAdvisorTerminal) +// bottom-aligned with the right panel, narrating +// whatever the current page is showing +// the broadcast — one page per pending/replayed story, followed by +// one page per MOO1-style ranking metric, then the +// always-accessible relations page, paged with +// Prev/Next at the bottom +// +// Story pages come from VegaGnn.js's pendingGnnStories/describeGnnStory; +// ranking pages from its RANKING_METRICS/rankingRows. Full-screen takeover +// container pattern borrowed from VegaColoniesScreen.js (edge-to-edge veil + +// explicit close()), not modalShell — GNN is a takeover, not a centred +// dialog. See VegaGnn.js's header comment for why GNN tracks its own +// event-consumption flag separately from VegaTurnReport.js's. + +import * as Phaser from 'phaser'; + +import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js'; +import { Button } from './VegaButton.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { FONT, D } from './VegaScreens.js'; +import { frame } from './VegaColonyView.js'; +import { makeSpeciesPortrait, sourceWidth, techFrame } from './VegaArt.js'; +import { createAdvisorTerminal } from './VegaColoniesScreen.js'; +import * as Gnn from './VegaGnn.js'; + +const ACCENT = 0x6fc4ff; +const PANEL = 0x0b1220; + +const GNN_ANCHOR_KEY = 'vega-gnn-anchor'; +// gnn.mp4 is 544x800 (2:3 portrait) — the fallback sourceWidth() uses before +// the video's first frame decodes (see VegaArt.js's sourceWidth for why a +// fresh Video's own .width cannot be trusted that early). +const GNN_ANCHOR_SRC_W = 544; + +const LEFT_X = 40; +const CONTENT_TOP = 130; +const LEFT_W = 460; +const LEFT_H = Math.round(LEFT_W * (800 / 544)); +const RIGHT_X = LEFT_X + LEFT_W + 48; +const RIGHT_W = GAME_WIDTH - RIGHT_X - 40; +const FOOTER_Y = GAME_HEIGHT - 100; +const RIGHT_H = FOOTER_Y - 30 - CONTENT_TOP; + +// The green-screen anchor-desk ticker below the video. Height is whatever's +// left under the video down to the right panel's own bottom edge — computed, +// not guessed — so the two columns end flush, the symmetry Brian asked for. +const TERM_GAP = 20; +const TERM_Y = CONTENT_TOP + LEFT_H + TERM_GAP; +const TERM_H = (CONTENT_TOP + RIGHT_H) - TERM_Y; + +// Colors reuse existing palette conventions rather than inventing new ones: +// war/red ~ VegaSidePanel.js's at-war tint, alliance/cyan ~ this game's own +// ACCENT, tech/amber ~ VegaTurnReport's "Now available" highlight, +// espionage/magenta ~ VegaButton's magenta scheme, grim ~ VegaButton's +// desaturated disabled-glow tone. +const STORY_COLORS = { + war: 0xff3355, peace: 0x2bff9e, alliance: 0x6fc4ff, + tech: 0xffd88a, grim: 0x8a94a8, espionage: 0xff2fd0, +}; + +const hex = (c) => `#${c.toString(16).padStart(6, '0')}`; +const fmtValue = (v) => Math.round(v).toLocaleString(); + +// -------------------------------------------------------------- anchor panel + +function buildAnchorFallback(scene, container, w, h) { + container.add(scene.add.rectangle(w / 2, h / 2, w, h, PANEL, 1).setStrokeStyle(2, ACCENT, 0.5)); + container.add(scene.add.text(w / 2, h / 2, 'GNN', { + fontFamily: FONT, fontSize: '72px', color: '#6fc4ff', + }).setOrigin(0.5)); +} + +/** The persistent looping anchor-desk video. Built once at screen open, + * never touched again by page changes — the clip is eager-loaded at + * game-room entry (src/data/assetManifest.js) so there is no JIT-fetch path + * to wait on here, unlike VegaResearchScreen.js's per-species clips. */ +function buildAnchorPanel(scene, w, h) { + const container = scene.add.container(0, 0); + if (scene.cache.video?.exists(GNN_ANCHOR_KEY)) { + const v = scene.add.video(w / 2, h / 2, GNN_ANCHOR_KEY); + v.setMute(true); + v.setLoop(true); + const fit = () => v.setScale(w / sourceWidth(v, GNN_ANCHOR_SRC_W)); + fit(); + v.on('created', fit); + v.on('playing', fit); + v.play(true); + // A missing/corrupt clip never leaves a hole where the anchor should be. + v.once('error', () => { + if (!v.scene) return; + container.removeAll(true); + buildAnchorFallback(scene, container, w, h); + }); + container.add(v); + } else { + buildAnchorFallback(scene, container, w, h); + } + return container; +} + +// ----------------------------------------------------------- story chrome + +function storyFrame(scene, container, x, y, w, h, color) { + const box = scene.add.rectangle(x, y, w, h, 0x0b1220, 0.9).setOrigin(0, 0); + box.setStrokeStyle(4, color, 0.9); + container.add(box); + return box; +} + +function addPortraitBlock(scene, rules, art, container, cx, cy, size, color, speciesId, label, labelColor) { + const half = size / 2; + storyFrame(scene, container, cx - half, cy - half, size, size, color); + const portrait = makeSpeciesPortrait(scene, rules, art, speciesId, cx, cy, size - 20); + container.add(portrait); + if (label) { + container.add(scene.add.text(cx, cy + half + 16, label, { + fontFamily: FONT, fontSize: '22px', color: labelColor ?? '#e8f4ff', + }).setOrigin(0.5, 0)); + } +} + +function addHeadline(scene, container, cy, text, color = '#e8f4ff', fontSize = '28px') { + container.add(scene.add.text(RIGHT_W / 2, cy, text, { + fontFamily: FONT, fontSize, color, align: 'center', wordWrap: { width: RIGHT_W - 120 }, + }).setOrigin(0.5, 0)); +} + +// ------------------------------------------------------------ story pages + +function renderDiplomacyStory(scene, rules, state, art, container, desc) { + const color = STORY_COLORS[desc.accent]; + const size = 420; + const cy = 60 + size / 2; + const leftCx = 60 + size / 2; + const rightCx = RIGHT_W - 60 - size / 2; + const empA = state.empires[desc.a]; + const empB = state.empires[desc.b]; + + addPortraitBlock(scene, rules, art, container, leftCx, cy, size, color, empA.speciesId, empA.name, empA.color); + addPortraitBlock(scene, rules, art, container, rightCx, cy, size, color, empB.speciesId, empB.name, empB.color); + + const g = scene.add.graphics(); + g.lineStyle(4, color, 0.85); + g.lineBetween(leftCx + size / 2, cy, rightCx - size / 2, cy); + container.add(g); + + const verb = scene.add.text((leftCx + rightCx) / 2, cy, desc.verb, { + fontFamily: FONT, fontSize: '28px', color: hex(color), fontStyle: 'bold', + }).setOrigin(0.5); + container.add(verb); + scene.tweens.add({ + targets: verb, scale: { from: 1, to: 1.1 }, duration: 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + + addHeadline(scene, container, cy + size / 2 + 70, desc.headline); +} + +function renderTechStory(scene, rules, state, art, container, desc) { + const color = STORY_COLORS.tech; + const size = 420; + const cy = 60 + size / 2; + const leftCx = 60 + size / 2; + const rightCx = RIGHT_W - 60 - size / 2; + const emp = state.empires[desc.empire]; + + addPortraitBlock(scene, rules, art, container, leftCx, cy, size, color, emp.speciesId, emp.name, emp.color); + + storyFrame(scene, container, rightCx - size / 2, cy - size / 2, size, size, color); + // Breakthrough flourish: expanding outline rings behind the icon, pure + // procedural tween — no new asset pipeline needed. + for (let i = 0; i < 3; i += 1) { + const ring = scene.add.circle(rightCx, cy, 100, color, 0).setStrokeStyle(3, color, 0.8); + container.add(ring); + ring.setScale(0.6); + scene.tweens.add({ + targets: ring, scale: 1.7, alpha: { from: 0.8, to: 0 }, + duration: 1800, delay: i * 600, repeat: -1, ease: 'Sine.easeOut', + }); + } + const icon = scene.add.image(rightCx, cy, art.techicons, techFrame(rules, desc.techId)); + icon.setDisplaySize(220, 220); + container.add(icon); + + addHeadline(scene, container, cy + size / 2 + 70, desc.headline); + if (desc.sub) addHeadline(scene, container, cy + size / 2 + 116, desc.sub, '#9fb6cc', '20px'); +} + +function renderTerritoryStory(scene, rules, state, art, container, desc) { + const color = STORY_COLORS.grim; + const hasAttacker = desc.attacker >= 0; + const victim = state.empires[desc.victim]; + const size = hasAttacker ? 380 : 460; + const cy = 80 + size / 2; + const cx = hasAttacker ? RIGHT_W / 2 - 140 : RIGHT_W / 2; + + storyFrame(scene, container, cx - size / 2, cy - size / 2, size, size, color); + const portrait = makeSpeciesPortrait(scene, rules, art, victim.speciesId, cx, cy, size - 20); + container.add(portrait); + // A somber "falling" read that works on both Video and Image portraits — + // a dark overlay rectangle rather than setTint, which Phaser's Video game + // object does not reliably support. + container.add(scene.add.rectangle(cx, cy, size - 20, size - 20, 0x000000, 0.4)); + container.add(scene.add.text(cx, cy + size / 2 + 16, victim.name, { + fontFamily: FONT, fontSize: '22px', color: victim.color, + }).setOrigin(0.5, 0)); + + if (hasAttacker) { + const attacker = state.empires[desc.attacker]; + const asize = 220; + const acx = RIGHT_W / 2 + 210; + addPortraitBlock(scene, rules, art, container, acx, cy, asize, STORY_COLORS.war, + attacker.speciesId, attacker.name, attacker.color); + const g = scene.add.graphics(); + g.lineStyle(4, STORY_COLORS.war, 0.85); + g.lineBetween(acx - asize / 2 - 8, cy, cx + size / 2 + 8, cy); + container.add(g); + } + + addHeadline(scene, container, cy + size / 2 + 70, desc.headline, '#c7d3e0'); +} + +function renderEspionageStory(scene, rules, state, art, container, desc) { + const color = STORY_COLORS.espionage; + const perp = state.empires[desc.perpetrator]; + const victim = state.empires[desc.victim]; + const size = 380; + const cy = 70 + size / 2; + const leftCx = 60 + size / 2; + const rightCx = RIGHT_W - 60 - size / 2; + + // "Caught red-handed" — a harsh magenta vignette behind the perpetrator's + // portrait with a brief flash on open. + const vignette = scene.add.rectangle(leftCx, cy, size + 60, size + 60, color, 0.5); + container.add(vignette); + scene.tweens.add({ targets: vignette, alpha: { from: 0.75, to: 0.2 }, duration: 450, yoyo: true, repeat: 2 }); + addPortraitBlock(scene, rules, art, container, leftCx, cy, size, color, perp.speciesId, perp.name, perp.color); + addPortraitBlock(scene, rules, art, container, rightCx, cy, size, ACCENT, victim.speciesId, victim.name, victim.color); + + addHeadline(scene, container, cy + size / 2 + 70, desc.headline); + addHeadline(scene, container, cy + size / 2 + 116, desc.sub, '#ff9fd8', '20px'); +} + +function renderUnknownStory(scene, rules, state, art, container, desc) { + addHeadline(scene, container, RIGHT_H / 2 - 20, desc.headline ?? 'Unknown event.'); +} + +const STORY_RENDERERS = { + diplomacy: renderDiplomacyStory, + tech: renderTechStory, + territory: renderTerritoryStory, + espionage: renderEspionageStory, + unknown: renderUnknownStory, +}; + +// ----------------------------------------------------------- ranking page + +const RANKING_ANIM_MS = 4000; +// How long a single row's position swap takes once it's actually triggered +// — deliberately much shorter than RANKING_ANIM_MS so a crossing reads as a +// discrete "it just happened" event rather than a slow continuous drift. +const RANKING_SWAP_MS = 400; + +/** + * Renders a ranking page AND kicks off its 4-second "count-up race" + * animation, returning a `{stop()}` handle so the caller can cancel it — + * both the counter and every in-flight row-swap tween — if the player + * pages away before it finishes (see openGnnScreen's activeRankingAnim). + * + * Every row starts alphabetically ordered and at zero, then climbs toward + * its own final value at one shared rate (value per second — `max/4`, not + * a shared duration), so the leading empire's bar/number are still moving + * right up to the 4-second mark while shorter bars arrive early and hold. + * A row's on-screen position only moves when its LIVE rank actually + * changes — ties (which is what every row is, at the start, and again + * whenever a plateaued row is still equal to one still climbing) resolve + * back to alphabetical order via a stable sort of the same fixed-order row + * list every tick, so nothing reorders until a real numeric crossover + * happens, and the swap itself fires as one short, snappy tween starting + * exactly on the tick that crossover is detected — not a continuous chase. + */ +function renderRankingPage(scene, rules, state, art, container, metric) { + container.add(scene.add.text(RIGHT_W / 2, 16, metric.label, { + fontFamily: FONT, fontSize: '32px', color: '#cfe8ff', fontStyle: 'bold', + }).setOrigin(0.5, 0)); + + const finalRows = Gnn.rankingRows(rules, state, metric.id); + const me = state.humanIndex; + const max = Math.max(1, finalRows[0]?.value ?? 0); + + const barX = 100; + const barW = RIGHT_W - barX - 140; + const rowH = 78; + const startY = 90; + + const alphaOrder = [...finalRows].sort((a, b) => a.name.localeCompare(b.name)); + const finalOrderIdx = new Map(finalRows.map((r, i) => [r.idx, i])); + + const rowVisuals = alphaOrder.map((row, i) => { + const isMe = row.idx === me; + const rowContainer = scene.add.container(0, startY + i * rowH); + container.add(rowContainer); + + const colorInt = Phaser.Display.Color.HexStringToColor(row.color).color; + // Thick species-color frame around the portrait thumbnail. + rowContainer.add(scene.add.rectangle(36, 18, 60, 60, 0x000000, 0).setStrokeStyle(4, colorInt, 1)); + rowContainer.add(makeSpeciesPortrait(scene, rules, art, row.speciesId, 36, 18, 56)); + + rowContainer.add(scene.add.text(barX, 0, row.name + (isMe ? ' ★' : ''), { + fontFamily: FONT, fontSize: '20px', color: row.color, + })); + rowContainer.add(scene.add.rectangle(barX, 32, barW, 16, 0x1b2b42).setOrigin(0, 0)); + + // Horizontal gradient — primarily the species color, brightening toward + // the bar's own tip for a little flair. Drawn once at the row's FULL + // final width and revealed left-to-right via scaleX rather than redrawn + // every frame: a Graphics object scales around its own local (0,0), + // which is the bar's left edge, so the bright tip always lands exactly + // at the bar's true end instead of stretching across whatever sliver is + // currently visible. + const finalW = barW * Phaser.Math.Clamp(row.value / max, 0, 1); + const leftColor = Phaser.Display.Color.HexStringToColor(row.color).darken(8).color; + const rightColor = Phaser.Display.Color.HexStringToColor(row.color).lighten(30).color; + const barG = scene.add.graphics(); + barG.fillGradientStyle(leftColor, rightColor, leftColor, rightColor, 1); + barG.fillRect(0, 0, Math.max(finalW, 0.01), 16); + if (isMe) { + barG.lineStyle(2, 0xffffff, 0.9); + barG.strokeRect(0, 0, Math.max(finalW, 0.01), 16); + } + barG.setPosition(barX, 32); + barG.scaleX = 0; + rowContainer.add(barG); + + const valueText = scene.add.text(barX + barW, 2, '0', { + fontFamily: FONT, fontSize: '20px', color: '#e8f4ff', + }).setOrigin(1, 0); + rowContainer.add(valueText); + + // `rank` starts at this row's alphabetical slot (i) — matches the + // position it's actually drawn in above, so the first tick never sees + // a spurious "rank changed" and fires a swap tween nothing asked for. + return { + idx: row.idx, finalValue: row.value, rowContainer, barG, valueText, rank: i, posTween: null, + }; + }); + + const rate = max / (RANKING_ANIM_MS / 1000); // units/sec — the SAME rate for every row + + // Not the fire-and-forget playSound() helper — that goes through + // scene.sound.play(), which pools playback and hands back no handle to + // stop just this instance. scene.sound.add() gives us our own Sound + // object instead (see playSoundEx's own comment on the tradeoff), which + // the returned stop() below cuts off if the player pages away before it + // finishes. + const counterSound = scene.sound.add(SFX.VEGA_COUNTER, { volume: 0.9 }); + counterSound.play(); + + function movePosition(rv, targetRank) { + rv.rank = targetRank; + rv.posTween?.stop(); + rv.posTween = scene.tweens.add({ + targets: rv.rowContainer, y: startY + targetRank * rowH, + duration: RANKING_SWAP_MS, ease: 'Cubic.easeOut', + }); + } + + const counterTween = scene.tweens.addCounter({ + from: 0, + to: 1, + duration: RANKING_ANIM_MS, + ease: 'Linear', + onUpdate: (tw) => { + const t = tw.getValue() * (RANKING_ANIM_MS / 1000); + // Built fresh from `rowVisuals` (always in fixed alphabetical order) + // every tick, with NO secondary tiebreak — Array.sort is stable, so + // any rows still exactly tied fall back to alphabetical order every + // time rather than whatever the previous tick's order happened to + // be. Only a genuine value difference (which the shared-rate design + // only produces once a row plateaus at its own final value while + // another keeps climbing past it) moves a row in this sort. + const current = rowVisuals.map((rv) => ({ rv, value: Math.min(rv.finalValue, rate * t) })); + current.sort((a, b) => b.value - a.value); + current.forEach(({ rv, value }, i) => { + if (!rv.valueText.scene) return; // page changed mid-tween; onUpdate still fires once more + rv.valueText.setText(fmtValue(value)); + rv.barG.scaleX = rv.finalValue > 0 ? value / rv.finalValue : 0; + if (i !== rv.rank) movePosition(rv, i); + }); + }, + onComplete: () => { + rowVisuals.forEach((rv) => { + if (!rv.valueText.scene) return; + rv.valueText.setText(fmtValue(rv.finalValue)); + rv.barG.scaleX = 1; + rv.posTween?.stop(); + rv.rank = finalOrderIdx.get(rv.idx); + rv.rowContainer.y = startY + rv.rank * rowH; + }); + }, + }); + + return { + stop() { + counterTween.stop(); + rowVisuals.forEach((rv) => rv.posTween?.stop()); + counterSound.stop(); + counterSound.destroy(); + }, + }; +} + +// ---------------------------------------------------------- relations page + +// Column layout: species name/portrait, then three independent relationship +// lists — a treaty is a single rung (war/peace/alliance) but a trade +// agreement coexists with any of them, so it always gets its own column +// rather than folding into the treaty state (see VegaGnn.js's relationsRows). +const RELATIONS_COLS = [ + { key: 'atWar', label: 'AT WAR WITH', color: () => STORY_COLORS.war }, + { key: 'trade', label: 'TRADE AGREEMENTS', color: () => ACCENT }, + { key: 'allied', label: 'ALLIANCES', color: () => STORY_COLORS.peace }, +]; + +/** Always-accessible reference page — who's at war, trading, or allied with + * whom, across every empire still in the galaxy. Not contact-gated, unlike + * the ranking pages: see VegaGnn.js's relationsRows for why. */ +function renderRelationsPage(scene, rules, state, art, container) { + container.add(scene.add.text(RIGHT_W / 2, 16, 'DIPLOMATIC RELATIONS', { + fontFamily: FONT, fontSize: '32px', color: '#cfe8ff', fontStyle: 'bold', + }).setOrigin(0.5, 0)); + + const rows = Gnn.relationsRows(rules, state); + + const nameX = 0; + const nameW = 230; + const gap = 24; + const colW = (RIGHT_W - nameW - gap * 4) / 3; + const colX = RELATIONS_COLS.map((_, i) => nameX + nameW + gap + i * (colW + gap)); + + const headerY = 70; + container.add(scene.add.text(nameX, headerY, 'EMPIRE', { + fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', fontStyle: 'bold', + })); + RELATIONS_COLS.forEach((col, i) => { + container.add(scene.add.text(colX[i], headerY, col.label, { + fontFamily: FONT, fontSize: '15px', color: hex(col.color()), fontStyle: 'bold', + })); + }); + container.add(scene.add.rectangle(nameX, headerY + 26, RIGHT_W, 1, ACCENT, 0.3).setOrigin(0, 0)); + + let y = headerY + 40; + for (const row of rows) { + const portrait = makeSpeciesPortrait(scene, rules, art, row.speciesId, nameX + 24, y + 22, 42); + container.add(portrait); + container.add(scene.add.text(nameX + 52, y + 8, row.name, { + fontFamily: FONT, fontSize: '19px', color: row.color, + })); + + const cellTexts = RELATIONS_COLS.map((col, i) => { + const list = row[col.key]; + const t = scene.add.text(colX[i], y + 6, list.length ? list.join(', ') : '—', { + fontFamily: FONT, fontSize: '15px', color: list.length ? hex(col.color()) : '#5a6b80', + wordWrap: { width: colW }, + }); + container.add(t); + return t; + }); + + const rowH = Math.max(56, ...cellTexts.map((t) => t.height + 20)); + y += rowH; + container.add(scene.add.rectangle(nameX, y - 8, RIGHT_W, 1, ACCENT, 0.12).setOrigin(0, 0)); + } + + if (!rows.length) { + container.add(scene.add.text(RIGHT_W / 2, RIGHT_H / 2, 'No empires remain.', { + fontFamily: FONT, fontSize: '20px', color: '#7f97b3', + }).setOrigin(0.5)); + } +} + +// ----------------------------------------------------------------- pager + +/** + * Opens the full-screen GNN takeover. `opts.onClose` fires once, when the + * screen is dismissed. Consumes every currently-pending story (marking it + * `gnnAnnounced`) the instant it opens — a story is "told" the moment this + * screen is shown, whether or not the player pages all the way through it. + */ +export function openGnnScreen(scene, rules, state, art, opts = {}) { + const { onClose = null } = opts; + + // Duck the regular soundtrack for the whole broadcast: a one-shot opening + // sting, then a looping ambience that takes over the instant the sting + // finishes and keeps playing until the screen closes — same pause()/ + // resume() ducking VegaColonyIntro.js's founding cue already uses, just + // handed off to a loop afterward instead of letting the soundtrack right + // back in. `closed` is declared further down but this callback only ever + // fires asynchronously, well after that declaration has run, so the + // closure over it is safe (same reasoning VegaColonyIntro.js's own + // resumeMusic() relies on). + scene.music?.pause(); + let gnnSting = null; + let gnnLoop = null; + function startGnnLoop() { + if (!scene.cache.audio?.exists(SFX.VEGA_GNN_LOOP)) return; + gnnLoop = scene.sound.add(SFX.VEGA_GNN_LOOP, { loop: true, volume: 0.5 }); + gnnLoop.play(); + } + if (scene.cache.audio?.exists(SFX.VEGA_GNN_STING)) { + gnnSting = scene.sound.add(SFX.VEGA_GNN_STING, { volume: 0.8 }); + gnnSting.once(Phaser.Sound.Events.COMPLETE, () => { + gnnSting = null; + if (closed) return; // screen already closed before the sting finished + startGnnLoop(); + }); + gnnSting.play(); + } else { + startGnnLoop(); + } + + const pending = Gnn.pendingGnnStories(rules, state); + const usingHistory = pending.length === 0; + if (pending.length) Gnn.consumeGnnStories(state, pending); + const storyEvents = usingHistory ? (state.gnn?.history ?? []) : pending; + + const storyPages = storyEvents.map((ev) => ({ kind: 'story', desc: Gnn.describeGnnStory(rules, state, ev) })); + const rankingPages = Gnn.RANKING_METRICS.map((metric) => ({ kind: 'ranking', metric })); + // Always-accessible: present every time GNN opens, independent of + // whether there's a pending story — same footing as the ranking pages. + const relationsPage = { kind: 'relations' }; + const pages = [...storyPages, ...rankingPages, relationsPage]; + + const root = scene.add.container(0, 0).setDepth(D.gnn); + root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x030509, 0.99) + .setOrigin(0, 0).setInteractive()); + + root.add(scene.add.text(LEFT_X, 34, 'GALACTIC NEWS NETWORK', { + fontFamily: FONT, fontSize: '34px', color: '#e8f4ff', + })); + root.add(new Button(scene, GAME_WIDTH - 24 - 20, 44, '✕', () => { + playSound(scene, SFX.VEGA_CLOSE); + close(); + }, { width: 40, height: 36, fontSize: 18, variant: 'ghost' })); + + // Left: the persistent looping anchor video — built once, untouched by + // subsequent page renders, so it keeps playing "for the entire time GNN + // is open, regardless of which page is showing." + frame(scene, root, LEFT_X, CONTENT_TOP, LEFT_W, LEFT_H, 0); + const anchorPanel = buildAnchorPanel(scene, LEFT_W, LEFT_H); + anchorPanel.setPosition(LEFT_X, CONTENT_TOP); + root.add(anchorPanel); + + // Green-screen ticker under the video, bottom-aligned with the right + // panel (TERM_H is computed for exactly that — see its own comment). + // Reuses the Colony Advisor's terminal chrome (VegaColoniesScreen.js) — + // same scanlines/glitch/typing-cursor treatment, different narration. + const terminal = createAdvisorTerminal(scene, LEFT_X, TERM_Y, LEFT_W, TERM_H); + root.add(terminal.container); + + // Right: per-page content. + frame(scene, root, RIGHT_X, CONTENT_TOP, RIGHT_W, RIGHT_H, 0.82); + const content = scene.add.container(RIGHT_X, CONTENT_TOP); + root.add(content); + + const pageLabel = scene.add.text(GAME_WIDTH / 2, FOOTER_Y + 18, '', { + fontFamily: FONT, fontSize: '18px', color: '#7f97b3', + }).setOrigin(0.5, 0); + + // "First page is never blank": with nothing new to report, land on a + // random ranking page (MOO1's own random-stat-on-open convention) rather + // than page 0 of a replay list the player didn't ask to review — Prev + // still walks back into `state.gnn.history` from there, so replaying + // recent stories stays reachable. + let idx = usingHistory + ? storyPages.length + Math.floor(Math.random() * rankingPages.length) + : 0; + + // The ranking page's 3-second count-up race (renderRankingPage) — tracked + // here so paging away or closing mid-animation stops it rather than + // leaving a tween running against a page that's no longer on screen. + let activeRankingAnim = null; + + function renderPage() { + activeRankingAnim?.stop(); + activeRankingAnim = null; + content.removeAll(true); + const page = pages[idx]; + if (page.kind === 'story') { + const renderer = STORY_RENDERERS[page.desc.kind] ?? renderUnknownStory; + renderer(scene, rules, state, art, content, page.desc); + } else if (page.kind === 'ranking') { + activeRankingAnim = renderRankingPage(scene, rules, state, art, content, page.metric); + } else { + renderRelationsPage(scene, rules, state, art, content); + } + // Re-picked and re-typed on every page change — never cached — so + // paging back to an earlier page can land on a different phrasing. + terminal.setLines([Gnn.anchorLine(rules, state, page)]); + pageLabel.setText(`${idx + 1} / ${pages.length}`); + prevBtn.setEnabled(idx > 0); + nextBtn.setEnabled(idx < pages.length - 1); + } + + // First pagination control in the codebase — per the project's own + // "don't build a shared component until a second consumer needs it" rule, + // this stays two plain Button instances with a closure-local index rather + // than a new shared pager component. + const prevBtn = new Button(scene, GAME_WIDTH / 2 - 140, FOOTER_Y + 18, '‹ Prev', () => { + if (idx <= 0) return; + idx -= 1; + playSound(scene, SFX.VEGA_SELECT); + renderPage(); + }, { width: 160, height: 46, fontSize: 18, variant: 'ghost' }); + const nextBtn = new Button(scene, GAME_WIDTH / 2 + 140, FOOTER_Y + 18, 'Next ›', () => { + if (idx >= pages.length - 1) return; + idx += 1; + playSound(scene, SFX.VEGA_SELECT); + renderPage(); + }, { width: 160, height: 46, fontSize: 18, variant: 'ghost' }); + root.add(prevBtn); + root.add(nextBtn); + root.add(pageLabel); + + renderPage(); + + let closed = false; + function close() { + if (closed) return; + closed = true; + scene.input.keyboard?.off('keydown-ESC', onEsc); + // Explicit, not left to root.destroy()'s cascade — the terminal owns + // its own timers/tween/scroll-mask (see createAdvisorTerminal's own + // destroy(), VegaColoniesScreen.js), same as every other consumer of it. + terminal.destroy(); + activeRankingAnim?.stop(); + gnnSting?.stop(); + gnnLoop?.stop(); + scene.music?.resume(); + root.destroy(); + onClose?.(); + } + function onEsc() { close(); } + scene.input.keyboard?.on('keydown-ESC', onEsc); + + return { root, close }; +} diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 0764ab5..6a8475a 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -420,6 +420,7 @@ export function createGame(rules, opts) { nextFleetId: 0, events: [], council: { nextTurn: rules.council.firstTurn, lastResult: null, history: [] }, + gnn: { history: [] }, over: false, winnerIdx: -1, victoryKind: null, @@ -1612,7 +1613,8 @@ export function bombard(rules, state, e, starIdx, orbit = null) { state.colonies = state.colonies.filter((c) => c !== colony); pushEvent(state, { type: 'colonyDestroyed', empire: e, target: owner, starIdx, turn: state.turn }); recomputeTotals(rules, state); - checkElimination(rules, state, owner); + checkLastColony(state, owner, e); + checkElimination(rules, state, owner, e); return { destroyed: true, killed: Math.round(before) }; } recomputeTotals(rules, state); @@ -1699,7 +1701,8 @@ export function invade(rules, state, e, starIdx, orbit = null) { pushEvent(state, { type: 'captured', empire: e, from, starIdx, colonyId: colony.id, turn: state.turn }); state.empires[from].attitude[e] = Math.max(-100, Math.min(100, (state.empires[from].attitude[e] ?? 0) - 40)); state.empires[e]._range = null; state.empires[e]._rangeAt = -1; - checkElimination(rules, state, from); + checkLastColony(state, from, e); + checkElimination(rules, state, from, e); } else { pushEvent(state, { type: 'invasionFailed', empire: e, starIdx, turn: state.turn }); } @@ -1707,7 +1710,23 @@ export function invade(rules, state, e, starIdx, orbit = null) { return result; } -function checkElimination(rules, state, e) { +// Pushed the instant an attack (capture or bombardment) leaves the victim +// with exactly one colony — the beat GNN's "reduced to their last world" +// story reads off. Checked only from the two attack call sites that already +// know the attacker (bombard/invade); the generic end-of-turn elimination +// sweep below has no attacker in scope by construction and is not a +// colony-loss path itself, so it never needs this check. +function checkLastColony(state, victim, attacker) { + const emp = state.empires[victim]; + if (!emp.alive) return; + if (empireColonies(state, victim).length === 1) { + pushEvent(state, { type: 'lastColony', empire: victim, attacker, turn: state.turn }); + } +} + +// `attacker` defaults to -1 ("unknown/none") for the generic end-of-turn +// sweep call site, which isn't tied to any specific attack. +function checkElimination(rules, state, e, attacker = -1) { const emp = state.empires[e]; if (!emp.alive) return; if (empireColonies(state, e).length > 0) return; @@ -1716,7 +1735,7 @@ function checkElimination(rules, state, e) { // scuttled. Leaving them on the map produced fleets belonging to a dead // empire that nothing would ever clean up. state.fleets = state.fleets.filter((f) => f.empireIdx !== e); - pushEvent(state, { type: 'eliminated', empire: e, turn: state.turn }); + pushEvent(state, { type: 'eliminated', empire: e, attacker, turn: state.turn }); } // -------------------------------------------------------------------------- @@ -2108,9 +2127,11 @@ export function autoQueueColonies(rules, state, e) { * Empire-wide fleet strength — the same computation VegaAI.js's * computeStrategy uses for its own `myFleet` (VegaAI.js:71), reused here so * "fleet condition of the species" means what the AI already judges itself - * by, rather than a second invented metric. + * by, rather than a second invented metric. Exported (renamed from the old + * human-only `myFleetPower`) so GNN's Military ranking page can compute the + * same number for every empire, not just the human's. */ -function myFleetPower(rules, state, e) { +export function empireFleetPower(rules, state, e) { return empireFleets(state, e).reduce((t, f) => t + fleetPower(rules, state, f), 0); } @@ -2153,7 +2174,7 @@ export function recommendColonyFocus(rules, state, colony) { }; } - if (myFleetPower(rules, state, e) < fleetAdequacyThreshold(state)) { + if (empireFleetPower(rules, state, e) < fleetAdequacyThreshold(state)) { return { value: 'fleet', label: 'Fleet Production', reason: "Empire-wide fleet strength is below what's typical this far into the game — " @@ -2231,7 +2252,7 @@ export function recommendAllocationFocus(rules, state, colony) { }; } - if (myFleetPower(rules, state, e) < fleetAdequacyThreshold(state)) { + if (empireFleetPower(rules, state, e) < fleetAdequacyThreshold(state)) { return { key: 'military', label: 'Military Buildup', reason: "Empire-wide fleet strength is below what's typical this far into the game.", @@ -2530,6 +2551,17 @@ export function deserialize(json) { emp.fleetIntrusions ??= {}; emp.espionageMission ??= {}; } + // Same convention for a save from before GNN existed — but a bare + // `gnn ??= {history:[]}` is not enough here: state.events is never cleared + // (only capped/trimmed), so a save with retained war/peace/tech events + // would otherwise have every one of them treated as a brand-new unreported + // GNN story the instant the player next ends a turn, flooding them with + // retroactive news. Marking every existing event gnnAnnounced up front + // prevents that burst — GNN only ever reports what happens from here on. + if (!state.gnn) { + state.gnn = { history: [] }; + for (const ev of state.events) ev.gnnAnnounced = true; + } // Same convention for a save from before Colony Focus existed. for (const c of state.colonies) { c.focus ??= 'manual'; diff --git a/src/games/mastervega/VegaRules.js b/src/games/mastervega/VegaRules.js index 4079695..3d3512b 100644 --- a/src/games/mastervega/VegaRules.js +++ b/src/games/mastervega/VegaRules.js @@ -62,6 +62,7 @@ export function compileRules(json) { 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`); + need(t.gnnHeadline === undefined || typeof t.gnnHeadline === 'boolean', `tech ${t.id} gnnHeadline must be boolean`); } if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`); diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 26ab064..14466af 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -27,7 +27,7 @@ export const FONT = '"Julius Sans One"'; // vignette, which opens over the system view it was triggered from and must // cover everything except the end-of-game overlay. export const D = { - map: 1, hud: 30, modal: 60, colony: 70, detail: 76, intro: 78, toast: 80, + map: 1, hud: 30, modal: 60, colony: 70, gnn: 72, detail: 76, intro: 78, toast: 80, }; /** diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index 4c1827b..274adc7 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -111,6 +111,9 @@ export const SFX = { VEGA_ZOOMOUT: 'sfx-vega-zoomout', VEGA_VIEWCOLONY: 'sfx-vega-viewcolony', VEGA_ENDTURN: 'sfx-vega-endturn', + VEGA_COUNTER: 'sfx-vega-counter', + VEGA_GNN_STING: 'sfx-vega-gnn-sting', + VEGA_GNN_LOOP: 'sfx-vega-gnn-loop', // Beam cues split by the firing ship's Mark (I-VII); missile cues split the // same way but only two ways, and further split into launch vs. impact. // See VegaCombatView.js's weaponSfxKey()/missileLaunchSfxKey()/ diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index f1e7d62..a74dc2e 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -48,6 +48,9 @@ import { CHAT } from '../src/games/mastervega/VegaChat.js'; // Turn-report classification is Phaser-free, so what the "New Turn" popup will // and will not interrupt the player for is checkable here. import { NOTABLE_TYPES, describeEvent, TYPE_LABEL, groupShipDoneEvents } from '../src/games/mastervega/VegaTurnReport.js'; +// Galactic News Network — also Phaser-free (VegaGnnScreen.js is the Phaser +// half), so its classification/copy/ranking logic is checkable here. +import * as Gnn from '../src/games/mastervega/VegaGnn.js'; // Ship media is addressed here and nowhere else, so the key convention is // checkable without a canvas. import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js'; @@ -108,6 +111,15 @@ section('1. Rules integrity'); 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); + + // GNN headline techs — hand-curated, not threshold-based (Brian's ask), so + // this just sanity-checks the curation stayed within the intended range + // rather than picking specific ids. + const headlineTechs = RULES.techList.filter((t) => t.gnnHeadline); + check('gnnHeadline tech count is within the intended 12-20 range', + headlineTechs.length >= 12 && headlineTechs.length <= 20, `${headlineTechs.length}`); + check('gnnHeadline is only ever true or absent', RULES.techList.every( + (t) => t.gnnHeadline === undefined || t.gnnHeadline === true)); 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', @@ -2695,6 +2707,311 @@ section('7. Diplomacy and the Galactic Council'); } } +// --------------------------------------------------------------------------- +section('7b. Galactic News Network'); +// --------------------------------------------------------------------------- +{ + // --- classification: isGnnStory / pendingGnnStories, hand-built events so + // every branch (research+headline, espionage-sourced, non-headline, + // silent-sabotage, spyCaught, already-announced) is exercised directly + // rather than waiting for a soak to happen to produce one of each. + { + const headlineTech = 'lasercannon'; // gnnHeadline: true, tier 0 weapons + const plainTech = 'battlescanner'; // not flagged + check('fixture headline tech is actually flagged', RULES.techs[headlineTech]?.gnnHeadline === true); + check('fixture plain tech is not flagged', !RULES.techs[plainTech]?.gnnHeadline); + + const evResearchHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'research' }; + const evEspionageHeadline = { type: 'techDone', empire: 0, techId: headlineTech, source: 'espionage' }; + const evResearchPlain = { type: 'techDone', empire: 0, techId: plainTech, source: 'research' }; + const evSabotage = { type: 'sabotage', empire: 0, target: 1, starIdx: 0 }; + const evTechStolen = { type: 'techStolen', empire: 0, target: 1, techId: headlineTech }; + const evSpyCaught = { type: 'spyCaught', empire: 0, target: 1, mission: 'sabotage' }; + const evPeace = { type: 'peace', empire: 0, other: 1 }; + const evWarAlreadyAnnounced = { type: 'warDeclared', empire: 0, other: 1, gnnAnnounced: true }; + + check('research-sourced headline tech is a GNN story', Gnn.isGnnStory(RULES, evResearchHeadline)); + check('espionage-sourced headline tech is NOT a GNN story (nobody can publicly name the thief\'s target)', + !Gnn.isGnnStory(RULES, evEspionageHeadline)); + check('research-sourced non-headline tech is NOT a GNN story', !Gnn.isGnnStory(RULES, evResearchPlain)); + check('a successful sabotage is never a GNN story (silent theft is unattributable)', + !Gnn.isGnnStory(RULES, evSabotage)); + check('a successful tech theft is never a GNN story', !Gnn.isGnnStory(RULES, evTechStolen)); + check('a caught spy ("exposed") is a GNN story', Gnn.isGnnStory(RULES, evSpyCaught)); + check('peace is a GNN story', Gnn.isGnnStory(RULES, evPeace)); + + const fakeState = { + events: [ + evResearchHeadline, evEspionageHeadline, evResearchPlain, evSabotage, + evTechStolen, evSpyCaught, evPeace, evWarAlreadyAnnounced, + ], + }; + const pending = Gnn.pendingGnnStories(RULES, fakeState); + check('pendingGnnStories excludes an already-gnnAnnounced event', !pending.includes(evWarAlreadyAnnounced)); + check('pendingGnnStories returns exactly the expected set', + pending.length === 3 && pending.includes(evResearchHeadline) + && pending.includes(evSpyCaught) && pending.includes(evPeace), + `got ${pending.length}: ${pending.map((e) => e.type).join(',')}`); + } + + // --- consumeGnnStories: flags set, history ordered, cap-and-trim. + { + const csState = { gnn: { history: [] } }; + const toConsume = [ + { type: 'peace', empire: 0, other: 1 }, + { type: 'spyCaught', empire: 0, target: 1, mission: 'steal' }, + ]; + Gnn.consumeGnnStories(csState, toConsume); + check('consumeGnnStories flags every consumed event announced', + toConsume.every((ev) => ev.gnnAnnounced === true)); + check('consumeGnnStories appends to history in order', + csState.gnn.history.length === 2 + && csState.gnn.history[0] === toConsume[0] && csState.gnn.history[1] === toConsume[1]); + + const bigState = { gnn: { history: [] } }; + const many = []; + for (let i = 0; i < 40; i += 1) many.push({ type: 'peace', empire: 0, other: 1, n: i }); + Gnn.consumeGnnStories(bigState, many); + check('consumeGnnStories caps history at 30, keeping the most recent', + bigState.gnn.history.length === 30 + && bigState.gnn.history[0].n === 10 && bigState.gnn.history[29].n === 39); + } + + // --- describeGnnStory: never throws, always a non-empty headline, and the + // "by X" clause is present only when the attacker is actually known. + { + const stD = Logic.createGame(RULES, { + sizeId: 'small', shapeId: 'ring', seed: 4242, difficultyId: 'normal', + speciesIds: ['human', 'kkrix', 'rrashaa'], humanIndex: 0, + }); + stD.rules = RULES; + + const sampleEvents = [ + { type: 'warDeclared', empire: 0, other: 1 }, + { type: 'peace', empire: 0, other: 1 }, + { type: 'alliance', empire: 0, other: 1 }, + { type: 'techDone', empire: 0, techId: 'lasercannon', source: 'research' }, + { type: 'lastColony', empire: 1, attacker: 0 }, + { type: 'lastColony', empire: 1, attacker: -1 }, + { type: 'eliminated', empire: 1, attacker: 0 }, + { type: 'eliminated', empire: 1, attacker: -1 }, + { type: 'spyCaught', empire: 0, target: 1, mission: 'sabotage' }, + ]; + for (const ev of sampleEvents) { + const desc = Gnn.describeGnnStory(RULES, stD, ev); + check(`describeGnnStory(${ev.type}, attacker=${ev.attacker}) has a non-empty headline`, + typeof desc.headline === 'string' && desc.headline.length > 0); + } + check('describeGnnStory omits the "by X" clause when the attacker is unknown', + !Gnn.describeGnnStory(RULES, stD, { type: 'eliminated', empire: 1, attacker: -1 }).headline.includes(' by ')); + check('describeGnnStory names the attacker when known', + Gnn.describeGnnStory(RULES, stD, { type: 'eliminated', empire: 1, attacker: 0 }) + .headline.includes(stD.empires[0].name)); + } + + // --- anchorLine: the green-screen ticker narration under GNN's anchor + // video — never throws, always non-empty, for every page kind the pager + // can actually build, and correctly distinguishes a quiet galaxy from a + // busy one on the relations page. + { + const stD = Logic.createGame(RULES, { + sizeId: 'small', shapeId: 'ring', seed: 4242, difficultyId: 'normal', + speciesIds: ['human', 'kkrix', 'rrashaa'], humanIndex: 0, + }); + stD.rules = RULES; + + const sampleEvents = [ + { type: 'warDeclared', empire: 0, other: 1 }, + { type: 'peace', empire: 0, other: 1 }, + { type: 'alliance', empire: 0, other: 1 }, + { type: 'techDone', empire: 0, techId: 'lasercannon', source: 'research' }, + { type: 'lastColony', empire: 1, attacker: 0 }, + { type: 'lastColony', empire: 1, attacker: -1 }, + { type: 'eliminated', empire: 1, attacker: 0 }, + { type: 'eliminated', empire: 1, attacker: -1 }, + { type: 'spyCaught', empire: 0, target: 1, mission: 'sabotage' }, + { type: 'spyCaught', empire: 0, target: 1, mission: 'steal' }, + ]; + for (const ev of sampleEvents) { + const desc = Gnn.describeGnnStory(RULES, stD, ev); + const line = Gnn.anchorLine(RULES, stD, { kind: 'story', desc }); + check(`anchorLine(story ${ev.type}, attacker=${ev.attacker}) is a non-empty string`, + typeof line === 'string' && line.length > 0); + } + for (const metric of Gnn.RANKING_METRICS) { + const line = Gnn.anchorLine(RULES, stD, { kind: 'ranking', metric }); + check(`anchorLine(ranking ${metric.id}) is a non-empty string`, + typeof line === 'string' && line.length > 0); + } + const quietLine = Gnn.anchorLine(RULES, stD, { kind: 'relations' }); + check('anchorLine(relations) is a non-empty string', typeof quietLine === 'string' && quietLine.length > 0); + check('anchorLine(relations) uses the "quiet" phrasing when there is nothing to report', + /calm|quiet/i.test(quietLine)); + + const stR = Logic.createGame(RULES, { + sizeId: 'small', shapeId: 'ring', seed: 3131, difficultyId: 'normal', + speciesIds: ['human', 'kkrix'], humanIndex: 0, + }); + stR.rules = RULES; + Diplo.declareWar(RULES, stR, 0, 1); + const busyLine = Gnn.anchorLine(RULES, stR, { kind: 'relations' }); + check('anchorLine(relations) switches out of the "quiet" phrasing once a war exists', + typeof busyLine === 'string' && busyLine.length > 0 && !/calm|quiet/i.test(busyLine)); + } + + // --- lastColony / eliminated.attacker, scripted through the real + // invade() call site (not a hand-built fixture) so the attacker-threading + // change to VegaLogic.js is exercised end-to-end. + { + const st = Logic.createGame(RULES, { + sizeId: 'medium', shapeId: 'elliptical', seed: 606, difficultyId: 'normal', + speciesIds: ['human', 'kkrix'], humanIndex: -1, + }); + st.rules = RULES; + const attacker = 0; + const victim = 1; + st.empires[attacker].contacted[victim] = true; + st.empires[victim].contacted[attacker] = true; + Diplo.declareWar(RULES, st, attacker, victim); + + const homeColony = st.colonies.find((c) => c.empireIdx === victim); + const secondStarIdx = st.galaxy.stars.findIndex((s, i) => i !== homeColony.starIdx && s.planets.length > 0); + Logic.foundColony(RULES, st, victim, secondStarIdx, 0, 5); + check('victim starts the scenario with two colonies', Logic.empireColonies(st, victim).length === 2); + + // Overwhelming force (300 transports vs. an undefended pop-1 colony) + // makes capture as close to certain as resolveInvasion's RNG allows, + // without having to reverse-engineer its exact odds curve — see + // VegaCombat.js's resolveInvasion for why a huge attacker/defender + // troop ratio dominates regardless of the attack/defence-bonus term. + function overwhelmAndInvade(colony) { + colony.pop = 1; + colony.defenseHp = 0; + Logic.addFleet(RULES, st, attacker, colony.starIdx, [{ hullId: 'transport', count: 300 }]); + return Logic.invade(RULES, st, attacker, colony.starIdx, colony.orbit); + } + + const firstColony = Logic.empireColonies(st, victim)[0]; + const r1 = overwhelmAndInvade(firstColony); + check('first invasion captures the colony', !!r1?.captured, JSON.stringify(r1)); + check('victim is reduced to one colony, not eliminated, after the first loss', + Logic.empireColonies(st, victim).length === 1 && st.empires[victim].alive); + check('a lastColony event fired with the correct victim and attacker', + st.events.some((e) => e.type === 'lastColony' && e.empire === victim && e.attacker === attacker)); + check('no eliminated event fired yet', + !st.events.some((e) => e.type === 'eliminated' && e.empire === victim)); + + const secondColony = Logic.empireColonies(st, victim)[0]; + const r2 = overwhelmAndInvade(secondColony); + check('second invasion captures the last colony', !!r2?.captured, JSON.stringify(r2)); + check('victim is eliminated after the second loss', + Logic.empireColonies(st, victim).length === 0 && !st.empires[victim].alive); + check('an eliminated event fired with the correct attacker', + st.events.some((e) => e.type === 'eliminated' && e.empire === victim && e.attacker === attacker)); + check('no spurious second lastColony event fired on total elimination', + st.events.filter((e) => e.type === 'lastColony' && e.empire === victim).length === 1); + } + + // --- the generic end-of-turn elimination sweep has no attacker in scope + // by construction (it isn't tied to any specific attack) — proves it + // defaults to -1 rather than throwing or mislabeling one. + { + const st3 = Logic.createGame(RULES, { + sizeId: 'small', shapeId: 'ring', seed: 707, difficultyId: 'normal', + speciesIds: ['human', 'kkrix'], humanIndex: -1, + }); + st3.rules = RULES; + st3.colonies = st3.colonies.filter((c) => c.empireIdx !== 1); + Logic.endEmpireTurn(RULES, st3, st3.current, { skipMove: true }); + const sweepElim = st3.events.filter((e) => e.type === 'eliminated' && e.empire === 1); + check('the generic end-of-turn sweep fires eliminated exactly once for a colonyless empire', + sweepElim.length === 1); + check('the generic sweep has no attacker in scope, so it defaults to -1', + sweepElim[0]?.attacker === -1); + } + + // --- ranking metrics: finite and non-negative across a real, played-out + // multi-turn galaxy (not just a hand-built fixture). + { + const st4 = Logic.createGame(RULES, { + sizeId: 'medium', shapeId: 'spiral', seed: 808, difficultyId: 'normal', + speciesIds: ['human', 'kkrix', 'rrashaa', 'lithox'], humanIndex: -1, + }); + st4.rules = RULES; + for (let i = 0; i < 30 * st4.empires.length; i += 1) { + Logic.beginEmpireTurn(RULES, st4, st4.current); + AI.runAITurn(RULES, st4, st4.current); + Logic.endEmpireTurn(RULES, st4, st4.current); + } + let allFinite = true; + let detail = ''; + for (const metric of Gnn.RANKING_METRICS) { + for (const e of st4.empires) { + if (!e.alive) continue; + const v = metric.valueFn(RULES, st4, e.idx); + if (!Number.isFinite(v) || v < 0) { allFinite = false; detail = `${metric.id} empire ${e.idx}: ${v}`; } + } + } + check('every ranking metric is finite and non-negative across a real multi-turn galaxy', + allFinite, detail); + } + + // --- rankingRows contact-gating, same shape VegaScreens.js:164 already + // uses for "empires I know about." + { + const st5 = Logic.createGame(RULES, { + sizeId: 'medium', shapeId: 'elliptical', seed: 909, difficultyId: 'normal', + speciesIds: ['human', 'kkrix', 'rrashaa'], humanIndex: 0, + }); + st5.rules = RULES; + st5.empires[0].totalPop = 500; + st5.empires[1].totalPop = 900; // met — should appear, and rank first + st5.empires[2].totalPop = 100; // never met — must not appear + st5.empires[0].contacted[1] = true; + st5.empires[1].contacted[0] = true; + + const rows = Gnn.rankingRows(RULES, st5, 'population'); + check('rankingRows includes only the human and empires the human has met', + rows.length === 2 && rows.every((r) => r.idx === 0 || r.idx === 1)); + check('rankingRows excludes the un-contacted empire', !rows.some((r) => r.idx === 2)); + check('rankingRows is sorted descending by value', rows[0].idx === 1 && rows[0].value >= rows[1].value); + } + + // --- relationsRows: war/trade/alliance are three independent columns + // (formTradeAgreement's own comment: trade coexists with any treaty rung + // rather than being another value on it), and — unlike rankingRows — + // NOT contact-gated, since diplomacy events already broadcast regardless + // of contact (VegaTurnReport.js's PERSONAL_TYPES). + { + const st6 = Logic.createGame(RULES, { + sizeId: 'medium', shapeId: 'elliptical', seed: 1010, difficultyId: 'normal', + speciesIds: ['human', 'kkrix', 'rrashaa', 'lithox'], humanIndex: 0, + }); + st6.rules = RULES; + // 0 (human) at war with 1; 1 and 2 allied; 0 and 2 have a trade + // agreement; 3 stays uncontacted and relation-free throughout. + Diplo.declareWar(RULES, st6, 0, 1); + Diplo.formAlliance(RULES, st6, 1, 2); + Diplo.formTradeAgreement(RULES, st6, 0, 2); + + const relRows = Gnn.relationsRows(RULES, st6); + check('relationsRows returns one row per alive empire, contact or not', + relRows.length === 4); + const byIdx = Object.fromEntries(relRows.map((r) => [r.idx, r])); + check('war is reciprocal', byIdx[0].atWar.includes(st6.empires[1].name) + && byIdx[1].atWar.includes(st6.empires[0].name)); + check('alliance is reciprocal and separate from war', + byIdx[1].allied.includes(st6.empires[2].name) && byIdx[2].allied.includes(st6.empires[1].name) + && byIdx[1].atWar.length === 1 && !byIdx[1].atWar.includes(st6.empires[2].name)); + check('trade agreement is reciprocal and independent of treaty state', + byIdx[0].trade.includes(st6.empires[2].name) && byIdx[2].trade.includes(st6.empires[0].name)); + check('an empire with no relations at all has three empty lists', + byIdx[3].atWar.length === 0 && byIdx[3].trade.length === 0 && byIdx[3].allied.length === 0); + check('an empire not at war with everyone does not falsely list the uninvolved', + !byIdx[0].atWar.includes(st6.empires[2].name) && !byIdx[0].atWar.includes(st6.empires[3].name)); + } +} + // --------------------------------------------------------------------------- section('8. Leaders'); // --------------------------------------------------------------------------- @@ -2763,6 +3080,8 @@ section('9. Serialisation'); 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('state.gnn round-trips through save/load unchanged', + JSON.stringify(back.gnn) === JSON.stringify(st.gnn)); 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')); @@ -2789,6 +3108,12 @@ section('9. Serialisation'); // Colony Focus follows the same convention: an old save has no `focus` at // all on any colony. Advisor tracking is the same again, one field newer. for (const c of oldSave.colonies) { delete c.focus; delete c.advisor; } + // GNN is one field newer still, but its back-fill is NOT a bare ??={} — + // deserialize() must also mark every already-retained event gnnAnnounced, + // or a save from before GNN existed would surface a burst of retroactive + // "news" for every war/peace/tech event state.events happened to still be + // holding onto (see VegaLogic.js deserialize's own comment on this). + delete oldSave.gnn; const backOld = Logic.deserialize(JSON.stringify(oldSave)); check('an old save missing the new diplomacy fields deserializes without throwing', !!backOld); check('tradeAgreements is back-filled to {} on an old save', @@ -2802,6 +3127,13 @@ section('9. Serialisation'); check('advisor tracking is back-filled to all-null on an old save', backOld.colonies.every((c) => c.advisor && c.advisor.focusQuietUntil === null && c.advisor.allocQuietUntil === null && c.advisor.allocKey === null)); + check('gnn is back-filled to {history:[]} on an old save', + JSON.stringify(backOld.gnn) === JSON.stringify({ history: [] })); + check('every event already on an old save is marked gnnAnnounced (no retroactive news flood)', + backOld.events.length > 0 && backOld.events.every((e) => e.gnnAnnounced === true), + `${backOld.events.length} events`); + check('pendingGnnStories reports nothing new for a freshly-backfilled old save', + Gnn.pendingGnnStories(RULES, backOld).length === 0); backOld.rules = RULES; check('the freshly-backfilled state tolerates a full galaxy diplomacy pass', (() => { Diplo.runGalaxyDiplomacyPass(RULES, backOld); @@ -2992,6 +3324,36 @@ section('10. AI self-play soak'); 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}`); + // GNN classification/copy over real, played-out data — catches any + // realistic type/field/attacker-value combination the hand-built fixtures + // in section 7b didn't happen to cover. state.events is capped/trimmed to + // the most recent 300 (pushEvent), so this only sees each game's tail — + // still a wide spread across N full games. + { + let storyEventCount = 0; + let lastColonyCount = 0; + let describeFailure = null; + for (const g of games) { + for (const ev of g.st.events) { + if (ev.type === 'lastColony') lastColonyCount += 1; + if (!Gnn.isGnnStory(RULES, ev)) continue; + storyEventCount += 1; + try { + const desc = Gnn.describeGnnStory(RULES, g.st, ev); + if (!desc.headline) describeFailure = `empty headline for ${ev.type}`; + } catch (err) { + describeFailure = `${ev.type}: ${err.message}`; + } + } + } + check('describeGnnStory never throws and always returns a headline across the AI soak', + !describeFailure, describeFailure ?? ''); + check('at least one GNN story event occurs somewhere in the sweep', storyEventCount > 0, `${storyEventCount}`); + if (!QUICK) { + check('lastColony fires at least once somewhere in the sweep', lastColonyCount > 0, `${lastColonyCount}`); + } + } + // The diplomacy-expansion passes run every calendar turn of every game in // this sweep — if fleet complaints never fire across dozens of full games, // something upstream (contact, colonization, fleet movement feeding