feat: add Galactic News Network (GNN) broadcast system
Introduce GNN, a galaxy-wide news broadcast feature that reports major events to the player via a full-screen takeover UI with anchor desk video, story pages, MOO1-style animated rankings, and a diplomatic relations reference. Key changes: - VegaGnn.js: headless event classification, anchor copywriting, and ranking logic (Phaser-free, testable in Node) - VegaGnnScreen.js: full-screen UI with looping anchor video, green-screen ticker terminal, story renderers (diplomacy/tech/ territory/espionage), animated bar-chart rankings, and relations page - HUD: new GNN button with unread bullet cue; auto-opens after turns when stories are pending, on-demand via button - VegaLogic.js: new `lastColony` event (reduced to single world), `attacker` field on elimination events, `gnn` state with history buffer, export empireFleetPower for multi-empire rankings - VegaColoniesScreen.js: export and generalize createAdvisorTerminal for reuse by GNN's ticker - mastervega-rules.json: flag ~16 headline-worthy techs with gnnHeadline (research-sourced only, not espionage-stolen) - Assets: eager-load GNN anchor video, sting/loop audio, and populate all 10 species colony advisor videos in mastervega-artwork.json - verifyMasterOfVega.js: comprehensive 7b test section covering classification, consumption, describeGnnStory, anchorLine, attacker threading, ranking metrics, relationsRows, serialization, and AI soak
This commit is contained in:
parent
ea70fb4e07
commit
0c6c4b8452
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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-<speciesId>.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": {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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."]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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('; ')}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue