Compare commits

...

5 Commits

Author SHA1 Message Date
Brian Fertig 223c1fd191 Implement dynamic leader hiring costs and improved assignment UI
- Add scaling leader hire costs based on number of leaders already owned (costScalePerOwned: 0.5), making it progressively more expensive to hire multiple leaders and preventing cheap roster stockpiling
- Add leaderHireCost() function in VegaLogic.js to calculate scaled hire costs
- Add unassignLeader() function to allow removing leader assignments
- Update AI in VegaLeaders.js to use dynamic pricing when deciding to hire leaders
- Add research videos for ursaal, kkrix, and mekhan factions
- Enhance leader screen UI with:
  - Dynamic cost display reflecting current scaling
  - Fleet labeling showing ship composition and location
  - Assign/Reassign buttons with list picker for colonies (admins) and fleets (captains)
  - Unassign button for posted leaders
  - Better location display for assigned leaders
2026-08-08 14:10:32 -06:00
Brian Fertig b721b6221e feat(mastervega): expand diplomacy, trade, espionage, and map rendering
- Add full diplomacy subsystem: tiered gifts (BC payments with diminishing returns), trade agreements (passive BC income, coexists with treaties, cancelled on war), and espionage mission toggles (Off/Steal/Sabotage) in the Audience screen.
- Implement whole-galaxy diplomacy passes: border and fleet proximity tension, fleet intrusion tracking with escalating penalties and force-opened Audience complaints, and "enemy of my enemy" attitude triangulation.
- Update Audience screen UI with new action buttons, gift picker, intelligence toggle, and species-specific chat dialogue for all diplomacy events.
- Add research choice prompts for ambiguous tech frontier picks, surfaced before the turn report.
- Improve star map: Delaunay triangulation for controlled-space range darkness, staggered docked fleet markers, expanded pan slack to clear UI chrome, and zoom-aware colony info labels.
- Backward-compatible save serialization for new diplomacy fields.
- Add comprehensive test coverage for all new logic, chat completeness, and AI soak validation.
2026-08-08 13:44:14 -06:00
Brian Fertig d8d127f33a feat(mastervega): add research choice prompts, Delaunay-controlled-space corridors, and map UX improvements
- Introduce VegaResearchChoiceScreen for ambiguous tech selections: when
  a player completes research that opens a 2+ way choice rung, they're
  prompted before the turn report to pick their next technology.

- Add VegaDelaunay.js (Bowyer-Watson Delaunay triangulation) and use it
  in VegaStarMap to draw "controlled space" corridors/fills between the
  player's own colonized stars instead of a blanket radius.

- Improve star map UX: stagger docked fleet markers per-star, add
  PAN_SLACK so edge stars can be dragged out from under the side panel,
  and show colony info bars at mid-zooms with text only at near-zoom.

- Add `closable` option to modalShell so forced-choice screens can
  suppress the ✕ button.

- Export buildVideoPanel from VegaResearchScreen for reuse by the new
  choice screen.

- Update artwork JSON: rrashaa and cerebrai audience videos now use
  species-specific clips; add intro-human.mp3 and new video assets.

- Add tests for openResearchChoices and pendingResearchChoices filtering
  logic in verifyMasterOfVega.js.
2026-08-08 08:39:45 -06:00
Brian Fertig 582b9acfc7 feat(mastervega): add second branching pass with 20+ new alternate techs and fieldTechLevel
- Add 21 new alternate techs across all 6 fields (computers, construction,
  forcefields, planetology, propulsion, weapons), using icon frames 78-96
- Set research video paths for human and kestrelli species
- Expand tech sprite sheet from 8 to 10 rows to accommodate new frames
- Add fieldTechLevel() to VegaLogic: displays per-field level as highest
  known tier × 5 plus extra known techs below the frontier
- Display field level in research screen headers
- Add verification tests for 3-way branching rungs, fieldTechLevel calculations,
  and refine availability roll tests to measure unbranched rungs only
2026-08-07 22:28:13 -06:00
Brian Fertig 27aff33429 feat(mastervega): redesign research screen tech tree and add direct navigation
- Replace vertical "research ladder" list with a visual tech tree rendered as
  connected node badges showing prerequisites and branch structure
- Add `initialField` parameter to `openResearchScreen` to open on a specific
  category instead of always defaulting to the first one
- Add "View Research" shortcut button on turn report when a tech completes,
  jumping directly to that field
- Add Cerebrai species research video (research-cerebrai.mp4)
2026-08-07 21:49:03 -06:00
51 changed files with 2490 additions and 181 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

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.

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -90,8 +90,24 @@
"cruiser": { "key": "vega-ship-cerebrai-cruiser", "path": "assets/videos/vega/ship-cerebrai-cruiser.mp4" },
"battleship": { "key": "vega-ship-cerebrai-battleship", "path": "assets/videos/vega/ship-cerebrai-battleship.mp4" }
},
"ssakar": null,
"lithox": null
"ssakar": {
"scout": { "key": "vega-ship-ssakar-scout", "path": "assets/videos/vega/ship-ssakar-scout.mp4" },
"colonyship": { "key": "vega-ship-ssakar-colonyship", "path": "assets/videos/vega/ship-ssakar-colony.mp4" },
"transport": { "key": "vega-ship-ssakar-transport", "path": "assets/videos/vega/ship-ssakar-transport.mp4" },
"frigate": { "key": "vega-ship-ssakar-frigate", "path": "assets/videos/vega/ship-ssakar-frigate.mp4" },
"destroyer": { "key": "vega-ship-ssakar-destroyer", "path": "assets/videos/vega/ship-ssakar-destroyer.mp4" },
"cruiser": { "key": "vega-ship-ssakar-cruiser", "path": "assets/videos/vega/ship-ssakar-cruiser.mp4" },
"battleship": { "key": "vega-ship-ssakar-battleship", "path": "assets/videos/vega/ship-ssakar-battleship.mp4" }
},
"lithox": {
"scout": { "key": "vega-ship-lithox-scout", "path": "assets/videos/vega/ship-lithox-scout.mp4" },
"colonyship": { "key": "vega-ship-lithox-colonyship", "path": "assets/videos/vega/ship-lithox-colony.mp4" },
"transport": { "key": "vega-ship-lithox-transport", "path": "assets/videos/vega/ship-lithox-transport.mp4" },
"frigate": { "key": "vega-ship-lithox-frigate", "path": "assets/videos/vega/ship-lithox-frigate.mp4" },
"destroyer": { "key": "vega-ship-lithox-destroyer", "path": "assets/videos/vega/ship-lithox-destroyer.mp4" },
"cruiser": { "key": "vega-ship-lithox-cruiser", "path": "assets/videos/vega/ship-lithox-cruiser.mp4" },
"battleship": { "key": "vega-ship-lithox-battleship", "path": "assets/videos/vega/ship-lithox-battleship.mp4" }
}
},
"_portraitStillsReadme": "High-resolution (1024x1024) still portraits, one per species. Every species now has a video too, so these serve as the recovery path if a video fails to decode. NOTE: kestrelli's file is spelled 'char-kestralli.png' on disk — the path below points at the real filename; rename the file and this one line together if you want the spelling fixed.",
@ -177,34 +193,34 @@
"happy": { "key": "vega-audience-mekhan-happy", "path": "assets/videos/vega/audience-mekhan-happy.mp4" }
},
"rrashaa": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
"angry": { "key": "vega-audience-rrashaa-angry", "path": "assets/videos/vega/audience-rrashaa-angry.mp4" },
"neutral": { "key": "vega-audience-rrashaa-neutral", "path": "assets/videos/vega/audience-rrashaa-neutral.mp4" },
"happy": { "key": "vega-audience-rrashaa-happy", "path": "assets/videos/vega/audience-rrashaa-happy.mp4" }
},
"cerebrai": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
"angry": { "key": "vega-audience-cerebrai-angry", "path": "assets/videos/vega/audience-cerebrai-angry.mp4" },
"neutral": { "key": "vega-audience-cerebrai-neutral", "path": "assets/videos/vega/audience-cerebrai-neutral.mp4" },
"happy": { "key": "vega-audience-cerebrai-happy", "path": "assets/videos/vega/audience-cerebrai-happy.mp4" }
},
"ssakar": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
"angry": { "key": "vega-audience-ssakar-angry", "path": "assets/videos/vega/audience-ssakar-angry.mp4" },
"neutral": { "key": "vega-audience-ssakar-neutral", "path": "assets/videos/vega/audience-ssakar-neutral.mp4" },
"happy": { "key": "vega-audience-ssakar-happy", "path": "assets/videos/vega/audience-ssakar-happy.mp4" }
}
},
"_researchVideosReadme": "Research-screen ambient loops — one per species, showing that species' scientists at work, muted, looping continuously (setLoop(true), unlike the audience screen's play-once mood clips above). Recorded 2:3 (portrait). JIT-loaded like colonyVideos/audienceVideos, NOT eager via assetManifest.js — Research only ever shows the human player's own species, so there is no reason to fetch the other nine at game-room entry. A species with no clip recorded falls back to its portraitVideos clip (256x256, letterboxed to 2:3), then portraitStills — same three-tier fallback convention as makeSpeciesPortrait. Naming: assets/videos/vega/research-<speciesId>.mp4.",
"researchVideos": {
"human": { "key": "vega-research-human", "path": null },
"kestrelli": { "key": "vega-research-kestrelli", "path": null },
"ursaal": { "key": "vega-research-ursaal", "path": null },
"human": { "key": "vega-research-human", "path": "assets/videos/vega/research-human.mp4" },
"kestrelli": { "key": "vega-research-kestrelli", "path": "assets/videos/vega/research-kestrelli.mp4" },
"ursaal": { "key": "vega-research-ursaal", "path": "assets/videos/vega/research-ursaal.mp4" },
"umbrix": { "key": "vega-research-umbrix", "path": null },
"kkrix": { "key": "vega-research-kkrix", "path": null },
"mekhan": { "key": "vega-research-mekhan", "path": null },
"kkrix": { "key": "vega-research-kkrix", "path": "assets/videos/vega/research-kkrix.mp4" },
"mekhan": { "key": "vega-research-mekhan", "path": "assets/videos/vega/research-mekhan.mp4" },
"rrashaa": { "key": "vega-research-rrashaa", "path": null },
"cerebrai": { "key": "vega-research-cerebrai", "path": null },
"ssakar": { "key": "vega-research-ssakar", "path": null },
"lithox": { "key": "vega-research-lithox", "path": null }
"cerebrai": { "key": "vega-research-cerebrai", "path": "assets/videos/vega/research-cerebrai.mp4" },
"ssakar": { "key": "vega-research-ssakar", "path": "assets/videos/vega/research-ssakar.mp4" },
"lithox": { "key": "vega-research-lithox", "path": "assets/videos/vega/research-lithox.mp4" }
},
"sheets": {
@ -275,8 +291,8 @@
"frameWidth": 48,
"frameHeight": 48,
"cols": 10,
"rows": 8,
"_layout": "Frames 0-5 are the six tech FIELDS (techFields[].iconFrame); frames 6-65 are the original 60 individual techs, 66-77 are the MOO1-branching alternates added alongside them (techs[].iconFrame). 78-79 spare."
"rows": 10,
"_layout": "Frames 0-5 are the six tech FIELDS (techFields[].iconFrame); frames 6-65 are the original 60 individual techs, 66-77 are the first MOO1-branching alternates, 78-96 are a second branching pass (techs[].iconFrame). 97-99 spare."
}
}
}

View File

@ -15,51 +15,63 @@
"techs": [
{ "id": "electroniccomputer", "name": "Electronic Computer", "field": "computers", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 6, "desc": "Mark I battle computer. +1 to hit.", "effects": { "targeting": 1 } },
{ "id": "battlescanner", "name": "Battle Scanner", "field": "computers", "tier": 1, "prereqs": ["electroniccomputer"], "cost": 150, "iconFrame": 7, "desc": "Reveals enemy fleet composition and adds combat initiative.", "effects": { "initiative": 2, "scanRange": 1 } },
{ "id": "securecommarray", "name": "Secure Comm Array", "field": "computers", "tier": 1, "prereqs": ["electroniccomputer"], "cost": 150, "iconFrame": 78, "desc": "Encrypted fleet channels. Enemy spies have a harder time reading your signals.", "effects": { "counterEspionage": 14, "scanRange": 1 } },
{ "id": "optroniccomputer", "name": "Optronic Computer", "field": "computers", "tier": 2, "prereqs": ["battlescanner"], "cost": 400, "iconFrame": 8, "desc": "Mark II battle computer. +2 to hit.", "effects": { "targeting": 2 } },
{ "id": "neuralscanner", "name": "Neural Scanner", "field": "computers", "tier": 3, "prereqs": ["optroniccomputer"], "cost": 900, "iconFrame": 9, "desc": "Deep-scan interrogation. Espionage missions far more likely.", "effects": { "espionage": 15 } },
{ "id": "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": "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": "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": "reinforcedhull", "name": "Reinforced Hull", "field": "construction", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 16, "desc": "Cross-braced spaceframes. Hulls absorb 25% more damage.", "effects": { "armor": { "name": "Reinforced", "hpMult": 1.25 } } },
{ "id": "duralloyarmor", "name": "Duralloy Armour", "field": "construction", "tier": 1, "prereqs": ["reinforcedhull"], "cost": 160, "iconFrame": 17, "desc": "Duralloy plating. Hulls absorb 50% more damage.", "effects": { "armor": { "name": "Duralloy", "hpMult": 1.5 } } },
{ "id": "modularhullframes", "name": "Modular Hull Frames", "field": "construction", "tier": 1, "prereqs": ["reinforcedhull"], "cost": 160, "iconFrame": 81, "desc": "Standardised hull sections. Ships refit for less, though the plating itself is no thicker than reinforced hull.", "effects": { "refitCostMult": 0.85 } },
{ "id": "improvedindustrial", "name": "Improved Industrial Tech","field": "construction","tier": 2,"prereqs": ["duralloyarmor"], "cost": 420, "iconFrame": 18, "desc": "Factories cost 20% less to build and to run.", "effects": { "factoryCostMult": 0.8 } },
{ "id": "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": "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": "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": "deflectori", "name": "Class I Deflector Shield","field": "forcefields","tier": 0, "prereqs": [], "cost": 80, "iconFrame": 26, "desc": "Absorbs 1 point from every incoming hit.", "effects": { "shield": 1 } },
{ "id": "personalshield", "name": "Personal Shield", "field": "forcefields", "tier": 1, "prereqs": ["deflectori"], "cost": 200, "iconFrame": 27, "desc": "Shielded infantry. +10 to ground combat on attack and defence.","effects": { "groundAttack": 10, "groundDefense": 10 } },
{ "id": "pointdefensegrid", "name": "Point-Defense Grid", "field": "forcefields", "tier": 1, "prereqs": ["deflectori"], "cost": 200, "iconFrame": 70, "desc": "Automated point-defense turrets. Fleets react a beat faster in combat.", "effects": { "initiative": 1 } },
{ "id": "deflectoriii", "name": "Class III Deflector", "field": "forcefields", "tier": 2, "prereqs": ["personalshield"], "cost": 500, "iconFrame": 28, "desc": "Absorbs 2 points from every incoming hit.", "effects": { "shield": 2 } },
{ "id": "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": "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": "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": "ecorestoration", "name": "Ecological Restoration","field": "planetology", "tier": 0, "prereqs": [], "cost": 70, "iconFrame": 36, "desc": "Industrial waste costs 25% less to clean up.", "effects": { "wasteMult": 0.75 } },
{ "id": "controlledbarren", "name": "Controlled Barren Environment","field": "planetology","tier": 1,"prereqs": ["ecorestoration"], "cost": 180, "iconFrame": 37, "desc": "Sealed habitats let you colonise barren worlds.", "effects": { "colonizeHostility": 1 } },
{ "id": "preemptivewastefiltration","name": "Preemptive Waste Filtration","field": "planetology","tier": 1,"prereqs": ["ecorestoration"], "cost": 180, "iconFrame": 87, "desc": "Filters industrial runoff before it ever reaches the surface. Waste cleanup costs another 15% less, though it does nothing to open up barren worlds.", "effects": { "wasteMult": 0.65 } },
{ "id": "enhancedeco", "name": "Enhanced Eco Restoration","field": "planetology","tier": 2, "prereqs": ["controlledbarren"], "cost": 460, "iconFrame": 38, "desc": "Industrial waste costs 50% less to clean up.", "effects": { "wasteMult": 0.5 } },
{ "id": "biosphereresearchinitiative","name": "Biosphere Research Initiative","field": "planetology","tier": 2,"prereqs": ["controlledbarren"], "cost": 460, "iconFrame": 88, "desc": "Terraforming survey data doubles as pure science. Research output up 8%, though colonies keep producing waste at the old rate.", "effects": { "researchMult": 1.08 } },
{ "id": "controlleddead", "name": "Controlled Dead Environment","field": "planetology","tier": 3,"prereqs": ["enhancedeco"], "cost": 1000, "iconFrame": 39, "desc": "Colonise dead worlds stripped of atmosphere.", "effects": { "colonizeHostility": 2 } },
{ "id": "soilenrichment", "name": "Soil Enrichment", "field": "planetology", "tier": 4, "prereqs": ["controlleddead"], "cost": 2000, "iconFrame": 40, "desc": "Every colony supports 15 more population.", "effects": { "maxPopBonus": 15 } },
{ "id": "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": "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 } },
@ -69,26 +81,33 @@
{ "id": "deuteriumcells", "name": "Deuterium Fuel Cells", "field": "propulsion", "tier": 1, "prereqs": ["nuclearengines"], "cost": 170, "iconFrame": 47, "desc": "Fuel range 7 parsecs. The map opens a little.", "effects": { "fuelRange": 7 } },
{ "id": "reactionthrusters", "name": "Reaction Thrusters", "field": "propulsion", "tier": 1, "prereqs": ["nuclearengines"], "cost": 170, "iconFrame": 74, "desc": "Snap-fire manoeuvring jets. Fleets react a beat faster in combat, though range stays short.", "effects": { "initiative": 1 } },
{ "id": "fusiondrive", "name": "Fusion Drive", "field": "propulsion", "tier": 2, "prereqs": ["deuteriumcells"], "cost": 440, "iconFrame": 48, "desc": "Warp 2 drives.", "effects": { "engine": { "name": "Fusion", "speed": 2 } } },
{ "id": "auxiliaryfueltanks", "name": "Auxiliary Fuel Tanks", "field": "propulsion", "tier": 2, "prereqs": ["deuteriumcells"], "cost": 440, "iconFrame": 90, "desc": "Bolt-on tankage extends the envelope. Fuel range 8 parsecs, though the hull still tops out at Warp 1.", "effects": { "fuelRange": 8 } },
{ "id": "iridiumcells", "name": "Iridium Fuel Cells", "field": "propulsion", "tier": 3, "prereqs": ["fusiondrive"], "cost": 980, "iconFrame": 49, "desc": "Fuel range 9 parsecs.", "effects": { "fuelRange": 9 } },
{ "id": "sublightdrive", "name": "Sub-Light Drive", "field": "propulsion", "tier": 4, "prereqs": ["iridiumcells"], "cost": 2000, "iconFrame": 50, "desc": "Warp 3 drives.", "effects": { "engine": { "name": "Sub-Light", "speed": 3 } } },
{ "id": "uridiumcells", "name": "Uridium Fuel Cells", "field": "propulsion", "tier": 5, "prereqs": ["sublightdrive"], "cost": 3600, "iconFrame": 51, "desc": "Fuel range 12 parsecs. Half the galaxy lights up.", "effects": { "fuelRange": 12 } },
{ "id": "tachyondrivecoils", "name": "Tachyon Drive Coils", "field": "propulsion", "tier": 5, "prereqs": ["sublightdrive"], "cost": 3600, "iconFrame": 75, "desc": "Precognitive drive telemetry. A further edge in combat tempo, at the cost of range.", "effects": { "initiative": 2 } },
{ "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": "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": "lasercannon", "name": "Laser Cannon", "field": "weapons", "tier": 0, "prereqs": [], "cost": 60, "iconFrame": 56, "desc": "The first real gun. 1-4 damage.", "effects": { "weapon": { "id": "laser", "name": "Laser Cannon", "kind": "beam", "min": 1, "max": 4, "shots": 1, "space": 2, "cost": 8 } } },
{ "id": "hypervrockets", "name": "Hyper-V Rockets", "field": "weapons", "tier": 1, "prereqs": ["lasercannon"], "cost": 160, "iconFrame": 57, "desc": "Cheap standoff missiles. 6 damage, 2 salvoes.", "effects": { "weapon": { "id": "hyperv", "name": "Hyper-V Rocket", "kind": "missile", "min": 6, "max": 6, "shots": 4, "space": 4, "cost": 10 } } },
{ "id": "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 } } },
{ "id": "protontorpedoes", "name": "Proton Torpedoes", "field": "weapons", "tier": 2, "prereqs": ["hypervrockets"], "cost": 430, "iconFrame": 76, "desc": "Slow, but heavier warheads than Hyper-V. 10 damage, 2 salvoes.", "effects": { "weapon": { "id": "protontorpedo", "name": "Proton Torpedo", "kind": "missile", "min": 10, "max": 10, "shots": 2, "space": 5, "cost": 14 } } },
{ "id": "ioncannon", "name": "Ion Cannon", "field": "weapons", "tier": 3, "prereqs": ["neutronpellet"], "cost": 960, "iconFrame": 59, "desc": "3-8 damage.", "effects": { "weapon": { "id": "ion", "name": "Ion Cannon", "kind": "beam", "min": 3, "max": 8, "shots": 1, "space": 4, "cost": 18 } } },
{ "id": "merculitemissiles", "name": "Merculite Missiles", "field": "weapons", "tier": 4, "prereqs": ["ioncannon"], "cost": 1950, "iconFrame": 60, "desc": "10 damage, 2 salvoes, long reach.", "effects": { "weapon": { "id": "merculite", "name": "Merculite Missile", "kind": "missile", "min": 10, "max": 10, "shots": 4, "space": 6, "cost": 20 } } },
{ "id": "disruptorcannon", "name": "Disruptor Cannon", "field": "weapons", "tier": 4, "prereqs": ["ioncannon"], "cost": 1950, "iconFrame": 94, "desc": "Phase-shifted beam bleeds straight through deflectors. 4-9 damage, ignores 1 point of shield.", "effects": { "weapon": { "id": "disruptor", "name": "Disruptor Cannon", "kind": "beam", "min": 4, "max": 9, "shots": 1, "space": 5, "cost": 22, "shieldPierce": 1 } } },
{ "id": "neutronblaster", "name": "Neutron Blaster", "field": "weapons", "tier": 5, "prereqs": ["merculitemissiles"], "cost": 3400, "iconFrame": 61, "desc": "5-12 damage and it kills crew through the hull.", "effects": { "weapon": { "id": "neutronblaster", "name": "Neutron Blaster", "kind": "beam", "min": 5, "max": 12, "shots": 1, "space": 5, "cost": 30, "shieldPierce": 1 } } },
{ "id": "hyperxrockets", "name": "Hyper-X Rockets", "field": "weapons", "tier": 6, "prereqs": ["neutronblaster"], "cost": 5700, "iconFrame": 62, "desc": "14 damage, 3 salvoes.", "effects": { "weapon": { "id": "hyperx", "name": "Hyper-X Rocket", "kind": "missile", "min": 16, "max": 16, "shots": 5, "space": 8, "cost": 32 } } },
{ "id": "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": "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 } } }
],
@ -353,6 +372,11 @@
{ "id": "elissedra", "name": "Elis Sedra", "kind": "captain", "hireCost": 210, "upkeep": 6, "portraitFrame": 15, "skills": { "shipAttack": 25, "shipDefense": 15, "initiative": 2 }, "bio": "Flag officer. Give her a battleship and stay out of the way." }
],
"leaderHiring": {
"_readme": "Each leader already on an empire's roster raises what the NEXT one costs that empire — see VegaLogic.js leaderHireCost. costScalePerOwned 0.5 means: 1st leader = list price, 2nd = 1.5x, 3rd = 2x, 4th = 2.5x, 5th = 3x, 6th (the roster cap) = 3.5x. Keeps the shared 16-leader pool from being cleared cheaply by whoever stockpiles BC fastest, without changing the cost of a first hire at all.",
"costScalePerOwned": 0.5
},
"galaxySizes": [
{ "id": "small", "name": "Small", "stars": 24, "width": 2600, "height": 1700, "maxEmpires": 4, "desc": "Cramped. You will meet your neighbours early and often." },
{ "id": "medium", "name": "Medium", "stars": 36, "width": 3400, "height": 2200, "maxEmpires": 5, "desc": "The standard galaxy." },
@ -385,6 +409,58 @@
"submitAttitude": -25
},
"diplomacy": {
"_readme": "Tunables for gifts, trade agreements, border/fleet proximity tension, and enemy-of-my-enemy attitude triangulation. See VegaDiplomacy.js.",
"gift": {
"tiers": [
{ "id": "modest", "name": "Modest Gift", "bc": 25, "delta": 4 },
{ "id": "generous", "name": "Generous Gift", "bc": 75, "delta": 9 },
{ "id": "lavish", "name": "Lavish Gift", "bc": 200, "delta": 18 }
],
"cooldownTurns": 8,
"repeatPenaltyFactor": 0.4,
"aiProposeChance": 0.06,
"aiMaxTreasuryFraction": 0.25
},
"tradeAgreement": {
"attitudeThreshold": 15,
"formBonus": 10,
"bcRatePerPop": 0.05,
"aiProposeChance": 0.08
},
"alliance": {
"_readme": "AI-proposal side of Alliance. wouldAccept('alliance') reads attitudeThreshold too, so this is the single source of truth for the 45-point bar both proposing and accepting share.",
"attitudeThreshold": 45,
"aiProposeChance": 0.25
},
"espionage": {
"_readme": "Sabotage magnitude and its attitude sting when a runEspionage roll succeeds with mission 'sabotage' (VegaLogic.js). Targeting/frequency/odds are unchanged from the steal-tech path; this only controls what a sabotage success does.",
"sabotageFactoriesFraction": 0.25,
"sabotageDefenseFraction": 0.25,
"sabotageAttitudePenalty": 14
},
"borderTension": {
"colonyRange": 6,
"colonyMaxPerTurn": 1,
"fleetRange": 3,
"fleetMaxPerTurn": 2
},
"fleetIntrusion": {
"graceTurns": 1,
"basePerTurn": 3,
"escalationPerTurn": 2,
"capPerTurn": 15
},
"triangulation": {
"_readme": "Enemy-of-my-enemy pull, and how much it makes empires forgive each other's border/fleet/colony congestion (applyBorderTension, applyFleetProximityTension, checkFleetIntrusions all read sharedEnemyForgiveness).",
"hostileThreshold": -10,
"perSharedEnemy": 3,
"maxPerTurn": 12,
"sharedEnemyForgiveness": 0.15,
"scope": "galaxy"
}
},
"economy": {
"_readme": "Core economic constants. Sliders are the five MOO1 allocation channels; buildings multiply the channel they name.",
"channels": ["ships", "defense", "industry", "ecology", "research"],

View File

@ -29,10 +29,11 @@ import {
showVictoryOverlay,
} from './VegaScreens.js';
import { openResearchScreen } from './VegaResearchScreen.js';
import { openResearchChoiceScreen } from './VegaResearchChoiceScreen.js';
import { openTurnReportScreen } from './VegaTurnReportScreen.js';
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
import { openAudienceScreen } from './VegaAudience.js';
import { claimAudienceContacts } from './VegaDiplomacy.js';
import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js';
const SAVE_KEY = 'mastervega-save';
@ -609,7 +610,6 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.buildHud();
this.refreshHud();
this.log('The stars are yours to take.');
}
// ------------------------------------------------------------------ HUD
@ -652,6 +652,15 @@ export default class MasterOfVegaGame extends Phaser.Scene {
});
hud.add(this.logText);
// Held peace/alliance/trade-agreement offers from an AI empire get an
// actionable card (portrait + Seek Audience button) stacked just above
// the log, not just the "seek an audience" ticker line — see
// refreshOfferNotifications(). One card per empire currently holding an
// offer for the human, rebuilt from state.empires[human].pendingOffers
// every refreshAll() so it tracks accept/reject/expiry automatically.
this.offerCardLayer = this.add.container(0, 0);
hud.add(this.offerCardLayer);
const back = new Button(this, 96, GAME_HEIGHT - 40, '← Menu', () => {
this.writeSave();
this.scene.start('GameMenu');
@ -677,10 +686,59 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.logText?.setText(this.logLines.join('\n'));
}
// One card per empire currently holding a peace/alliance/trade-agreement
// offer for the human — a species portrait on the left, a Seek Audience
// button on the right, stacked upward from just above the status log.
// Fully rebuilt from live state every call, so a card simply stops
// appearing the moment its offer is accepted, rejected, or expires —
// no separate dismiss action or lifecycle to manage.
refreshOfferNotifications() {
this.offerCardLayer?.removeAll(true);
if (!this.state) return;
const human = this.state.empires[this.state.humanIndex];
if (!human) return;
const W = 340;
const H = 64;
const GAP = 10;
const X = 24;
const PORTRAIT = 48;
const otherIdxs = Object.keys(human.pendingOffers)
.map(Number)
.filter((idx) => human.pendingOffers[idx] && this.state.empires[idx]?.alive);
otherIdxs.forEach((otherIdx, row) => {
const other = this.state.empires[otherIdx];
const cy = GAME_HEIGHT - 168 - 16 - row * (H + GAP) - H / 2;
const card = this.add.container(X + W / 2, cy);
card.add(this.add.rectangle(0, 0, W, H, 0x0b1220, 0.92)
.setStrokeStyle(1.5, Phaser.Display.Color.HexStringToColor(other.color).color, 0.6));
const px = -W / 2 + 12 + PORTRAIT / 2;
card.add(makeSpeciesPortrait(this, this.rules, this.art, other.speciesId, px, 0, PORTRAIT));
card.add(this.add.text(px + PORTRAIT / 2 + 12, 0, `The ${other.name}\nseek an audience.`, {
fontFamily: FONT, fontSize: '13px', color: '#cfe0f2', lineSpacing: 2,
wordWrap: { width: 108 },
}).setOrigin(0, 0.5));
const canSeek = canNegotiate(this.rules, this.state, this.state.humanIndex, otherIdx);
const btn = new Button(this, W / 2 - 78, 0, 'Seek Audience', () => {
this.openModal((done) => openAudienceScreen(
this, this.rules, this.state, this.state.humanIndex, otherIdx, this.art, done,
() => this.refreshAll(),
));
}, { width: 140, height: 40, fontSize: 14 });
if (!canSeek) btn.setEnabled(false);
card.add(btn);
this.offerCardLayer.add(card);
});
}
refreshAll() {
this.map?.refresh();
this.panel?.refresh();
this.refreshHud();
this.refreshOfferNotifications();
}
// -------------------------------------------------------------- modals
@ -788,6 +846,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// closes, via openModal's drain hook above.
this.pendingAudiences.push(
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
...claimFleetComplaints(this.rules, this.state, this.state.humanIndex),
);
this.refreshAll();
},
@ -830,6 +889,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// else about this turn resolves. Meet before you fight.
this.pendingAudiences.push(
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
...claimFleetComplaints(this.rules, this.state, this.state.humanIndex),
);
this.runAudienceQueue(() => {
this.playPlayerBattles(() => {
@ -884,6 +944,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// "contact made" always reads as the bigger beat.
this.pendingAudiences.push(
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
...claimFleetComplaints(this.rules, this.state, this.state.humanIndex),
);
const finish = () => {
this.busy = false;
@ -895,10 +956,23 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// 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.
this.openModal((done) => openTurnReportScreen(this, this.rules, this.state, notable, () => {
done();
finish();
}));
// Any research-branch choice prompts run first, each opened
// 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.
const pendingChoices = this.pendingResearchChoices(notable);
this.openModal((done) => {
const finishAll = () => { done(); finish(); };
const openReport = () => openTurnReportScreen(this, this.rules, this.state, notable, finishAll);
const runChoices = (queue) => {
if (!queue.length) { openReport(); return; }
const [next, ...rest] = queue;
openResearchChoiceScreen(this, this.rules, this.state, this.state.humanIndex, this.art,
next.field, next.choices, next.completedTechId, () => runChoices(rest));
};
runChoices(pendingChoices);
});
} else {
this.refreshAll();
finish();
@ -915,6 +989,25 @@ export default class MasterOfVegaGame extends Phaser.Scene {
step();
}
// techDone events this turn, human-owned, from actual research (not trade
// or espionage — grantTech's techDone events always carry a `source`),
// deduped by field, where the field's NEW frontier is ambiguous. Order
// matches turn/event order. Each entry carries the completed tech's id too,
// so the choice prompt can name what just finished.
pendingResearchChoices(notable) {
const seen = new Set();
const out = [];
for (const ev of notable) {
if (ev.type !== 'techDone' || ev.empire !== this.state.humanIndex || ev.source !== 'research') continue;
const field = this.rules.techs[ev.techId].field;
if (seen.has(field)) continue;
seen.add(field);
const choices = Logic.openResearchChoices(this.rules, this.state, this.state.humanIndex, field);
if (choices.length > 1) out.push({ field, choices, completedTechId: ev.techId });
}
return out;
}
// Turn engine events into log lines, map pings, and — for the events worth
// interrupting the player for — rows in the "New Turn" popup. `announced`
// marks a record consumed, since beginEmpireTurn trims rather than clears
@ -945,6 +1038,22 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.log(`The ${name(ev.empire)} seek an audience.`);
} else if (ev.type === 'populationDelivered') {
this.log(`${ev.amount.toFixed(1)} population arrived at ${star?.name}.`);
} else if (ev.type === 'gift' && ev.other === me) {
this.log(`The ${name(ev.empire)} send a gift of ${ev.tierId === 'lavish' ? 'considerable' : ev.tierId === 'generous' ? 'notable' : 'modest'} value.`);
} else if (ev.type === 'fleetComplaint' && ev.other === me) {
// The FIRST violation of an incident force-opens the Audience screen
// instead (claimFleetComplaints, called before this) and shares its
// `announced` flag, so this branch only ever sees turn 2+ of an
// ongoing intrusion — or a first violation for a diplomacy-incapable
// colony owner that can never get a popup at all.
this.log(`${name(ev.empire)} ${ev.dwell > 4 ? 'are furious about' : 'are unhappy about'} your fleet lingering at ${star?.name}.`);
} else if (ev.type === 'techStolen' && ev.target === me) {
this.log(`The ${name(ev.empire)} have stolen ${this.rules.techs[ev.techId]?.name} technology from us.`);
} else if (ev.type === 'spyCaught' && ev.empire === me) {
this.log(`Our spies were caught ${ev.mission === 'sabotage' ? 'attempting sabotage' : 'operating'} against the ${name(ev.target)}.`);
} else if (ev.type === 'sabotage' && ev.target === me) {
this.log(`The ${name(ev.empire)} have sabotaged our colony at ${star?.name}.`);
if (star) this.fx?.ping(star.x, star.y, 0xffa050);
}
}
return notable;

View File

@ -43,7 +43,7 @@ import {
} from './VegaArt.js';
import { atWar, grantTech } from './VegaLogic.js';
import {
attitudeOf, moodOf, videoMood, canNegotiate, declareWar, proposeTreaty, respondToOffer, tradeTech,
attitudeOf, moodOf, videoMood, canNegotiate, declareWar, proposeTreaty, respondToOffer, tradeTech, giveGift,
} from './VegaDiplomacy.js';
import { CHAT, pickLine } from './VegaChat.js';
@ -289,7 +289,13 @@ const REPLAY_PAD = 18;
const CHAT_X = VIDEO_X + VIDEO_W + 60;
const CHAT_W = GAME_WIDTH - CHAT_X - 70;
const CHAT_Y = VIDEO_Y;
const CHAT_H = 620;
// Shortened from 620 — the button area below was already tight (2 treaty
// rows + the CONVERSATION lore row, which does happen at happy mood, ran
// past the 1080-tall canvas) and the new INTELLIGENCE row made it worse.
// The chat log is a scrollable masked container regardless of height, so
// this only means slightly more scrolling on long conversations, not lost
// history.
const CHAT_H = 460;
const BTN_Y = CHAT_Y + CHAT_H + 55;
// Widened from 190 — as close to CHAT_W's 3-per-row ceiling as BTN_GAP allows
@ -343,7 +349,10 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
function statusLine() {
const treaty = state.empires[me].treaties[otherIdx] ?? 'none';
const att = attitudeOf(state, otherIdx, me);
statusTxt.setText(`${treaty === 'war' ? 'AT WAR' : treaty.toUpperCase()} · attitude ${att} (${moodOf(att)})`);
const tradeSuffix = state.empires[me].tradeAgreements[otherIdx] ? ' · Trade Agreement' : '';
statusTxt.setText(
`${treaty === 'war' ? 'AT WAR' : treaty.toUpperCase()} · attitude ${att} (${moodOf(att)})${tradeSuffix}`,
);
}
statusLine();
@ -522,10 +531,19 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
}
const openingOffer = state.empires[me].pendingOffers[otherIdx];
if (openingOffer) {
const key = openingOffer.kind === 'peace' ? chat.offerPeaceOpener : chat.offerAllianceOpener;
const key = openingOffer.kind === 'peace' ? chat.offerPeaceOpener
: openingOffer.kind === 'alliance' ? chat.offerAllianceOpener
: chat.offerTradeAgreementOpener;
pushChat('o', pickLine(key, vars));
}
// A fresh (this-turn-or-last) fleet complaint gets its own opener line,
// same slot as the pending-offer injection above — this is what makes the
// force-opened Audience screen actually voice the complaint that triggered it.
const recentComplaint = state.events.some((ev) => ev.type === 'fleetComplaint'
&& ev.empire === otherIdx && ev.other === me && ev.first && ev.turn >= state.turn - 1);
if (recentComplaint) pushChat('o', pickLine(chat.fleetComplaintOpener, vars));
moodVisual.show(videoMood(attitudeOf(state, otherIdx, me)));
// --- action buttons
@ -553,15 +571,52 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
}
function doPropose(kind) {
pushChat('p', pickLine(kind === 'peace' ? chat.proposePeace : chat.proposeAlliance, vars));
const proposeKey = kind === 'peace' ? chat.proposePeace
: kind === 'alliance' ? chat.proposeAlliance
: chat.proposeTradeAgreement;
pushChat('p', pickLine(proposeKey, vars));
const accepted = proposeTreaty(rules, state, me, otherIdx, kind);
const key = kind === 'peace'
? (accepted ? chat.replyPeaceAccept : chat.replyPeaceReject)
: (accepted ? chat.replyAllianceAccept : chat.replyAllianceReject);
: kind === 'alliance'
? (accepted ? chat.replyAllianceAccept : chat.replyAllianceReject)
: (accepted ? chat.replyTradeAgreementAccept : chat.replyTradeAgreementReject);
say(pickLine(key, vars));
afterAction();
}
function doGift(tierId) {
pushChat('p', pickLine(chat.giftOffer, vars));
const beforeMood = videoMood(attitudeOf(state, otherIdx, me));
giveGift(rules, state, me, otherIdx, tierId);
const afterMood = videoMood(attitudeOf(state, otherIdx, me));
const moodImproved = MOODS.indexOf(afterMood) > MOODS.indexOf(beforeMood);
say(pickLine(moodImproved ? chat.replyGiftWarm : chat.replyGiftNeutral, vars));
afterAction();
}
// The two responses to a fresh fleet complaint (see the opener injection
// above). Both are conversational only in v1 — the Audience screen has no
// reach into fleet selection/pathing to actually issue a recall order, so
// "withdraw" is a promise the player has to follow through on manually by
// moving the fleet; "defy" applies its own small extra sting on top of the
// ongoing per-turn escalation checkFleetIntrusions already handles.
let complaintPending = recentComplaint;
function doComplaintWithdraw() {
pushChat('p', pickLine(chat.acknowledgeComplaintWithdraw, vars));
complaintPending = false;
say(pickLine(chat.afterComplaintWithdrawPromise, vars));
afterAction();
}
function doComplaintDefy() {
pushChat('p', pickLine(chat.acknowledgeComplaintDefy, vars));
complaintPending = false;
state.empires[otherIdx].attitude[me] = Math.max(-100, (state.empires[otherIdx].attitude[me] ?? 0) - 5);
say(pickLine(chat.afterComplaintDefy, vars));
afterAction();
}
function doDeclareWar() {
pushChat('p', pickLine(chat.declareWar, vars));
declareWar(rules, state, me, otherIdx);
@ -580,6 +635,17 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
afterAction();
}
// A standing policy, not a negotiated exchange — no chat line, no
// attitude change, no mood pulse. Just re-render so the active toggle
// highlights immediately. 'off' pulls this empire out of runEspionage's
// target pool entirely (VegaLogic.js); 'steal'/'sabotage' only change
// what a successful hit against them does.
function doSetMission(mission) {
if (mission === 'steal') delete state.empires[me].espionageMission[otherIdx];
else state.empires[me].espionageMission[otherIdx] = mission;
renderButtons();
}
// --- pure-flavor conversation topics: no treaty/attitude effect, so no
// afterAction() — pushChat()/say() already do everything the chat log
// needs, and nothing about the mood, status line or button set changes.
@ -627,6 +693,35 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
});
}
/** A small fixed 3-tier picker distinct from pickFromList since that one
* assumes rules.techs[id].name labels, not a tier table. */
function openGiftPicker() {
const tiers = rules.diplomacy.gift.tiers.filter((t) => t.bc <= state.empires[me].bc);
if (!tiers.length) return;
const box = scene.add.container(0, 0);
root.add(box);
const bx = GAME_WIDTH / 2;
const by = GAME_HEIGHT / 2;
const h = Math.min(420, 120 + tiers.length * 46);
box.add(scene.add.rectangle(bx, by, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001).setOrigin(0.5).setInteractive());
box.add(scene.add.rectangle(bx, by, 420, h, PANEL, 0.98).setStrokeStyle(2, ACCENT));
box.add(scene.add.text(bx, by - h / 2 + 26, 'SEND WHICH GIFT?', {
fontFamily: FONT, fontSize: '19px', color: '#ffd88a',
}).setOrigin(0.5));
tiers.forEach((tier, i) => {
const t = scene.add.text(bx, by - h / 2 + 66 + i * 46, `${tier.name}${tier.bc} BC`, {
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
t.on('pointerover', () => t.setColor('#ffd88a'));
t.on('pointerout', () => t.setColor('#e8f4ff'));
t.on('pointerdown', () => { box.destroy(true); doGift(tier.id); });
box.add(t);
});
const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', () => box.destroy(true),
{ width: 160, height: 40, fontSize: 16, variant: 'ghost' });
box.add(cancel);
}
/** Lays out one row (or wrapped rows) of [label, fn] pairs at `y`, 3 per row. Returns the row count. */
function layoutRow(list, y, extraOpts = {}) {
const perRow = 3;
@ -643,7 +738,12 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
function renderButtons() {
buttonRow.removeAll(true);
const offer = state.empires[me].pendingOffers[otherIdx];
const giftAffordable = state.empires[me].bc >= rules.diplomacy.gift.tiers[0].bc;
const items = [];
if (complaintPending) {
items.push(['We\'ll Withdraw', doComplaintWithdraw]);
items.push(['We\'re Staying', doComplaintDefy]);
}
if (offer) {
items.push(['Accept', () => doPending(true)]);
items.push(['Reject', () => doPending(false)]);
@ -651,16 +751,50 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
if (!atWar(state, me, otherIdx)) items.push(['Declare War', doDeclareWar]);
} else if (atWar(state, me, otherIdx)) {
items.push(['Sue for Peace', () => doPropose('peace')]);
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
} else {
items.push(['Declare War', doDeclareWar]);
if (state.empires[me].treaties[otherIdx] !== 'alliance') {
items.push(['Propose Alliance', () => doPropose('alliance')]);
}
if (!state.empires[me].tradeAgreements[otherIdx]) {
items.push(['Propose Trade Agreement', () => doPropose('tradeAgreement')]);
}
const give = Object.keys(state.empires[me].known).filter((t) => !state.empires[otherIdx].known[t]);
const want = Object.keys(state.empires[otherIdx].known).filter((t) => !state.empires[me].known[t]);
if (give.length && want.length) items.push(['Trade Tech', () => openTechPicker(give, want)]);
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
}
const treatyRows = layoutRow(items, BTN_Y);
let nextY = BTN_Y + treatyRows * (BTN_H + BTN_GAP);
// INTELLIGENCE — a standing mission choice, not a one-shot proposal, so
// it renders as a highlighted 3-way toggle rather than action buttons.
// Available whenever you could negotiate with them at all and they're
// not an ally (mirroring runEspionage's own targeting exclusions);
// unlike the treaty items above it stays available even at war.
if (canNegotiate(rules, state, me, otherIdx) && state.empires[me].treaties[otherIdx] !== 'alliance') {
const current = state.empires[me].espionageMission[otherIdx] ?? 'steal';
const labelY = nextY + 4;
buttonRow.add(scene.add.text(CHAT_X, labelY, 'INTELLIGENCE', {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa8', letterSpacing: 2,
}));
// Not layoutRow: the active option needs its own styling (solid
// accent vs. ghost) rather than the one shared extraOpts every other
// row applies to its whole list.
const rowY = labelY + 22 + BTN_H / 2;
[['off', 'Off'], ['steal', 'Steal Tech'], ['sabotage', 'Sabotage']].forEach(([id, label], i) => {
const active = current === id;
const x = CHAT_X + i * (BTN_W + BTN_GAP) + BTN_W / 2;
buttonRow.add(new Button(scene, x, rowY, label, () => doSetMission(id), {
width: BTN_W, height: BTN_H, fontSize: 16,
variant: active ? 'solid' : 'ghost',
bg: active ? ACCENT : undefined,
textColor: active ? '#04121e' : undefined,
}));
});
nextY = labelY + 22 + (BTN_H + BTN_GAP);
}
// Lore/conversation topics — pure flavor, appended UNCONDITIONALLY after
// the treaty-branch logic above, gated purely on mood tier (never on
@ -676,7 +810,7 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
loreItems.push(['A Story', doAskStory]);
}
if (loreItems.length) {
const labelY = BTN_Y + treatyRows * (BTN_H + BTN_GAP) + 4;
const labelY = nextY + 4;
buttonRow.add(scene.add.text(CHAT_X, labelY, 'CONVERSATION', {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa8', letterSpacing: 2,
}));

View File

@ -161,6 +161,54 @@ export const CHAT = {
'Your loss, {you}. The offer won\'t stay on the table forever.',
'Suit yourself, {you}. I\'ll remember this.',
],
giftOffer: [
'A gesture of goodwill, {me} — nothing owed, just good business in the long run.',
'Consider this a down payment on a better relationship, {me}.',
],
replyGiftWarm: [
'Now that\'s how you open a negotiation, {you}. Noted, and appreciated.',
'Generous, {you} — and generosity, in my experience, is rarely wasted on me.',
],
replyGiftNeutral: [
'Appreciated, {you}. It won\'t buy everything, but it\'s a start.',
'Noted, {you}. Small gestures add up eventually.',
],
proposeTradeAgreement: [
'Let\'s put this on a proper footing, {me} — a standing trade agreement, good for both ledgers.',
'I\'m proposing something ongoing, {me}: a trade agreement, not just a one-time deal.',
],
replyTradeAgreementAccept: [
'Now we\'re talking, {you}. I\'ll have my people draw up the terms.',
'Agreed, {you} — a standing arrangement suits me fine.',
],
replyTradeAgreementReject: [
'Not yet, {you}. I don\'t open a ledger I don\'t trust.',
'I\'ll pass for now, {you}. Ask again when the numbers make more sense.',
],
offerTradeAgreementOpener: [
'I\'ve got a standing proposal for you, {you} — a trade agreement, ongoing, good for us both.',
'Let\'s formalize something, {you}: regular trade, on the record.',
],
fleetComplaintOpener: [
'Your fleet\'s been sitting over my colony a little too long, {you}. Care to explain, or just move it?',
'I\'ll be direct, {you}: that fleet parked over my world is bad for business. Move it.',
],
acknowledgeComplaintWithdraw: [
'Fair point, {me}. I\'ll have it moved.',
'Understood, {me} — consider it handled.',
],
acknowledgeComplaintDefy: [
'It stays, {me}. Nothing personal, just where I need it right now.',
'I hear you, {me}, but it\'s not moving yet.',
],
afterComplaintWithdrawPromise: [
'Appreciated, {you}. I\'ll be watching for it.',
'Good. That\'s the kind of business I like doing, {you}.',
],
afterComplaintDefy: [
'Noted, {you}. I won\'t forget who kept me waiting.',
'Fine, {you} — but don\'t expect my patience to hold the price.',
],
askPeople: [
'Tell me about your people, {me} — where you\'ve come from, what you value. I like knowing who I\'m dealing with.',
'I\'m curious about your people, {me}. Humor me — tell me your story.',
@ -281,6 +329,54 @@ export const CHAT = {
'A groundling\'s answer, {you}. We shall remember the insult.',
'Then the sky remains closed to you, {you}.',
],
giftOffer: [
'A gift, freely given, {me} — let it speak plainer than my words could.',
'I offer this without demand of return, {me}. Take it as the gesture it is.',
],
replyGiftWarm: [
'Graciously given, {you}. The aerie takes note of such gestures.',
'You fly closer to our favor with this, {you}. Well offered.',
],
replyGiftNeutral: [
'Received, {you}. A small wind, but not an unwelcome one.',
'Noted, {you}. It does not go unmarked.',
],
proposeTradeAgreement: [
'Let our trade routes fly a steadier course, {me} — I propose a standing agreement.',
'Beyond a single exchange, {me}, I offer something lasting: a trade agreement between our peoples.',
],
replyTradeAgreementAccept: [
'Agreed, {you}. Let the routes between us stay open.',
'A steady arrangement suits the aerie, {you}. Accepted.',
],
replyTradeAgreementReject: [
'Not yet, {you}. We do not open our routes to just any wind.',
'The aerie is not convinced, {you}. Ask again once you\'ve proven steadier.',
],
offerTradeAgreementOpener: [
'The aerie proposes something lasting, {you} — a standing trade agreement, not merely a single exchange.',
'Let us formalize our commerce, {you}. I offer an ongoing arrangement.',
],
fleetComplaintOpener: [
'Your ships circle our nest longer than courtesy allows, {you}. Explain yourself, or withdraw them.',
'A fleet that lingers over our aerie is no longer a guest, {you}. Move it.',
],
acknowledgeComplaintWithdraw: [
'A fair grievance, {me}. The fleet will be recalled.',
'Understood, {me}. We did not mean to overstay our welcome.',
],
acknowledgeComplaintDefy: [
'It remains where it is, {me}, for now.',
'I hear the grievance, {me}, but the fleet does not move yet.',
],
afterComplaintWithdrawPromise: [
'Good. The aerie will be watching the sky above our nest, {you}.',
'Wisely chosen, {you}. See that it is done swiftly.',
],
afterComplaintDefy: [
'Then the aerie remembers this insult, {you}.',
'Bold, to test our patience so openly, {you}. We will not forget it.',
],
askPeople: [
'Tell me of your people, {me}. I would understand what shaped a race so certain of its own altitude.',
'I\'m curious about the Kestrelli, {me} — your history, your ways. Enlighten a groundling.',
@ -400,6 +496,54 @@ export const CHAT = {
'Then we stand alone, {you}. So be it.',
'Your answer has weight, {you}. We will not forget it.',
],
giftOffer: [
'A gift, {me}. No weight of obligation attached to it.',
'Take this plainly, {me} — offered, not owed.',
],
replyGiftWarm: [
'Good weight, {you}. This is noticed.',
'A generous gesture, {you}. We remember weight like this.',
],
replyGiftNeutral: [
'Received, {you}. Small, but not nothing.',
'Noted, {you}. It adds to the scale, if slowly.',
],
proposeTradeAgreement: [
'Let this trade stand steady, {me}, not shift with every wind. I propose an agreement.',
'Beyond one exchange, {me} — I offer something that holds. A trade agreement.',
],
replyTradeAgreementAccept: [
'Agreed, {you}. A steady arrangement suits us.',
'Good. Let it stand, {you}.',
],
replyTradeAgreementReject: [
'Not yet, {you}. We do not bind ourselves to unproven weight.',
'No, {you}. Prove your footing first.',
],
offerTradeAgreementOpener: [
'We offer something that holds, {you} — not a single exchange, but a standing arrangement.',
'Let our trade stand steady, {you}. We propose an agreement.',
],
fleetComplaintOpener: [
'Your ships sit over our ground too long, {you}. Explain the weight of that, or lift it.',
'A fleet that does not move becomes an occupation, {you}. Move it.',
],
acknowledgeComplaintWithdraw: [
'Fair. It will be moved, {me}.',
'Understood, {me}. The weight will be lifted.',
],
acknowledgeComplaintDefy: [
'It stays, {me}. That is my answer.',
'Not yet, {me}. The fleet remains.',
],
afterComplaintWithdrawPromise: [
'Good. We will watch the ground, {you}.',
'Sound judgment, {you}. See it done.',
],
afterComplaintDefy: [
'Then we remember this weight, {you}.',
'So be it, {you}. This will not be forgotten.',
],
askPeople: [
'Tell me of your people, {me}. I want to understand what makes an Ursaal stand as you do.',
'Speak of your history, {me}. I\'d hear it plainly, the way you tell it.',
@ -520,6 +664,54 @@ export const CHAT = {
'As expected, {you}. Trust was always going to be the harder sell.',
'Fine, {you}. We\'ll remember this face you\'ve shown us.',
],
giftOffer: [
'A gift, {me} — genuine, for once. Believe that or don\'t.',
'Take this plainly offered, {me}. No hidden face behind it, I promise.',
],
replyGiftWarm: [
'Generous, {you} — and we mean that honestly, which surprises us as much as you.',
'A convincing gesture, {you}. We\'re almost inclined to trust it.',
],
replyGiftNeutral: [
'Received, {you}. We\'ll wear this kindly, for now.',
'Noted, {you}. Small trust, carefully weighed.',
],
proposeTradeAgreement: [
'Something lasting, {me}, not just one trade — I propose an agreement. Suspicious of us as ever, I\'m sure.',
'Beyond a single exchange, {me}: a standing arrangement. We\'ll even mean it.',
],
replyTradeAgreementAccept: [
'Agreed, {you} — and yes, we know exactly how that sounds coming from us.',
'A standing arrangement, then, {you}. Watch us anyway.',
],
replyTradeAgreementReject: [
'No, {you}. Even we know better than to bind ourselves to something we might not keep.',
'Not yet, {you}. We haven\'t decided which face this trade wears.',
],
offerTradeAgreementOpener: [
'We propose something lasting, {you} — a trade agreement. Sincerely, for whatever that\'s worth from us.',
'A standing arrangement, {you}. We\'d like to make this formal, believe it or not.',
],
fleetComplaintOpener: [
'Your fleet has worn out its welcome over our world, {you}. Move it, or explain why it stays.',
'We\'ve watched your ships linger long enough, {you}. That\'s not a face we appreciate.',
],
acknowledgeComplaintWithdraw: [
'Fair enough, {me}. It\'ll be moved.',
'Understood, {me} — we didn\'t mean to overstay.',
],
acknowledgeComplaintDefy: [
'It stays, {me}. Read into that what you like.',
'Not yet, {me}. That\'s simply the shape of things right now.',
],
afterComplaintWithdrawPromise: [
'Good. We\'ll be watching to see if that face holds, {you}.',
'Believable, for once, {you}. We\'ll wait and see.',
],
afterComplaintDefy: [
'As expected, {you}. We\'ll remember this face you\'ve shown us too.',
'Fine, {you}. We\'ve worn patience before. We can wear it a little longer — but not forever.',
],
askPeople: [
'Tell me about your people, {me}. I\'d like the truth, if that\'s a thing you deal in.',
'I\'m curious about the Umbrix, {me} — who you really are, beneath whichever face this is.',
@ -640,6 +832,54 @@ export const CHAT = {
'Rejection logged, {you}. Function reverts.',
'Noted, {you}. Proposal withdrawn from active memory.',
],
giftOffer: [
'Resource transfer proposed, {me}. No return function required.',
'Value offered without exchange condition, {me}. Gift function initiated.',
],
replyGiftWarm: [
'Gift logged. Pattern registers as significantly favorable, {you}.',
'Resource transfer noted. Relationship function improved substantially, {you}.',
],
replyGiftNeutral: [
'Gift logged, {you}. Pattern registers as marginally favorable.',
'Transfer acknowledged, {you}. Minor adjustment recorded.',
],
proposeTradeAgreement: [
'Continuous exchange protocol proposed, {me}. Superior to single-transaction function.',
'Standing trade function proposed, {me}. Recurring benefit calculated.',
],
replyTradeAgreementAccept: [
'Continuous exchange protocol accepted, {you}.',
'Standing trade function initiated, {you}.',
],
replyTradeAgreementReject: [
'Insufficient benefit calculated for continuous function, {you}. Rejected.',
'Standing protocol rejected, {you}. Function unclear.',
],
offerTradeAgreementOpener: [
'Continuous exchange protocol calculated as favorable, {you}. Proposed.',
'Standing trade function available, {you}. Proposing.',
],
fleetComplaintOpener: [
'Foreign vessel detected over colony function beyond acceptable duration, {you}. Withdraw it.',
'Occupation pattern logged at colony coordinates, {you}. Explain function or withdraw.',
],
acknowledgeComplaintWithdraw: [
'Function acknowledged. Vessel will be withdrawn, {me}.',
'Understood. Withdrawal initiated, {me}.',
],
acknowledgeComplaintDefy: [
'Vessel remains, {me}. Function unchanged.',
'Withdrawal not authorized at this time, {me}.',
],
afterComplaintWithdrawPromise: [
'Acknowledged. Monitoring continues, {you}.',
'Withdrawal logged as pending, {you}. Function will be verified.',
],
afterComplaintDefy: [
'Refusal logged, {you}. Pattern registers as hostile.',
'Noncompliance noted, {you}. Function reassessed accordingly.',
],
askPeople: [
'Query: describe your civilization. Origin, structure, function.',
'Request information: your people. History and purpose.',
@ -759,6 +999,54 @@ export const CHAT = {
'Rejection logged, {you}. Recalculating alternatives.',
'Noted, {you}. Model adjusted accordingly.',
],
giftOffer: [
'A resource transfer, {me}, no return cycle required. Consider it logged as goodwill.',
'Unconditional transfer offered, {me}. A calculated investment in relations.',
],
replyGiftWarm: [
'Efficient gesture, {you}. Relationship metric significantly improved.',
'Logged as favorable, {you}. This exceeds baseline expectation.',
],
replyGiftNeutral: [
'Received, {you}. Marginal improvement logged.',
'Noted, {you}. Small input, small output — as expected.',
],
proposeTradeAgreement: [
'A continuous exchange outperforms a single transaction, {me}. I propose a standing agreement.',
'Recurring trade improves throughput for both systems, {me}. Proposing an agreement.',
],
replyTradeAgreementAccept: [
'Recurring exchange accepted, {you}. Throughput improves.',
'Efficient. Standing agreement accepted, {you}.',
],
replyTradeAgreementReject: [
'Insufficient projected benefit, {you}. Rejected.',
'Not optimal at this time, {you}. Declined.',
],
offerTradeAgreementOpener: [
'A recurring exchange has been calculated as favorable, {you}. Proposing a standing agreement.',
'Continuous trade improves both systems, {you}. Proposal offered.',
],
fleetComplaintOpener: [
'Your fleet occupies orbital space above my colony beyond acceptable parameters, {you}. Relocate it.',
'Unauthorized presence logged at my colony, {you}. Justify it or withdraw.',
],
acknowledgeComplaintWithdraw: [
'Acceptable. The fleet will be relocated, {me}.',
'Understood. Withdrawal scheduled, {me}.',
],
acknowledgeComplaintDefy: [
'The fleet remains stationed, {me}. Parameters unchanged.',
'Relocation not authorized, {me}. Not yet.',
],
afterComplaintWithdrawPromise: [
'Logged. Compliance will be verified, {you}.',
'Acceptable resolution, {you}. Monitoring continues.',
],
afterComplaintDefy: [
'Noncompliance logged, {you}. Recalculating response.',
'Noted, {you}. This will factor into future calculations.',
],
askPeople: [
'Query your history, {me}. I\'d like to understand the upgrade path that led here.',
'Tell me about your civilization, {me} — how it was built, cycle by cycle.',
@ -878,6 +1166,54 @@ export const CHAT = {
'Your loss, {you}. The offer doesn\'t come twice.',
'Fine. Remember you had the chance, {you}.',
],
giftOffer: [
'A gift, {me}. No claim attached — take it as it\'s offered.',
'Freely given, {me}. Even hunters share a kill sometimes.',
],
replyGiftWarm: [
'Generous, {you}. That\'s the kind of offering that earns a longer leash.',
'Good instinct, {you}. Noted, and appreciated.',
],
replyGiftNeutral: [
'Received, {you}. Small, but not forgotten.',
'Noted, {you}. A fair enough gesture.',
],
proposeTradeAgreement: [
'One trade isn\'t enough, {me}. I propose something that holds — a standing agreement.',
'Let this arrangement outlast a single hunt, {me}. A trade agreement, ongoing.',
],
replyTradeAgreementAccept: [
'Agreed, {you}. A standing arrangement suits me.',
'Fine. Let it hold, {you}.',
],
replyTradeAgreementReject: [
'Not yet, {you}. You haven\'t earned that kind of trust.',
'No, {you}. Prove yourself further first.',
],
offerTradeAgreementOpener: [
'I offer something that holds, {you} — not one trade, but a standing arrangement.',
'Let this go beyond a single exchange, {you}. A trade agreement, proposed.',
],
fleetComplaintOpener: [
'Your ships have circled my territory too long, {you}. Move them, or give me a reason not to treat them as prey.',
'A fleet that lingers this long isn\'t visiting, {you}. It\'s hunting. Explain, or withdraw.',
],
acknowledgeComplaintWithdraw: [
'Fair. It\'ll be pulled back, {me}.',
'Understood, {me}. Consider it done.',
],
acknowledgeComplaintDefy: [
'It stays, {me}. That\'s my answer.',
'Not yet, {me}. The fleet doesn\'t move.',
],
afterComplaintWithdrawPromise: [
'Good instinct, {you}. I\'ll be watching to see it kept.',
'Wise, {you}. See that it holds.',
],
afterComplaintDefy: [
'Then you\'ve made yourself the hunt, {you}.',
'Noted, {you}. Remember you had the chance to walk away from this.',
],
askPeople: [
'Tell me about your people, {me}. I want to understand what you were before you were gunners.',
'Speak of your history, {me}. I\'d hear it — the parts you\'re proud of and the parts you aren\'t.',
@ -997,6 +1333,54 @@ export const CHAT = {
'An illogical answer, {you}, but your prerogative.',
'Noted, {you}. I will revise my model of you accordingly.',
],
giftOffer: [
'A transfer, unconditioned, {me}. Call it an investment in favorable variables.',
'I offer this without expectation of return, {me} — a rare gesture, even for me.',
],
replyGiftWarm: [
'A well-calculated gesture, {you}. The variables shift meaningfully in your favor.',
'Generous, and noted precisely, {you}. This improves the equation considerably.',
],
replyGiftNeutral: [
'Received, {you}. A minor but positive adjustment to the model.',
'Noted, {you}. The variables shift, marginally.',
],
proposeTradeAgreement: [
'A single exchange is an inefficient use of the relationship, {me}. I propose a standing agreement.',
'The mathematics favor a continuous arrangement over a one-time trade, {me}.',
],
replyTradeAgreementAccept: [
'The math favors this. Accepted, {you}.',
'A sound, continuous arrangement, {you}. Agreed.',
],
replyTradeAgreementReject: [
'Insufficient data to justify a standing commitment, {you}. Rejected.',
'Premature, {you}. The variables have not yet proven stable.',
],
offerTradeAgreementOpener: [
'I have modeled the benefit of a continuous arrangement, {you}. It exceeds a single exchange. Proposed.',
'A logical next step, {you}: a standing trade agreement, rather than isolated trades.',
],
fleetComplaintOpener: [
'Your fleet\'s continued presence over my colony no longer registers as accidental, {you}. Explain, or withdraw it.',
'I have recalculated your intentions given the duration of that fleet\'s presence, {you}, and the results are not favorable. Move it.',
],
acknowledgeComplaintWithdraw: [
'A reasonable correction, {me}. The fleet will be withdrawn.',
'Understood, {me}. I miscalculated the acceptable duration.',
],
acknowledgeComplaintDefy: [
'The fleet remains, {me}. My reasoning is my own.',
'Not yet, {me}. I have not finished with that position.',
],
afterComplaintWithdrawPromise: [
'A rational correction, {you}. I will verify the outcome.',
'Acceptable. I will be watching for confirmation, {you}.',
],
afterComplaintDefy: [
'A poorly reasoned answer, {you}, but your prerogative. I will revise my model of you accordingly.',
'Noted, {you}. I will factor this into every future calculation involving you.',
],
askPeople: [
'Tell me about your people, {me}. I\'d like to understand the shape of your civilization\'s thought.',
'Explain your history to me, {me} — I suspect it\'s a more interesting equation than most.',
@ -1116,6 +1500,54 @@ export const CHAT = {
'Then the brood remembers this refusal, {you}.',
'So be it, {you}. Our clutches grow apart, then.',
],
giftOffer: [
'A gift for the brood to remember, {me} — given freely, nothing owed.',
'Take this plainly, {me}. The clutch offers it without condition.',
],
replyGiftWarm: [
'Generous, {you}. The brood remembers gestures like this.',
'The clutch is pleased, {you}. Well given.',
],
replyGiftNeutral: [
'Received, {you}. Small, but the brood does not forget it.',
'Noted, {you}. It adds a little warmth to the nest.',
],
proposeTradeAgreement: [
'One trade feeds the brood for a season, {me}. I propose something that feeds it always — a standing agreement.',
'Let our clutches trade as a matter of course, {me}, not as a single exchange.',
],
replyTradeAgreementAccept: [
'Agreed, {you}. The brood welcomes a steady arrangement.',
'Good. Let our clutches trade freely from now on, {you}.',
],
replyTradeAgreementReject: [
'Not yet, {you}. The brood does not bind itself to unproven neighbors.',
'No, {you}. Prove yourself to the clutch first.',
],
offerTradeAgreementOpener: [
'The brood proposes something lasting, {you} — a standing trade agreement, not a single exchange.',
'Let our clutches trade as neighbors do, {you}. We offer an ongoing arrangement.',
],
fleetComplaintOpener: [
'Your fleet crowds our territory too long, {you}. The brood does not tolerate lingering guests. Withdraw it.',
'That fleet sits over our clutch longer than courtesy allows, {you}. Explain yourself, or move it.',
],
acknowledgeComplaintWithdraw: [
'Fair. The fleet will be withdrawn, {me}.',
'Understood, {me}. We did not mean to crowd the brood.',
],
acknowledgeComplaintDefy: [
'It stays, {me}. The brood will have to tolerate it a while longer.',
'Not yet, {me}. The fleet remains where it is.',
],
afterComplaintWithdrawPromise: [
'Good. The brood will be watching our territory, {you}.',
'Wisely chosen, {you}. See it done swiftly.',
],
afterComplaintDefy: [
'Then the brood remembers this encroachment, {you}.',
'So be it, {you}. The clutch does not forget crowded ground.',
],
askPeople: [
'Tell me about your people, {me}. I want to understand the brood, not just its numbers.',
'Speak of your history, {me} — how the clutches grew into what they are.',

View File

@ -0,0 +1,98 @@
// Master of Vega — Bowyer-Watson Delaunay triangulation.
//
// Pure and Phaser-free (the VegaZoom.js precedent: the deciding half of a UI
// concern is checkable headlessly even though the drawing half is not). The
// only consumer today is VegaStarMap.js's range-darkness "controlled space"
// mesh between a player's own colonies — it needs to know which colonies are
// geometric neighbors (triangulation edges) and which triples bound a filled
// face (triangles), without ever connecting colonies that aren't adjacent.
/** Circumcircle of three points, or null if they're (numerically) collinear. */
function circumcircle(a, b, c) {
const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
if (Math.abs(d) < 1e-9) return null;
const aSq = a.x * a.x + a.y * a.y;
const bSq = b.x * b.x + b.y * b.y;
const cSq = c.x * c.x + c.y * c.y;
const ux = (aSq * (b.y - c.y) + bSq * (c.y - a.y) + cSq * (a.y - b.y)) / d;
const uy = (aSq * (c.x - b.x) + bSq * (a.x - c.x) + cSq * (b.x - a.x)) / d;
const r2 = (a.x - ux) ** 2 + (a.y - uy) ** 2;
return { x: ux, y: uy, r2 };
}
const edgeKey = (u, v) => (u < v ? `${u}_${v}` : `${v}_${u}`);
/**
* Delaunay-triangulate `points` ({x,y} array). Returns triangles as index
* triples into `points`. Fewer than 3 points has no triangles by
* definition callers that want a single connecting edge for exactly 2
* points need to draw that themselves.
*/
export function delaunayTriangulate(points) {
const n = points.length;
if (n < 3) return [];
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of points) {
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
}
const spanX = maxX - minX || 1;
const spanY = maxY - minY || 1;
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
// A super-triangle big enough that no input point can ever sit outside it.
const size = Math.max(spanX, spanY) * 20;
const pts = [...points,
{ x: midX - size, y: midY - size },
{ x: midX + size, y: midY - size },
{ x: midX, y: midY + size },
];
const superIdx = [n, n + 1, n + 2];
let triangles = [superIdx];
for (let i = 0; i < n; i += 1) {
const p = pts[i];
const bad = [];
const good = [];
for (const tri of triangles) {
const cc = circumcircle(pts[tri[0]], pts[tri[1]], pts[tri[2]]);
const inside = cc && (p.x - cc.x) ** 2 + (p.y - cc.y) ** 2 <= cc.r2;
(inside ? bad : good).push(tri);
}
// The cavity left by removing the bad triangles is bounded by whichever
// edges belong to exactly one of them — shared (interior) edges cancel.
const edgeCount = new Map();
for (const [a, b, c] of bad) {
for (const [u, v] of [[a, b], [b, c], [c, a]]) {
const key = edgeKey(u, v);
edgeCount.set(key, (edgeCount.get(key) || 0) + 1);
}
}
triangles = good;
for (const [a, b, c] of bad) {
for (const [u, v] of [[a, b], [b, c], [c, a]]) {
if (edgeCount.get(edgeKey(u, v)) === 1) triangles.push([u, v, i]);
}
}
}
return triangles.filter((tri) => tri.every((idx) => idx < n));
}
/** Unique undirected edges (as [i, j] with i < j) covering a triangle set. */
export function triangulationEdges(triangles) {
const seen = new Map();
for (const [a, b, c] of triangles) {
for (const [u, v] of [[a, b], [b, c], [c, a]]) {
const key = edgeKey(u, v);
if (!seen.has(key)) seen.set(key, [Math.min(u, v), Math.max(u, v)]);
}
}
return [...seen.values()];
}

View File

@ -5,7 +5,10 @@
// decision reads. Species diplomacy traits bias it permanently — the Lithox sit
// at -100 and simply cannot be talked to, which is the point of them.
import { empireColonies, atWar, rand, randInt } from './VegaLogic.js';
import {
empireColonies, empireFleets, fleetsAt, atWar, rand, randInt,
} from './VegaLogic.js';
import { parsecs } from './VegaGalaxyGen.js';
export const TREATIES = ['none', 'peace', 'alliance', 'war'];
@ -52,6 +55,11 @@ export function canNegotiate(rules, state, a, b) {
export function declareWar(rules, state, a, b) {
state.empires[a].treaties[b] = 'war';
state.empires[b].treaties[a] = 'war';
if (state.empires[a].tradeAgreements[b]) {
delete state.empires[a].tradeAgreements[b];
delete state.empires[b].tradeAgreements[a];
state.events.push({ type: 'tradeAgreementEnded', empire: a, other: b, reason: 'war', turn: state.turn });
}
adjust(state, b, a, -35);
adjust(state, a, b, -15);
state.events.push({ type: 'warDeclared', empire: a, other: b, turn: state.turn });
@ -78,6 +86,18 @@ export function formAlliance(rules, state, a, b) {
state.events.push({ type: 'alliance', empire: a, other: b, turn: state.turn });
}
// A trade agreement is a separate, coexisting relationship, not another rung
// of the single-valued treaties[] ladder — an empire can trade with a
// neighbor it hasn't (or can't) fully ally with.
export function formTradeAgreement(rules, state, a, b) {
state.empires[a].tradeAgreements[b] = true;
state.empires[b].tradeAgreements[a] = true;
const bonus = rules.diplomacy.tradeAgreement.formBonus;
adjust(state, a, b, bonus);
adjust(state, b, a, bonus);
state.events.push({ type: 'tradeAgreementFormed', empire: a, other: b, turn: state.turn });
}
// Relative strength, used everywhere an empire has to decide whether it can
// afford an opinion.
export function powerOf(rules, state, e) {
@ -98,9 +118,11 @@ export function wouldAccept(rules, state, a, b, kind) {
// The losing side sues for peace readily; a winning one has no reason to.
return att > -70 && (att > -20 || ratio > 1.4);
case 'alliance':
return att > 45 && !atWar(state, a, b);
return att > rules.diplomacy.alliance.attitudeThreshold && !atWar(state, a, b);
case 'techTrade':
return att > 0;
case 'tradeAgreement':
return att > rules.diplomacy.tradeAgreement.attitudeThreshold && !atWar(state, a, b);
default:
return false;
}
@ -113,6 +135,7 @@ export function proposeTreaty(rules, state, a, b, kind) {
}
if (kind === 'peace') makePeace(rules, state, a, b);
else if (kind === 'alliance') formAlliance(rules, state, a, b);
else if (kind === 'tradeAgreement') formTradeAgreement(rules, state, a, b);
return true;
}
@ -142,6 +165,7 @@ export function respondToOffer(rules, state, b, a, accept) {
if (accept) {
if (offer.kind === 'peace') makePeace(rules, state, a, b);
else if (offer.kind === 'alliance') formAlliance(rules, state, a, b);
else if (offer.kind === 'tradeAgreement') formTradeAgreement(rules, state, a, b);
} else {
adjust(state, a, b, -8); // the proposer stings at being refused
adjust(state, b, a, -2); // refusing is itself a small friction cost
@ -182,6 +206,32 @@ export function tradeTech(rules, state, a, b, giveId, wantId) {
return true;
}
// A direct goodwill payment. Repeat gifts to the same empire within
// cooldownTurns still cost full price but buy a much smaller bump — there's
// no hard lockout, spamming gifts just gets rapidly less efficient, so it can
// never trivially buy max attitude the way an unthrottled version could.
export function giveGift(rules, state, a, b, tierId) {
const cfg = rules.diplomacy.gift;
const tier = cfg.tiers.find((t) => t.id === tierId);
if (!tier) return false;
const A = state.empires[a];
if (!canNegotiate(rules, state, a, b) || A.bc < tier.bc) return false;
A.bc -= tier.bc;
state.empires[b].bc += tier.bc;
const last = A.lastGiftTurn[b];
const recent = last !== undefined && state.turn - last < cfg.cooldownTurns;
const delta = recent ? Math.round(tier.delta * cfg.repeatPenaltyFactor) : tier.delta;
A.lastGiftTurn[b] = state.turn;
adjust(state, b, a, delta);
state.events.push({
type: 'gift', empire: a, other: b, tierId, delta, turn: state.turn,
});
return true;
}
// Per-turn drift. Attitudes decay toward the species baseline, war grinds them
// down, and shared borders create friction.
export function driftAttitudes(rules, state, e) {
@ -251,7 +301,28 @@ export function runDiplomacyTurn(rules, state, e) {
declareWar(rules, state, e, other.idx);
continue;
}
if (att > 45 && emp.treaties[other.idx] !== 'alliance' && rand(state) < 0.08) {
// Gifts and trade agreements are pro-social — deliberately NOT scaled by
// aiAggression (that dial is specifically war-eagerness; applying it here
// would make harder difficulties stingier for no thematic reason). The
// only gate is affordability: an AI never spends more than a fixed slice
// of its own treasury on one gift.
const giftCfg = rules.diplomacy.gift;
if (att > -40 && att < 60 && rand(state) < giftCfg.aiProposeChance) {
const cap = emp.bc * giftCfg.aiMaxTreasuryFraction;
const affordable = giftCfg.tiers.filter((t) => t.bc <= cap);
if (affordable.length) giveGift(rules, state, e, other.idx, affordable[affordable.length - 1].id);
}
const tradeCfg = rules.diplomacy.tradeAgreement;
if (att > tradeCfg.attitudeThreshold && !emp.tradeAgreements[other.idx]
&& rand(state) < tradeCfg.aiProposeChance) {
proposeOrOffer(rules, state, e, other.idx, 'tradeAgreement');
}
const allianceCfg = rules.diplomacy.alliance;
if (att > allianceCfg.attitudeThreshold && emp.treaties[other.idx] !== 'alliance'
&& rand(state) < allianceCfg.aiProposeChance) {
proposeOrOffer(rules, state, e, other.idx, 'alliance');
}
}
@ -273,3 +344,230 @@ export function breakStalemate(rules, state) {
declareWar(rules, state, aggressor.idx, victim.idx);
return true;
}
// ---------------------------------------------------------------------------
// Whole-galaxy passes: border/fleet proximity tension, fleet intrusion, and
// attitude triangulation. Each compares every empire pair once, so these are
// called ONCE per calendar turn (see runGalaxyDiplomacyPass) rather than once
// per empire's own turn — both to avoid triple-computing the same pair and to
// stay outside the AI-turn-time budget, which only times runAITurn.
function nearestParsecs(galaxy, starsA, starsB) {
let d = Infinity;
for (const a of starsA) {
for (const b of starsB) {
const p = parsecs(galaxy, a, b);
if (p < d) d = p;
}
}
return d;
}
// Shared by the congestion passes below AND triangulateAttitudes — "hostile"
// is the one definition everything in this file means by it: at war, or
// attitude below the triangulation threshold. Kept here, not inlined in
// triangulateAttitudes, so the congestion passes can ask the identical
// question ("do A and B have a common enemy?") without duplicating it.
function isHostileTo(rules, state, x, y) {
return atWar(state, x, y) || attitudeOf(state, x, y) < rules.diplomacy.triangulation.hostileThreshold;
}
function hasSharedHostile(rules, state, aIdx, bIdx) {
for (const c of state.empires) {
if (!c.alive || c.idx === aIdx || c.idx === bIdx) continue;
if (isHostileTo(rules, state, aIdx, c.idx) && isHostileTo(rules, state, bIdx, c.idx)) return true;
}
return false;
}
// Ambient border friction: the closer two non-allied, non-warring empires'
// territories sit, the more their populations grate on each other. War and
// alliance are skipped because driftAttitudes already carries its own
// war/alliance terms — stacking this on top would double-count. Peace is
// skipped too — a formal peace treaty is exactly the "we've agreed to stop
// minding this" signal, same as alliance. Empires who share a hostile third
// party (the enemy-of-my-enemy relationship triangulateAttitudes tracks)
// aren't exempt, but they ARE more forgiving of each other — a shared threat
// reads as more important than a crowded border, so the penalty is scaled
// down by sharedEnemyForgiveness rather than zeroed outright.
export function applyBorderTension(rules, state) {
const cfg = rules.diplomacy.borderTension;
const alive = state.empires.filter((e) => e.alive);
for (let i = 0; i < alive.length; i += 1) {
for (let j = i + 1; j < alive.length; j += 1) {
const a = alive[i];
const b = alive[j];
if (!a.contacted[b.idx] || atWar(state, a.idx, b.idx)) continue;
if (a.treaties[b.idx] === 'alliance' || a.treaties[b.idx] === 'peace') continue;
const ca = empireColonies(state, a.idx).map((c) => c.starIdx);
const cb = empireColonies(state, b.idx).map((c) => c.starIdx);
if (!ca.length || !cb.length) continue;
const d = nearestParsecs(state.galaxy, ca, cb);
if (d >= cfg.colonyRange) continue;
let delta = Math.round(cfg.colonyMaxPerTurn * (1 - d / cfg.colonyRange));
if (delta > 0 && hasSharedHostile(rules, state, a.idx, b.idx)) {
delta = Math.round(delta * rules.diplomacy.triangulation.sharedEnemyForgiveness);
}
if (delta > 0) {
adjust(state, a.idx, b.idx, -delta);
adjust(state, b.idx, a.idx, -delta);
}
}
}
}
// Same shape as applyBorderTension, but for fleets that have actually arrived
// somewhere (not mid-transit), at a tighter range and a stronger per-turn
// effect — armed ships maneuvering near each other reads as more provocative
// than settled territory.
export function applyFleetProximityTension(rules, state) {
const cfg = rules.diplomacy.borderTension;
const alive = state.empires.filter((e) => e.alive);
for (let i = 0; i < alive.length; i += 1) {
for (let j = i + 1; j < alive.length; j += 1) {
const a = alive[i];
const b = alive[j];
if (!a.contacted[b.idx] || atWar(state, a.idx, b.idx)) continue;
if (a.treaties[b.idx] === 'alliance' || a.treaties[b.idx] === 'peace') continue;
const fa = empireFleets(state, a.idx).filter((f) => f.starIdx >= 0).map((f) => f.starIdx);
const fb = empireFleets(state, b.idx).filter((f) => f.starIdx >= 0).map((f) => f.starIdx);
if (!fa.length || !fb.length) continue;
const d = nearestParsecs(state.galaxy, fa, fb);
if (d >= cfg.fleetRange) continue;
let delta = Math.round(cfg.fleetMaxPerTurn * (1 - d / cfg.fleetRange));
if (delta > 0 && hasSharedHostile(rules, state, a.idx, b.idx)) {
delta = Math.round(delta * rules.diplomacy.triangulation.sharedEnemyForgiveness);
}
if (delta > 0) {
adjust(state, a.idx, b.idx, -delta);
adjust(state, b.idx, a.idx, -delta);
}
}
}
}
// A foreign, non-allied fleet parked AT a colony's own star is a much sharper
// provocation than merely being nearby. Dwell time is tracked on the colony
// OWNER's empire object (fleetIntrusions[starIdx][intruderIdx]), not on the
// fleet object itself — fleet objects churn under consolidateFleets' merges
// and sendDetachment's splits, which would corrupt a per-fleet arrival stamp.
// Presence is re-derived fresh from fleetsAt() every pass, so this is robust
// to any such churn: what matters is only "is a foreign fleet here," not
// which specific fleet object it is.
export function checkFleetIntrusions(rules, state) {
const cfg = rules.diplomacy.fleetIntrusion;
for (const colony of state.colonies) {
const owner = state.empires[colony.empireIdx];
if (!owner?.alive) continue;
owner.fleetIntrusions[colony.starIdx] ??= {};
const atStar = owner.fleetIntrusions[colony.starIdx];
// A fleet parked at an enemy's colony DURING a war is a siege, not a
// faux-pas — "please leave" only makes sense between empires not already
// shooting at each other. Peace and alliance are both full exemptions,
// same carve-out the other two proximity passes use.
const present = new Set();
for (const f of fleetsAt(state, colony.starIdx)) {
if (f.empireIdx === colony.empireIdx) continue;
if (owner.treaties[f.empireIdx] === 'alliance' || owner.treaties[f.empireIdx] === 'peace') continue;
if (atWar(state, colony.empireIdx, f.empireIdx)) continue;
present.add(f.empireIdx);
}
// Anyone tracked but no longer present has left — reset immediately, so a
// later return starts the grace period over rather than resuming.
for (const idx of Object.keys(atStar)) {
if (!present.has(Number(idx))) delete atStar[idx];
}
for (const idx of present) {
const rec = atStar[idx] ?? (atStar[idx] = { sinceTurn: state.turn, lastNagTurn: -1 });
const dwell = state.turn - rec.sinceTurn;
if (dwell < cfg.graceTurns) continue; // the arrival turn itself is free
let magnitude = Math.min(cfg.capPerTurn,
cfg.basePerTurn + (dwell - cfg.graceTurns) * cfg.escalationPerTurn);
// A shared enemy makes even an overstaying fleet more forgivable —
// same dampening applyBorderTension/applyFleetProximityTension use.
if (hasSharedHostile(rules, state, colony.empireIdx, idx)) {
magnitude = Math.round(magnitude * rules.diplomacy.triangulation.sharedEnemyForgiveness);
}
adjust(state, colony.empireIdx, idx, -magnitude);
if (rec.lastNagTurn !== state.turn) {
rec.lastNagTurn = state.turn;
state.events.push({
type: 'fleetComplaint',
empire: colony.empireIdx,
other: idx,
starIdx: colony.starIdx,
dwell,
first: dwell === cfg.graceTurns,
turn: state.turn,
});
}
}
}
}
// Mirrors claimAudienceContacts exactly, including the shared `announced`
// flag: scans for unclaimed FIRST-violation fleetComplaint events involving
// `me`, marks them claimed, and returns the other party. Only the first-ever
// crossing of the grace period force-opens an Audience — every later turn
// the same intrusion continues is left unclaimed here, so
// MasterOfVegaGame.processTurnEvents() picks it up as an ordinary log line
// instead (never a second popup for the same incident). Using the SAME
// `announced` flag processTurnEvents() itself checks is what keeps a claimed
// first violation from ALSO turning into a redundant log line right after
// the popup already voiced it — same reasoning as claimAudienceContacts
// leaving an incapable species' contact event unclaimed so it falls through
// to the ordinary turn report instead of a popup that can never open for them.
export function claimFleetComplaints(rules, state, me) {
const found = [];
for (const ev of state.events) {
if (ev.announced || ev.type !== 'fleetComplaint' || !ev.first) continue;
const otherIdx = ev.empire === me ? ev.other : (ev.other === me ? ev.empire : undefined);
if (otherIdx === undefined) continue;
if (!canNegotiate(rules, state, me, otherIdx)) continue;
ev.announced = true;
found.push(otherIdx);
}
return [...new Set(found)];
}
// "The enemy of my enemy is my friend": if A is hostile to B, and B is
// hostile to C, C's opinion of A drifts up — directional only (C warms to A;
// A does not automatically warm to C from this alone). Hostile reuses
// moodOf's existing "cold" boundary rather than inventing a new tier. Runs
// galaxy-wide (every empire pair, not just the player's), per
// rules.diplomacy.triangulation.scope.
export function triangulateAttitudes(rules, state) {
const cfg = rules.diplomacy.triangulation;
const alive = state.empires.filter((e) => e.alive);
for (const A of alive) {
for (const C of alive) {
if (C.idx === A.idx || !C.contacted[A.idx] || atWar(state, A.idx, C.idx)) continue;
// Player-only mode narrows to pairs that actually involve the human —
// the human can be either the one warming up (C) or the one benefiting
// (A); pure AI-vs-AI pairs are skipped entirely in that mode.
if (cfg.scope === 'player' && A.idx !== state.humanIndex && C.idx !== state.humanIndex) continue;
let shared = 0;
for (const B of alive) {
if (B.idx === A.idx || B.idx === C.idx) continue;
if (isHostileTo(rules, state, A.idx, B.idx) && isHostileTo(rules, state, B.idx, C.idx)) shared += 1;
}
if (shared > 0) {
const delta = Math.min(cfg.maxPerTurn, cfg.perSharedEnemy * shared);
adjust(state, C.idx, A.idx, delta);
}
}
}
}
// One consolidated call site for every whole-galaxy pass above — see
// VegaLogic.js's endEmpireTurn, called once per calendar turn (not per
// empire) from the turn-wrap block, the same place runCouncil/breakStalemate
// already live.
export function runGalaxyDiplomacyPass(rules, state) {
applyBorderTension(rules, state);
applyFleetProximityTension(rules, state);
checkFleetIntrusions(rules, state);
triangulateAttitudes(rules, state);
}

View File

@ -3,7 +3,9 @@
// A leader is hired once, costs upkeep forever, and does nothing at all until
// posted. Admins run a colony; captains run a fleet. Headless.
import { empireColonies, empireFleets, hireLeader, assignLeader, fleetPower } from './VegaLogic.js';
import {
empireColonies, empireFleets, hireLeader, assignLeader, fleetPower, leaderHireCost,
} from './VegaLogic.js';
// Leaders are a shared galactic pool — once an empire hires Nyx Holt, nobody
// else can. That makes the offer worth taking when it appears.
@ -48,7 +50,7 @@ export function runLeaderTurn(rules, state, e) {
const budget = emp.bc * 0.35;
for (const offer of leaderOffers(rules, state, e)) {
if (emp.leaders.length >= 6) break;
if (offer.hireCost > budget) continue;
if (leaderHireCost(rules, state, e, offer.id) > budget) continue;
if (hireLeader(rules, state, e, offer.id)) break; // one hire per turn
}

View File

@ -15,7 +15,7 @@ import { generateGalaxy, parsecs, mulberry32 } from './VegaGalaxyGen.js';
import { techCost, techCostFactor } from './VegaRules.js';
import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js';
import { createBattle, runBattle, resolveInvasion } from './VegaCombat.js';
import { breakStalemate, declareWar } from './VegaDiplomacy.js';
import { breakStalemate, declareWar, runGalaxyDiplomacyPass } from './VegaDiplomacy.js';
export const CHANNELS = ['ships', 'defense', 'industry', 'ecology', 'research'];
@ -425,8 +425,15 @@ export function createGame(rules, opts) {
treaties: {},
attitude: {},
pendingOffers: {},
tradeAgreements: {},
lastGiftTurn: {},
fleetIntrusions: {},
explored: {},
spyPoints: 0,
// otherIdx -> 'steal' | 'sabotage', missing entry means 'steal' (see
// runEspionage) — every empire behaves exactly as it always has until
// the human explicitly sets a mission on the Audience screen.
espionageMission: {},
totalPop: 0,
nameCursor: 0,
// A per-empire shuffle of its species' colonyNames indices, so
@ -632,6 +639,33 @@ export function nextResearchTarget(rules, state, e, field) {
return null;
}
// The techs at a field's current frontier rung that are actually
// researchable right now. Length 0 = field exhausted; length 1 = the
// unambiguous pick nextResearchTarget already made; length 2+ = a live
// choice the player hasn't resolved — this is what VegaResearchChoiceScreen
// prompts for.
export function openResearchChoices(rules, state, e, field) {
const candidates = rules.techsByField[field].filter((t) => canResearch(rules, state, e, t));
if (!candidates.length) return [];
const minTier = Math.min(...candidates.map((t) => t.tier));
return candidates.filter((t) => t.tier === minTier);
}
// An original formula, not a MOO1 port — ×5 per known tier echoes the real
// MOO1 fact that its 10 sub-fields each span 5 levels; +1 per extra known
// tech below the frontier echoes the general shape (not the literals) of
// MOO1's own scoring. Pure function of emp.known/rules.techsByField.
export function fieldTechLevel(rules, state, e, field) {
const emp = state.empires[e];
const knownTiers = rules.techsByField[field]
.filter((t) => emp.known[t.id])
.map((t) => t.tier);
if (knownTiers.length === 0) return 0;
const highestKnownTier = Math.max(...knownTiers);
const extraKnownBelowFrontier = knownTiers.filter((t) => t < highestKnownTier).length;
return highestKnownTier * 5 + extraKnownBelowFrontier;
}
// Player (or a future AI override) picks a specific tech within its field's
// currently-open rung. Deliberately does not touch emp.beakers[field] — RP
// banked in a field is a shared pool (see processResearch below), so
@ -847,8 +881,19 @@ export function beginEmpireTurn(rules, state, e) {
}
for (const l of emp.leaders) upkeep += rules.leaders[l.leaderId].upkeep;
emp.bc = Math.max(0, emp.bc + trade - upkeep);
emp.lastIncome = trade - upkeep;
// Active trade agreements add a modest ongoing income, scaled to the
// smaller of the two economies so a deal with a tiny neighbor is never a
// windfall.
let tradeAgreementIncome = 0;
for (const otherIdx of Object.keys(emp.tradeAgreements)) {
const other = state.empires[otherIdx];
if (!other?.alive) continue;
tradeAgreementIncome += rules.diplomacy.tradeAgreement.bcRatePerPop
* Math.min(emp.totalPop, other.totalPop);
}
emp.bc = Math.max(0, emp.bc + trade + tradeAgreementIncome - upkeep);
emp.lastIncome = trade + tradeAgreementIncome - upkeep;
const researchMult = spec.traits.researchMult
* comps.researchMult
@ -889,7 +934,9 @@ function autoRefit(rules, state, e) {
}
// Espionage is passive: a species with spies gets periodic attempts against
// empires it has met, and steals a tech it could not research itself.
// empires it has met, and either steals a tech it could not research itself
// or sabotages a colony — whichever mission the attacker has standing (set
// from the Audience screen's Intelligence toggle, default 'steal').
function runEspionage(rules, state, e) {
const emp = state.empires[e];
const spec = rules.species[emp.speciesId];
@ -908,26 +955,51 @@ function runEspionage(rules, state, e) {
if (emp.spyPoints < 1) return;
emp.spyPoints -= 1;
// 'off' (set from the Audience screen's Intelligence toggle) pulls that
// empire out of the random-target pool entirely — the only way, since
// targeting is otherwise a single shared roll across everyone contacted,
// to actually stop spying on someone rather than just changing what a hit
// against them does.
const targets = state.empires.filter((o) => o.alive && o.idx !== e
&& emp.contacted[o.idx] && emp.treaties[o.idx] !== 'alliance');
&& emp.contacted[o.idx] && emp.treaties[o.idx] !== 'alliance'
&& (emp.espionageMission[o.idx] ?? 'steal') !== 'off');
if (!targets.length) return;
const target = targets[randInt(state, targets.length)];
const mission = emp.espionageMission[target.idx] ?? 'steal';
const tspec = rules.species[target.speciesId];
let defence = (tspec.traits.counterEspionage ?? 0) + empireComponents(rules, state, target.idx).counterEspionage;
for (const c of empireColonies(state, target.idx)) defence += buildingEffect(rules, c, 'espionage');
const odds = Math.max(0.05, Math.min(0.8, 0.4 + (power - defence) / 200));
if (rand(state) > odds) {
pushEvent(state, { type: 'spyCaught', empire: e, target: target.idx, turn: state.turn });
target.attitude[e] = (target.attitude[e] ?? 0) - 12;
pushEvent(state, { type: 'spyCaught', empire: e, target: target.idx, mission, turn: state.turn });
target.attitude[e] = Math.max(-100, Math.min(100, (target.attitude[e] ?? 0) - 12));
return;
}
if (mission === 'sabotage') {
const targetColonies = empireColonies(state, target.idx);
if (!targetColonies.length) return;
const colony = targetColonies[randInt(state, targetColonies.length)];
const cfg = rules.diplomacy.espionage;
const factoriesLost = Math.round(colony.factories * cfg.sabotageFactoriesFraction);
const defenseLost = Math.round(colony.defenseHp * cfg.sabotageDefenseFraction);
colony.factories = Math.max(0, colony.factories - factoriesLost);
colony.defenseHp = Math.max(0, colony.defenseHp - defenseLost);
pushEvent(state, {
type: 'sabotage', empire: e, target: target.idx, starIdx: colony.starIdx,
factoriesLost, defenseLost, turn: state.turn,
});
target.attitude[e] = Math.max(-100, Math.min(100, (target.attitude[e] ?? 0) - cfg.sabotageAttitudePenalty));
return;
}
const stealable = rules.techList.filter((t) => target.known[t.id] && !emp.known[t.id]);
if (!stealable.length) return;
const tech = stealable[randInt(state, stealable.length)];
grantTech(rules, state, e, tech.id, 'espionage');
pushEvent(state, { type: 'techStolen', empire: e, target: target.idx, techId: tech.id, turn: state.turn });
target.attitude[e] = (target.attitude[e] ?? 0) - 8;
target.attitude[e] = Math.max(-100, Math.min(100, (target.attitude[e] ?? 0) - 8));
}
function recomputeTotals(rules, state) {
@ -1571,7 +1643,7 @@ export function invade(rules, state, e, starIdx) {
colony.queue = [];
colony.defenseHp = 0;
pushEvent(state, { type: 'captured', empire: e, from, starIdx, colonyId: colony.id, turn: state.turn });
state.empires[from].attitude[e] = (state.empires[from].attitude[e] ?? 0) - 40;
state.empires[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);
} else {
@ -1727,6 +1799,11 @@ export function endEmpireTurn(rules, state, e, { skipMove = false } = {}) {
if (next === 0) {
state.turn += 1;
state.rules = rules;
// Border/fleet proximity tension, fleet-parked-at-your-colony
// complaints, and enemy-of-my-enemy triangulation — whole-galaxy
// passes, run once per calendar turn rather than once per empire, and
// before the Council so its vote reads this turn's fresh attitude.
runGalaxyDiplomacyPass(rules, state);
if (rules.council.council !== false
&& state.turn >= state.council.nextTurn
&& colonizedFraction(state) >= rules.council.minColonizedFraction) {
@ -1890,14 +1967,29 @@ export function moveQueueRun(rules, state, colony, rows, ri, dir) {
return true;
}
// A leader's list price (rules.leaders[id].hireCost) is fixed; what an
// empire actually pays climbs with how many leaders it already has on
// staff, so stockpiling BC and clearing the shared pool cheaply stops being
// the dominant strategy once a roster gets going. The 1st hire is always
// list price.
export function leaderHireCost(rules, state, e, leaderId) {
const leader = rules.leaders[leaderId];
if (!leader) return Infinity;
const owned = state.empires[e].leaders.length;
return Math.round(leader.hireCost * (1 + owned * rules.leaderHiring.costScalePerOwned));
}
export function hireLeader(rules, state, e, leaderId) {
const emp = state.empires[e];
const leader = rules.leaders[leaderId];
if (!leader || emp.leaders.some((l) => l.leaderId === leaderId)) return false;
if (emp.bc < leader.hireCost) return false;
emp.bc -= leader.hireCost;
const cost = leaderHireCost(rules, state, e, leaderId);
if (emp.bc < cost) return false;
emp.bc -= cost;
emp.leaders.push({ leaderId, assignKind: null, assignId: -1 });
pushEvent(state, { type: 'leaderHired', empire: e, leaderId, turn: state.turn });
pushEvent(state, {
type: 'leaderHired', empire: e, leaderId, cost, turn: state.turn,
});
return true;
}
@ -1917,6 +2009,15 @@ export function assignLeader(rules, state, e, leaderId, kind, id) {
return true;
}
export function unassignLeader(rules, state, e, leaderId) {
const emp = state.empires[e];
const l = emp.leaders.find((x) => x.leaderId === leaderId);
if (!l || l.assignKind === null) return false;
l.assignKind = null;
l.assignId = -1;
return true;
}
// --------------------------------------------------------------------------
// Serialisation
@ -1939,6 +2040,12 @@ export function deserialize(json) {
// at all — back-fill it the same way a fresh empire gets one, rather than
// bumping the save version over one new always-empty-by-default field.
emp.allocLocked ??= {};
// Same convention for the diplomacy-expansion fields: trade agreements,
// gift cooldown tracking, and foreign-fleet dwell tracking.
emp.tradeAgreements ??= {};
emp.lastGiftTurn ??= {};
emp.fleetIntrusions ??= {};
emp.espionageMission ??= {};
}
return state;
}

View File

@ -0,0 +1,236 @@
// Master of Vega — the "choose your next technology" prompt. Opened once per
// field, in turn order, right after a human research completion opens up a
// genuinely ambiguous rung (2+ live alternatives) — before the New Turn
// report. See MasterOfVegaGame.pendingResearchChoices()/runToHumanTurn() for
// the detection and sequencing.
//
// The engine has already auto-picked a default (the veteran tech, same as
// always — see VegaLogic.nextResearchTarget) by the time this screen opens;
// this screen never shows that pick and immediately discards it
// (emp.researching[field] = null) so nothing reads as pre-selected. The
// player's click calls the same zero-cost VegaLogic.setResearchTarget the
// normal Research screen's tree already uses.
//
// Same shell geometry as VegaResearchScreen.js's openResearchScreen (this
// file duplicates its tree-drawing math rather than sharing it — same
// "each screen owns its own copy of intricate rendering logic" convention
// already used for the JIT-video plumbing across VegaAudience.js/
// VegaColonyIntro.js/VegaResearchScreen.js, to avoid risking a live refactor
// of the shipped tree renderer).
import * as Phaser from 'phaser';
import { FONT, D, modalShell } from './VegaScreens.js';
import { setResearchTarget, fieldTechLevel } from './VegaLogic.js';
import { buildVideoPanel } from './VegaResearchScreen.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { describeTechTooltip } from './VegaTooltips.js';
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
const GOLD = '#ffd88a';
const GOLD_INT = 0xffd88a;
/** A ring of small dots around (x, y), continuously rotating. */
function buildRotatingRing(scene, x, y, radius, colourInt, dotCount = 12, dotRadius = 2.5) {
const ring = scene.add.container(x, y);
for (let i = 0; i < dotCount; i += 1) {
const angle = (i / dotCount) * Math.PI * 2;
ring.add(scene.add.circle(Math.cos(angle) * radius, Math.sin(angle) * radius, dotRadius, colourInt));
}
scene.tweens.add({ targets: ring, angle: 360, duration: 3000, repeat: -1, ease: 'Linear' });
return ring;
}
export function openResearchChoiceScreen(scene, rules, state, e, art, field, choices, completedTechId, onClose) {
const emp = state.empires[e];
const fieldDef = rules.techFields[field];
const completedTech = rules.techs[completedTechId];
// Discard the engine's auto-pick — this screen's whole point is that
// nothing is chosen until the player clicks one of the ringed options.
emp.researching[field] = null;
const tooltip = new Tooltip(scene, { depth: D.modal + 5 });
const shell = modalShell(scene, 'Choose Your Next Research', () => {
tooltip.destroy();
onClose?.();
}, { width: 1500, height: 880, closable: false });
// --- layout, identical to openResearchScreen's -----------------------
const videoH = shell.body.h;
const videoW = Math.round(videoH * (2 / 3));
const rightX = shell.body.x + videoW + 24;
const rightW = shell.body.w - videoW - 24;
const GAP = 16;
const boxW = (rightW - 2 * GAP) / 3;
const boxH = 200;
const gridY = shell.body.y;
const gridBottom = gridY + 2 * boxH + GAP;
const headingY = gridBottom + 6;
const detailY = gridBottom + 34;
const detailH = shell.body.h - (detailY - shell.body.y);
const detailX = rightX;
const detailW = rightW;
shell.add(buildVideoPanel(scene, rules, art, emp.speciesId, shell.body.x, shell.body.y, videoW, videoH));
// --- top-right: the completed field's box, read-only -------------------
const bx = rightX;
const by = gridY;
shell.add(scene.add.rectangle(bx + boxW / 2, by + boxH / 2, boxW, boxH, PANEL, 0.6)
.setStrokeStyle(1, ACCENT, 0.5));
const lvl = fieldTechLevel(rules, state, e, field);
shell.add(scene.add.text(bx + 12, by + 8, `${fieldDef.name.toUpperCase()} · Lv ${lvl}`, {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));
// A static, non-interactive stand-in for the allocation slider — the real
// slider() widget always wires up its own drag zone, and it's shared by
// several other screens that must not be touched, so this is a plain
// dimmed track+fill rather than the real thing.
shell.add(scene.add.text(bx + 14, by + 42, 'Allocation (locked)', {
fontFamily: FONT, fontSize: '17px', color: '#5a708c',
}));
const trackY = by + 70;
shell.add(scene.add.rectangle(bx + 14, trackY, boxW - 28, 8, 0x1b2b42, 0.6).setOrigin(0, 0.5));
shell.add(scene.add.rectangle(bx + 14, trackY, (boxW - 28) * (emp.alloc[field] ?? 0), 8, ACCENT, 0.35)
.setOrigin(0, 0.5));
shell.add(scene.add.text(bx + 14, by + 96, 'Select your next technology below.', {
fontFamily: FONT, fontSize: '14px', color: '#9fb6cc', wordWrap: { width: boxW - 28 },
}));
// --- remaining top-right space: the notification -----------------------
const noteX = bx + boxW + GAP;
const noteW = rightX + rightW - noteX;
shell.add(scene.add.text(noteX, by + 8, 'RESEARCH COMPLETE', {
fontFamily: FONT, fontSize: '22px', color: GOLD,
}));
shell.add(scene.add.text(noteX, by + 44,
`${completedTech.name} is finished. Choose your next ${fieldDef.name} technology `
+ 'from the highlighted options below.', {
fontFamily: FONT, fontSize: '16px', color: '#cfe8ff', wordWrap: { width: noteW }, lineSpacing: 4,
}));
// --- bottom-right: the tree ---------------------------------------------
shell.add(scene.add.text(detailX, headingY, `${fieldDef.name.toUpperCase()} — TECH TREE`, {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));
const bg = scene.add.rectangle(detailX + detailW / 2, detailY + detailH / 2, detailW, detailH, 0x000000, 0.35)
.setStrokeStyle(1, ACCENT, 0.3);
shell.add(bg);
const tree = scene.add.container(0, 0);
shell.add(tree);
const maskG = scene.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillRect(detailX, detailY, detailW, detailH);
tree.setMask(maskG.createGeometryMask());
const PAD = 28;
const LABEL_H = 20;
const NODE = 34;
const LANE_STEP = 78;
const innerX0 = detailX + PAD;
const innerY0 = detailY + PAD;
const spineY = innerY0 + LABEL_H + (detailH - 2 * PAD - LABEL_H) / 2;
const usableX0 = innerX0 + NODE / 2;
const usableX1 = detailX + detailW - PAD - NODE / 2;
const rungs = rules.techRungsByField[field];
const colStep = (usableX1 - usableX0) / Math.max(1, rungs.length - 1);
const nodeX = (i) => usableX0 + i * colStep;
const nodeY = (lane) => {
if (lane === 0) return spineY;
const sign = lane % 2 === 1 ? -1 : 1;
return spineY + sign * Math.ceil(lane / 2) * LANE_STEP;
};
const pos = new Map(); // techId -> {x, y}
rungs.forEach((rung, i) => {
rung.techs.forEach((tech, lane) => pos.set(tech.id, { x: nodeX(i), y: nodeY(lane) }));
});
rungs.forEach((rung, i) => {
tree.add(scene.add.text(nodeX(i), innerY0, `T${rung.tier}`, {
fontFamily: FONT, fontSize: '12px', color: '#5a708c',
}).setOrigin(0.5, 0));
});
// No isCurrent/yellow here — nothing is chosen yet in this screen.
const statusColour = (tech) => {
if (emp.known[tech.id]) return '#7fd8a0';
return emp.available[tech.id] ? '#9fb6cc' : '#5a4450';
};
const edges = scene.add.graphics();
tree.add(edges);
for (const tech of rules.techsByField[field]) {
const prereqId = tech.prereqs[0];
if (!prereqId) continue;
const from = pos.get(prereqId);
const to = pos.get(tech.id);
const colourInt = Phaser.Display.Color.HexStringToColor(statusColour(tech)).color;
edges.lineStyle(2, colourInt, 0.55);
edges.lineBetween(from.x, from.y, to.x, to.y);
}
const choiceIds = new Set(choices.map((t) => t.id));
const candidateNodes = new Map(); // techId -> { badge, mark, ring }
for (const tech of rules.techsByField[field]) {
const known = !!emp.known[tech.id];
const colour = statusColour(tech);
const mark = known ? '■' : (emp.available[tech.id] ? '□' : '✕');
const { x, y } = pos.get(tech.id);
const badge = scene.add.rectangle(x, y, NODE, NODE, 0x0b1220, 0.9)
.setStrokeStyle(1.5, Phaser.Display.Color.HexStringToColor(colour).color, 0.7);
tree.add(badge);
const markText = scene.add.text(x, y, mark, { fontFamily: FONT, fontSize: '20px', color: colour }).setOrigin(0.5);
tree.add(markText);
tooltip.attachTo(badge.setInteractive(), () => describeTechTooltip(rules, state, emp, tech));
if (choiceIds.has(tech.id)) {
const ring = buildRotatingRing(scene, x, y, NODE / 2 + 10, ACCENT);
tree.add(ring);
candidateNodes.set(tech.id, { badge, markText, ring });
}
}
// --- selection: shrink every ring, turn the chosen one yellow, persist,
// then close once every ring has finished vanishing.
let resolved = false;
let remaining = candidateNodes.size;
const finishAndClose = () => {
tooltip.destroy();
shell.destroy();
onClose?.();
};
for (const [techId, nodes] of candidateNodes) {
nodes.badge.setInteractive({ useHandCursor: true });
nodes.badge.on('pointerup', () => {
if (resolved) return;
resolved = true;
setResearchTarget(rules, state, e, field, techId);
tooltip.hide();
for (const [otherId, otherNodes] of candidateNodes) {
if (otherId === techId) {
otherNodes.badge.setStrokeStyle(2.5, GOLD_INT, 1);
otherNodes.markText.setColor(GOLD);
}
otherNodes.badge.disableInteractive();
scene.tweens.add({
targets: otherNodes.ring, scale: 0, alpha: 0, duration: 600, ease: 'Cubic.easeIn',
onComplete: () => {
otherNodes.ring.destroy();
remaining -= 1;
if (remaining === 0) scene.time.delayedCall(150, finishAndClose);
},
});
}
});
}
return shell;
}

View File

@ -18,7 +18,7 @@ import * as Phaser from 'phaser';
import { FONT, D, modalShell, slider } from './VegaScreens.js';
import { techCost, techCostFactor } from './VegaRules.js';
import {
setResearchAlloc, nextResearchTarget, setResearchTarget, canResearch,
setResearchAlloc, nextResearchTarget, setResearchTarget, canResearch, fieldTechLevel,
} from './VegaLogic.js';
import {
makeSpeciesPortrait, sourceWidth, speciesResearchVideoKey, hasSpeciesResearchVideo,
@ -109,7 +109,7 @@ function ensureOneResearchVideo(scene, speciesId, onDone) {
// centred inside the taller 2:3 frame until a real research clip loads, then
// the clip itself scaled the same way.
function buildVideoPanel(scene, rules, art, speciesId, x, y, w, h) {
export function buildVideoPanel(scene, rules, art, speciesId, x, y, w, h) {
const container = scene.add.container(x, y);
function showFallback() {
@ -148,7 +148,10 @@ function buildVideoPanel(scene, rules, art, speciesId, x, y, w, h) {
// --------------------------------------------------------------------------
export function openResearchScreen(scene, rules, state, e, art, onClose) {
// `initialField`: which category the screen opens on, e.g. the "View
// Research" button on a turn report's Research row jumps straight to the
// field that just completed instead of always landing on the first one.
export function openResearchScreen(scene, rules, state, e, art, onClose, { initialField = null } = {}) {
const emp = state.empires[e];
// A game saved before the research-lock padlock existed has no
// allocLocked at all — VegaLogic.deserialize() back-fills this for the
@ -184,7 +187,7 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
shell.add(buildVideoPanel(scene, rules, art, emp.speciesId, shell.body.x, shell.body.y, videoW, videoH));
let selectedField = fields[0].id;
let selectedField = (initialField && rules.techFields[initialField]) ? initialField : fields[0].id;
let gridLayer = null;
let detailLayer = null;
let maskG = null;
@ -213,7 +216,8 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
});
gridLayer.add(bg);
gridLayer.add(scene.add.text(bx + 12, by + 8, field.name.toUpperCase(), {
const lvl = fieldTechLevel(rules, state, e, field.id);
gridLayer.add(scene.add.text(bx + 12, by + 8, `${field.name.toUpperCase()} · Lv ${lvl}`, {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));
@ -291,10 +295,15 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
fields.forEach((field, i) => buildCategoryBox(field, i));
}
// --- detail pane: the selected field's full tech ladder, much larger
// font than the old screen's 14px columns, masked+scrollable since ten
// techs at this size can run past the pane's height. Mirrors
// VegaAudience.js's chat-log masked-scroll shape.
// --- detail pane: the selected field's tech TREE — a ten-tier spine with
// up to two short branch spurs (every branch tech added this session is a
// dead end, never reconverging — confirmed against the data), rendered as
// small status badges connected by prereq lines rather than a text list,
// so the shape of the field is visible at a glance. Full name/cost/desc
// stays in the hover tooltip, unchanged. Masked+scrollable defensively
// (mirrors VegaAudience.js's chat-log masked-scroll shape) for a future
// field that outgrows 10 tiers or a 3+-way branch — today's data always
// fits the pane with no scrolling needed.
function buildDetail() {
detailLayer?.destroy();
maskG?.destroy();
@ -302,7 +311,8 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
shell.add(detailLayer);
const field = rules.techFields[selectedField];
detailLayer.add(scene.add.text(detailX, headingY, `${field.name.toUpperCase()} — RESEARCH LADDER`, {
const detailLvl = fieldTechLevel(rules, state, e, selectedField);
detailLayer.add(scene.add.text(detailX, headingY, `${field.name.toUpperCase()} — TECH TREE (Level ${detailLvl})`, {
fontFamily: FONT, fontSize: '18px', color: '#cfe8ff',
}));
@ -310,51 +320,99 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
.setStrokeStyle(1, ACCENT, 0.3).setInteractive();
detailLayer.add(bg);
const ladder = scene.add.container(0, 0);
detailLayer.add(ladder);
const tree = scene.add.container(0, 0);
detailLayer.add(tree);
maskG = scene.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillRect(detailX, detailY, detailW, detailH);
ladder.setMask(maskG.createGeometryMask());
tree.setMask(maskG.createGeometryMask());
let rowY = detailY + 16;
let scrollUp = 0;
let overflow = 0;
const applyScroll = () => {
overflow = Math.max(0, rowY - (detailY + detailH - 16));
scrollUp = Phaser.Math.Clamp(scrollUp, 0, overflow);
ladder.y = -overflow + scrollUp;
// --- layout: rung index -> column x, lane within a rung -> row y.
const PAD = 28;
const LABEL_H = 20;
const NODE = 34;
const LANE_STEP = 78;
const innerX0 = detailX + PAD;
const innerX1 = detailX + detailW - PAD;
const innerY0 = detailY + PAD;
const innerY1 = detailY + detailH - PAD;
const spineY = innerY0 + LABEL_H + (detailH - 2 * PAD - LABEL_H) / 2;
// Node CENTRES are placed NODE/2 in from innerX0/innerX1, so the node's
// own edge lands on the padding boundary instead of overshooting past it
// — colStep spans the gap between node edges, not between the raw pane
// edges.
const usableX0 = innerX0 + NODE / 2;
const usableX1 = innerX1 - NODE / 2;
const rungs = rules.techRungsByField[selectedField];
const colStep = (usableX1 - usableX0) / Math.max(1, rungs.length - 1);
const nodeX = (i) => usableX0 + i * colStep;
const nodeY = (lane) => {
if (lane === 0) return spineY;
const sign = lane % 2 === 1 ? -1 : 1;
return spineY + sign * Math.ceil(lane / 2) * LANE_STEP;
};
bg.on('wheel', (pointer, dx, dy) => { scrollUp -= dy * 0.5; applyScroll(); });
const pos = new Map(); // techId -> {x, y}
rungs.forEach((rung, i) => {
rung.techs.forEach((tech, lane) => pos.set(tech.id, { x: nodeX(i), y: nodeY(lane) }));
});
rungs.forEach((rung, i) => {
tree.add(scene.add.text(nodeX(i), innerY0, `T${rung.tier}`, {
fontFamily: FONT, fontSize: '12px', color: '#5a708c',
}).setOrigin(0.5, 0));
});
// The field's current research target — same lookup the category box
// uses for its own progress bar — gets the same yellow as that bar, and
// the list opens scrolled to bring it as close to the pane's centre as
// the ladder's length allows.
// uses for its own progress bar — gets the same yellow as that bar.
const targetId = emp.researching[selectedField] ?? nextResearchTarget(rules, state, e, selectedField);
let targetRowTop = null;
let targetRowHeight = 0;
const statusColour = (tech) => {
if (tech.id === targetId) return '#ffd88a';
if (emp.known[tech.id]) return '#7fd8a0';
return emp.available[tech.id] ? '#9fb6cc' : '#5a4450';
};
// Edges first, so the badge nodes render on top of the lines feeding them.
const edges = scene.add.graphics();
tree.add(edges);
for (const tech of rules.techsByField[selectedField]) {
const prereqId = tech.prereqs[0];
if (!prereqId) continue;
const from = pos.get(prereqId);
const to = pos.get(tech.id);
const isCurrent = tech.id === targetId;
const colourInt = Phaser.Display.Color.HexStringToColor(statusColour(tech)).color;
edges.lineStyle(isCurrent ? 3 : 2, colourInt, isCurrent ? 1 : 0.55);
edges.lineBetween(from.x, from.y, to.x, to.y);
}
let maxRight = innerX0;
let maxBottom = innerY0;
let minTop = innerY0;
for (const tech of rules.techsByField[selectedField]) {
const known = !!emp.known[tech.id];
const avail = !!emp.available[tech.id];
const isCurrent = tech.id === targetId;
// A branched rung can have more than one open (□) tech at once — this
// is what makes it clickable: canResearch is the same gate
// setResearchTarget itself checks, so "clickable" and "would actually
// succeed" never disagree.
const selectable = !known && canResearch(rules, state, e, tech);
const colour = isCurrent ? '#ffd88a' : (known ? '#7fd8a0' : (avail ? '#9fb6cc' : '#5a4450'));
const mark = known ? '■' : (avail ? '□' : '✕');
const row = scene.add.text(detailX + 16, rowY, `${mark} ${tech.name}`, {
fontFamily: FONT, fontSize: '26px', color: colour, wordWrap: { width: detailW - 32 },
}).setInteractive({ useHandCursor: selectable });
ladder.add(row);
tooltip.attachTo(row, () => describeTechTooltip(rules, state, emp, tech));
const colour = statusColour(tech);
const mark = known ? '■' : (emp.available[tech.id] ? '□' : '✕');
const { x, y } = pos.get(tech.id);
const isCurrent = tech.id === targetId;
const badge = scene.add.rectangle(x, y, NODE, NODE, 0x0b1220, 0.9)
.setStrokeStyle(isCurrent ? 2.5 : 1.5, Phaser.Display.Color.HexStringToColor(colour).color, isCurrent ? 1 : 0.7)
.setInteractive({ useHandCursor: selectable });
tree.add(badge);
tree.add(scene.add.text(x, y, mark, { fontFamily: FONT, fontSize: '20px', color: colour }).setOrigin(0.5));
tooltip.attachTo(badge, () => describeTechTooltip(rules, state, emp, tech));
if (selectable) {
row.on('pointerup', () => {
badge.on('pointerup', () => {
if (!setResearchTarget(rules, state, e, selectedField, tech.id)) return;
// The row this listener is on is about to be destroyed by the
// The badge this listener is on is about to be destroyed by the
// rebuild below — Tooltip only clears itself on pointerout, which
// never fires for a destroyed object, so hide it explicitly first.
tooltip.hide();
@ -362,19 +420,36 @@ export function openResearchScreen(scene, rules, state, e, art, onClose) {
buildDetail();
});
}
if (isCurrent) { targetRowTop = rowY; targetRowHeight = row.height; }
rowY += row.height + 10;
maxRight = Math.max(maxRight, x + NODE / 2);
maxBottom = Math.max(maxBottom, y + NODE / 2);
minTop = Math.min(minTop, y - NODE / 2);
}
// First pass establishes `overflow` from the final rowY (and harmlessly
// clamps the still-default scrollUp); the second pass then aims for
// dead-centre on the target row, with applyScroll's own clamp pulling
// that back to the nearest reachable position when the target sits too
// close to either end of the list to actually reach centre.
// Defensive two-axis scroll fallback — a no-op against today's data
// (everything fits, confirmed by the pixel math above), kept so a field
// that later grows past 10 tiers or a 3+-way branch doesn't silently
// render off-pane. Mirrors this file's own overflow/clamp pattern.
let scrollX = 0;
let scrollY = 0;
let overflowX = 0;
let overflowY = 0;
const applyScroll = () => {
overflowX = Math.max(0, maxRight - innerX1);
overflowY = Math.max(0, innerY0 - minTop) + Math.max(0, maxBottom - innerY1);
scrollX = Phaser.Math.Clamp(scrollX, 0, overflowX);
scrollY = Phaser.Math.Clamp(scrollY, 0, overflowY);
tree.x = -scrollX;
tree.y = -scrollY;
};
bg.on('wheel', (pointer, dx, dy) => { scrollX += dx * 0.5; scrollY += dy * 0.5; applyScroll(); });
applyScroll();
scrollUp = targetRowTop !== null
? (detailY + detailH / 2 - (targetRowTop + targetRowHeight / 2)) + overflow
: overflow; // nothing in progress in this field — open at the top
if (targetId && pos.has(targetId)) {
const t = pos.get(targetId);
scrollX = Phaser.Math.Clamp(t.x - (usableX0 + usableX1) / 2, 0, overflowX);
scrollY = Phaser.Math.Clamp(t.y - spineY, 0, overflowY);
}
applyScroll();
}

View File

@ -17,6 +17,8 @@ export function compileRules(json) {
need(json.economy && typeof json.economy === 'object', 'economy block missing');
need(json.combat && typeof json.combat === 'object', 'combat block missing');
need(json.council && typeof json.council === 'object', 'council block missing');
need(json.diplomacy && typeof json.diplomacy === 'object', 'diplomacy block missing');
need(json.leaderHiring && typeof json.leaderHiring === 'object', 'leaderHiring block missing');
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
const byId = (list, label) => {
@ -258,6 +260,8 @@ export function compileRules(json) {
economy: json.economy,
combat: json.combat,
council: json.council,
diplomacy: json.diplomacy,
leaderHiring: json.leaderHiring,
victory: json.victory ?? { conquest: true, council: true, turnCap: 800 },
starNames: json.starNames,
};

View File

@ -7,7 +7,9 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { empireColonies, hireLeader } from './VegaLogic.js';
import {
empireColonies, empireFleets, hireLeader, assignLeader, unassignLeader, leaderHireCost,
} from './VegaLogic.js';
import {
attitudeOf, moodOf, powerOf, canNegotiate,
} from './VegaDiplomacy.js';
@ -38,7 +40,7 @@ const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
/** The holographic frame every modal is built inside. */
export function modalShell(scene, title, onClose, { width = 1220, height = 800 } = {}) {
export function modalShell(scene, title, onClose, { width = 1220, height = 800, closable = true } = {}) {
const layer = scene.add.container(0, 0).setDepth(D.modal);
const veil = scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000914, 0.72)
@ -72,11 +74,15 @@ export function modalShell(scene, title, onClose, { width = 1220, height = 800 }
const rule = scene.add.rectangle(x + 30, y + 64, width - 60, 1, ACCENT, 0.4).setOrigin(0, 0);
layer.add(rule);
const close = new Button(scene, x + width - 70, y + 40, '✕', () => {
layer.destroy();
onClose?.();
}, { width: 46, height: 40, variant: 'ghost' });
layer.add(close);
// Some screens (a forced choice prompt, say) opt out of the ✕ entirely —
// closable defaults true so every existing caller is unaffected.
if (closable) {
const close = new Button(scene, x + width - 70, y + 40, '✕', () => {
layer.destroy();
onClose?.();
}, { width: 46, height: 40, variant: 'ghost' });
layer.add(close);
}
return {
layer, x, y, width, height,
@ -248,6 +254,47 @@ export function openCouncilScreen(scene, rules, state, onClose) {
// --------------------------------------------------------------------------
/** "3× Cruiser, 2× Frigate (at Vega Prime)" / "(en route to Vega Prime)". */
function fleetLabel(rules, state, f) {
const ships = f.ships.filter((s) => s.count > 0)
.map((s) => `${s.count}× ${rules.hulls[s.hullId]?.name}`).join(', ') || 'empty fleet';
const where = f.starIdx >= 0
? `at ${state.galaxy.stars[f.starIdx]?.name}`
: `en route to ${state.galaxy.stars[f.toStar]?.name ?? '?'}`;
return `${ships} (${where})`;
}
/** A vertical list picker overlaid on `shell` same mechanism as
* VegaAudience.js's pickFromList, generalised to any {label, onPick} list
* since this screen has no existing picker of its own. */
function openListPicker(scene, shell, title, items) {
const bx = GAME_WIDTH / 2;
const by = GAME_HEIGHT / 2;
const box = scene.add.container(0, 0);
shell.add(box);
const h = Math.min(520, 100 + Math.max(items.length, 1) * 34);
box.add(scene.add.rectangle(bx, by, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001).setOrigin(0.5).setInteractive());
box.add(scene.add.rectangle(bx, by, 460, h, PANEL, 0.98).setStrokeStyle(2, ACCENT));
box.add(scene.add.text(bx, by - h / 2 + 26, title, { fontFamily: FONT, fontSize: '19px', color: '#ffd88a' }).setOrigin(0.5));
if (!items.length) {
box.add(scene.add.text(bx, by, 'Nothing available.', {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
}).setOrigin(0.5));
}
items.slice(0, 12).forEach(([label, fn], i) => {
const t = scene.add.text(bx, by - h / 2 + 66 + i * 34, label, {
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
t.on('pointerover', () => t.setColor('#ffd88a'));
t.on('pointerout', () => t.setColor('#e8f4ff'));
t.on('pointerdown', () => { box.destroy(true); fn(); });
box.add(t);
});
const cancel = new Button(scene, bx, by + h / 2 - 34, 'Cancel', () => box.destroy(true),
{ width: 160, height: 40, fontSize: 16, variant: 'ghost' });
box.add(cancel);
}
export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged) {
const shell = modalShell(scene, 'Leaders', onClose, { width: 1180, height: 760 });
const emp = state.empires[e];
@ -257,7 +304,8 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged
}));
let y = shell.body.y + 34;
for (const offer of leaderOffers(rules, state, e)) {
const affordable = emp.bc >= offer.hireCost;
const cost = leaderHireCost(rules, state, e, offer.id);
const affordable = emp.bc >= cost;
shell.add(scene.add.image(shell.body.x + 32, y + 30, art.leaders, offer.portraitFrame)
.setDisplaySize(60, 60));
shell.add(scene.add.text(shell.body.x + 76, y + 4, `${offer.name}${offer.kind}`, {
@ -271,7 +319,7 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged
fontFamily: FONT, fontSize: '13px', color: '#7fd8a0',
}));
const b = new Button(scene, shell.body.x + shell.body.w - 130, y + 30,
`Hire ${offer.hireCost} BC`, () => {
`Hire ${cost} BC`, () => {
if (hireLeader(rules, state, e, offer.id)) {
shell.destroy();
openLeaderScreen(scene, rules, state, e, art, onClose, onChanged);
@ -294,18 +342,50 @@ export function openLeaderScreen(scene, rules, state, e, art, onClose, onChanged
}
for (const l of emp.leaders) {
const def = rules.leaders[l.leaderId];
const posting = l.assignKind === 'colony'
? (empireColonies(state, e).find((c) => c.id === l.assignId)?.starIdx ?? -1)
: -1;
const where = l.assignKind === 'colony' && posting >= 0
? state.galaxy.stars[posting].name
: (l.assignKind === 'fleet' ? `Fleet ${l.assignId}` : 'unassigned');
let where = 'unassigned';
if (l.assignKind === 'colony') {
const c = empireColonies(state, e).find((c2) => c2.id === l.assignId);
where = c ? `${c.name} (${state.galaxy.stars[c.starIdx]?.name})` : 'unassigned';
} else if (l.assignKind === 'fleet') {
const f = empireFleets(state, e).find((f2) => f2.id === l.assignId);
where = f ? fleetLabel(rules, state, f) : 'unassigned';
}
shell.add(scene.add.image(shell.body.x + 24, y + 18, art.leaders, def.portraitFrame)
.setDisplaySize(40, 40));
shell.add(scene.add.text(shell.body.x + 56, y + 6,
`${def.name}${def.kind}${where}${def.upkeep} BC/turn`, {
fontFamily: FONT, fontSize: '16px', color: '#c8dcf0',
wordWrap: { width: shell.body.w - 320 },
}));
// Posting picker: admins list colonies, captains list fleets. Re-opens
// the whole screen on pick/unassign, same refresh pattern the Hire
// button already uses above.
const refresh = () => {
shell.destroy();
openLeaderScreen(scene, rules, state, e, art, onClose, onChanged);
onChanged?.();
};
const openPicker = () => {
const targets = def.kind === 'admin'
? empireColonies(state, e).map((c) => [
`${c.name} (${state.galaxy.stars[c.starIdx]?.name})`,
() => { assignLeader(rules, state, e, l.leaderId, 'colony', c.id); refresh(); },
])
: empireFleets(state, e).filter((f) => f.starIdx >= 0 || f.toStar >= 0).map((f) => [
fleetLabel(rules, state, f),
() => { assignLeader(rules, state, e, l.leaderId, 'fleet', f.id); refresh(); },
]);
openListPicker(scene, shell, def.kind === 'admin' ? 'POST TO WHICH COLONY?' : 'POST TO WHICH FLEET?', targets);
};
shell.add(new Button(scene, shell.body.x + shell.body.w - 110, y + 20,
l.assignKind ? 'Reassign' : 'Assign', openPicker, { width: 118, height: 32, fontSize: 14 }));
if (l.assignKind) {
shell.add(new Button(scene, shell.body.x + shell.body.w - 250, y + 20, 'Unassign', () => {
unassignLeader(rules, state, e, l.leaderId);
refresh();
}, { width: 118, height: 32, fontSize: 14, variant: 'ghost' }));
}
y += 44;
}
return shell;

View File

@ -21,6 +21,7 @@ import {
} from './VegaLogic.js';
import { starFrame } from './VegaArt.js';
import { buildZoomLadder, DEFAULT_ZOOM_INDEX } from './VegaZoom.js';
import { delaunayTriangulate, triangulationEdges } from './VegaDelaunay.js';
import { describeStarTooltip } from './VegaTooltips.js';
import { ORBIT } from './VegaScreens.js';
@ -38,6 +39,27 @@ const COLONY_INFO_GROUP_GAP = 20;
const COLONY_INFO_MIN_BAR_W = 80;
const COLONY_INFO_TEXT_SIZE = 26;
// Docked-fleet marker fan: index 0 keeps the original single-fleet anchor
// (up-right of the star). Later indices zigzag further up-right so several
// fleets parked at one star (different empires, or a garrison split from
// the mobile stack) never sit exactly on top of one another and each stays
// its own independently clickable hit target.
const FLEET_STAGGER_DX = 26;
const FLEET_STAGGER_DY0 = -22;
const FLEET_STAGGER_ZIGZAG = 16;
const FLEET_STAGGER_STEP = 28;
// Extra pan room beyond the galaxy's own edge, in screen pixels, so any star
// can be dragged clear of screen-space chrome that docks over the map —
// chiefly the 400px-wide right-hand command panel (VegaSidePanel.js) — at
// every zoom level, including the fully-zoomed-out one where the map exactly
// fills the viewport and previously had zero pan room at all.
const PAN_SLACK = 420;
// Range-darkness corridors between the player's own colonies (see
// redrawRange) use a band slightly narrower than the per-star clear discs.
const RANGE_CORRIDOR_FACTOR = 0.8;
// The range and territory fields are painted into low-resolution
// RenderTextures and scaled up. A huge galaxy is 5400px wide — past the safe
// single-texture size — and the upscale blur is exactly the soft edge both
@ -110,7 +132,15 @@ export default class VegaStarMap {
this.laneGfx = scene.add.graphics();
this.root.add(this.laneGfx);
this.rangeRT = scene.add.renderTexture(0, 0, this.fieldW, this.fieldH)
// Range darkness has to reach past the galaxy's own edge to cover
// PAN_SLACK too, or panning into that slack would reveal bare starfield
// instead of the same dark fill. Sized for the worst case — the bottom
// zoom rung, where a screen pixel of slack costs the most world pixels —
// so every other rung ends up with a little extra coverage, never a gap.
this.rangeMargin = Math.ceil(PAN_SLACK / this.zooms[0]);
const rangeFieldW = this.fieldW + Math.ceil((this.rangeMargin * 2) / FIELD_DIV);
const rangeFieldH = this.fieldH + Math.ceil((this.rangeMargin * 2) / FIELD_DIV);
this.rangeRT = scene.add.renderTexture(-this.rangeMargin, -this.rangeMargin, rangeFieldW, rangeFieldH)
.setOrigin(0, 0).setScale(FIELD_DIV);
this.root.add(this.rangeRT);
@ -265,15 +295,72 @@ export default class VegaStarMap {
const emp = this.state.empires[this.viewerIdx];
if (!emp) return;
// One soft disc per explored star — sized to clear just that star's own
// neighbourhood, not to bridge gaps into a "sphere of influence" the way
// the old fuel-range version did (that would re-imply territory the
// player hasn't actually scouted).
// neighbourhood, not to bridge gaps into a blanket "sphere of influence"
// the way the old fuel-range version did (that would re-imply territory
// the player hasn't actually scouted). The one deliberate exception is
// below: corridors between the player's OWN colonies specifically.
const radius = PARSEC_PX * 1.1;
const img = this.stamp((radius * 2) / 256 / FIELD_DIV);
for (const star of this.state.galaxy.stars) {
if (!emp.explored[star.idx]) continue;
rt.erase(img, star.x / FIELD_DIV, star.y / FIELD_DIV);
// Stamp coordinates are local to the RT's own buffer, whose origin sits
// rangeMargin before world (0,0) now — offset to compensate.
rt.erase(img, (star.x + this.rangeMargin) / FIELD_DIV, (star.y + this.rangeMargin) / FIELD_DIV);
}
// "Controlled space": a Delaunay triangulation of the player's own
// colonized stars ONLY (never AI, never mere exploration) clears a
// corridor between geometric neighbors, plus the interior of any
// resulting triangular face. Recomputed from scratch every redraw off
// current colony ownership, so a colony that's lost simply drops out —
// its corridors and any triangles through it vanish with it, with no
// separate bookkeeping needed.
const colonized = [...new Set(empireColonies(this.state, emp.idx).map((c) => c.starIdx))]
.map((idx) => this.state.galaxy.stars[idx]);
if (colonized.length === 2) {
this.stampRangeCorridor(rt, colonized[0], colonized[1], radius);
} else if (colonized.length >= 3) {
const tris = delaunayTriangulate(colonized.map((s) => ({ x: s.x, y: s.y })));
for (const [ai, bi, ci] of tris) {
this.fillRangeTriangle(rt, colonized[ai], colonized[bi], colonized[ci]);
}
for (const [ai, bi] of triangulationEdges(tris)) {
this.stampRangeCorridor(rt, colonized[ai], colonized[bi], radius);
}
}
}
/** Soft-edged erased band between two colonies, at RANGE_CORRIDOR_FACTOR
* of the star discs' own radius a brush stroke of overlapping stamps of
* the same soft-disc image the star discs use, for a consistent look. */
stampRangeCorridor(rt, a, b, starRadius) {
const corridorRadius = starRadius * RANGE_CORRIDOR_FACTOR;
const img = this.stamp((corridorRadius * 2) / 256 / FIELD_DIV);
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
const steps = Math.max(1, Math.ceil(len / corridorRadius));
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const x = a.x + dx * t + this.rangeMargin;
const y = a.y + dy * t + this.rangeMargin;
rt.erase(img, x / FIELD_DIV, y / FIELD_DIV);
}
}
/** Hard-edged erased fill for one triangular face of the colony mesh. The
* overlapping soft corridor stamps along its own edges (drawn separately)
* feather the otherwise-sharp boundary. */
fillRangeTriangle(rt, a, b, c) {
if (!this._rangeTriGfx) this._rangeTriGfx = this.scene.make.graphics({ add: false });
const g = this._rangeTriGfx.clear().fillStyle(0xffffff, 1);
const m = this.rangeMargin;
g.fillTriangle(
(a.x + m) / FIELD_DIV, (a.y + m) / FIELD_DIV,
(b.x + m) / FIELD_DIV, (b.y + m) / FIELD_DIV,
(c.x + m) / FIELD_DIV, (c.y + m) / FIELD_DIV,
);
rt.erase(g, 0, 0);
}
// Soft empire colour fields instead of hard borders.
@ -337,14 +424,18 @@ export default class VegaStarMap {
* Per-empire colony roster stacked up-left of a colonized star: a thick
* owner-colored bar over a "name (pop. N)" list, one group per empire that
* holds a colony at the star, each with its own line running to the star's
* center. Only shown at the closest zoom tier same density tier as the
* "N worlds · M habitable" subtext to keep the mid/far zoom map legible.
* center. Full text only at the closest zoom tier same density tier as
* the "N worlds · M habitable" subtext to keep the mid-zoom map legible;
* the two tiers below that still show the bar+line (ownership at a glance)
* with the text dropped, and the whole block disappears at the farthest
* tier.
*/
refreshColonyInfo() {
this.colonyInfoLayer.removeAll(true);
if (this.isFarZoom || !this.isNearZoom) return;
if (this.isFarZoom) return;
const { state } = this;
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
const showText = this.isNearZoom;
const textSize = Math.round(COLONY_INFO_TEXT_SIZE / Math.max(0.7, this.zoom));
for (const s of this.starSprites) {
@ -356,19 +447,19 @@ export default class VegaStarMap {
for (const emp of state.empires) {
const own = cols.filter((c) => c.empireIdx === emp.idx).sort((a, b) => a.orbit - b.orbit);
if (!own.length) continue;
const text = this.scene.add.text(0, 0, own.map((c) => (
const text = showText ? this.scene.add.text(0, 0, own.map((c) => (
`${c.name ?? `${s.star.name} ${ORBIT[c.orbit] ?? c.orbit + 1}`} (pop. ${c.pop.toFixed(1)})`
)).join('\n'), {
fontFamily: FONT, fontSize: `${textSize}px`, color: '#f0f4fa', lineSpacing: 2,
}).setOrigin(1, 0);
}).setOrigin(1, 0) : null;
groups.push({ emp, text });
}
if (!groups.length) continue;
const barWidth = Math.max(COLONY_INFO_MIN_BAR_W, ...groups.map((g) => g.text.width));
const barWidth = Math.max(COLONY_INFO_MIN_BAR_W, ...groups.map((g) => (g.text ? g.text.width : 0)));
const blockRightX = s.star.x - COLONY_INFO_GAP_X;
const totalHeight = groups.reduce((h, g) => (
h + COLONY_INFO_BAR_H + COLONY_INFO_BAR_TEXT_GAP + g.text.height
h + COLONY_INFO_BAR_H + (g.text ? COLONY_INFO_BAR_TEXT_GAP + g.text.height : 0)
), 0) + COLONY_INFO_GROUP_GAP * (groups.length - 1);
let y = s.star.y - COLONY_INFO_GAP_Y - totalHeight;
@ -383,10 +474,14 @@ export default class VegaStarMap {
lines.lineStyle(3, colour, 0.7);
lines.lineBetween(blockRightX, y + COLONY_INFO_BAR_H / 2, s.star.x, s.star.y);
g.text.setPosition(blockRightX, y + COLONY_INFO_BAR_H + COLONY_INFO_BAR_TEXT_GAP);
this.colonyInfoLayer.add(g.text);
let groupHeight = COLONY_INFO_BAR_H;
if (g.text) {
g.text.setPosition(blockRightX, y + COLONY_INFO_BAR_H + COLONY_INFO_BAR_TEXT_GAP);
this.colonyInfoLayer.add(g.text);
groupHeight += COLONY_INFO_BAR_TEXT_GAP + g.text.height;
}
y += COLONY_INFO_BAR_H + COLONY_INFO_BAR_TEXT_GAP + g.text.height + COLONY_INFO_GROUP_GAP;
y += groupHeight + COLONY_INFO_GROUP_GAP;
}
}
}
@ -397,6 +492,17 @@ export default class VegaStarMap {
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
this.fleetMarkers = [];
// Fleets docked at a star (starIdx >= 0) are grouped per star and sorted
// by empire so a given species' icon(s) land adjacent in the fan — see
// refreshFleets' position math below.
const dockedByStar = new Map();
for (const fleet of state.fleets) {
if (fleet.starIdx < 0) continue;
if (!dockedByStar.has(fleet.starIdx)) dockedByStar.set(fleet.starIdx, []);
dockedByStar.get(fleet.starIdx).push(fleet);
}
for (const group of dockedByStar.values()) group.sort((a, b) => a.empireIdx - b.empireIdx);
for (const fleet of state.fleets) {
const emp = state.empires[fleet.empireIdx];
if (!emp) continue;
@ -406,8 +512,9 @@ export default class VegaStarMap {
if (fleet.starIdx >= 0) {
const star = state.galaxy.stars[fleet.starIdx];
if (viewer && !own && !viewer.explored[fleet.starIdx]) continue;
x = star.x + 26;
y = star.y - 22;
const slot = dockedByStar.get(fleet.starIdx).indexOf(fleet);
x = star.x + FLEET_STAGGER_DX + (slot % 2 === 1 ? FLEET_STAGGER_ZIGZAG : 0);
y = star.y + FLEET_STAGGER_DY0 - slot * FLEET_STAGGER_STEP;
} else {
const a = state.galaxy.stars[fleet.fromStar];
const b = state.galaxy.stars[fleet.toStar];
@ -573,11 +680,13 @@ export default class VegaStarMap {
let ax;
let ay;
if (f.starIdx >= 0) {
const star = this.state.galaxy.stars[f.starIdx];
if (!star) return null;
// Match the fleet marker's own offset from the star it sits at.
ax = star.x + 26;
ay = star.y - 22;
// Read the marker's actual on-screen position rather than
// recomputing the offset — docked fleets are staggered per-star, so
// the offset depends on how many other fleets share that star.
const marker = this.fleetMarkers?.find((m) => m.fleet === f);
if (!marker) return null;
ax = marker.container.x;
ay = marker.container.y;
} else {
const from = this.state.galaxy.stars[f.fromStar];
if (!from) return null;
@ -710,20 +819,21 @@ export default class VegaStarMap {
// ---------------------------------------------------------------- camera
// Keep the viewport inside the galaxy. The old version allowed 160px of slack
// on every side, which let the player drag empty space into view even at a
// zoom that covered the screen — the same complaint as zooming out too far,
// just reached a different way.
// Keep the viewport close to the galaxy, with PAN_SLACK of give on every
// side. An earlier version clamped with zero slack, which was airtight but
// meant the map's edge sat pinned exactly at the screen edge at the bottom
// of the zoom ladder — right where the command panel docks — so an edge
// star there could never be dragged out from under the panel. PAN_SLACK
// fixes that while still bounding how much empty space beyond the galaxy
// can ever be revealed.
clampPan() {
const w = this.worldW * this.zoom;
const h = this.worldH * this.zoom;
// At the bottom of the ladder w/h equal the viewport, so both bounds
// collapse to a single value and the map sits locked and centred.
this.root.x = w >= GAME_WIDTH
? Phaser.Math.Clamp(this.root.x, GAME_WIDTH - w, 0)
this.root.x = w + PAN_SLACK * 2 >= GAME_WIDTH
? Phaser.Math.Clamp(this.root.x, GAME_WIDTH - w - PAN_SLACK, PAN_SLACK)
: (GAME_WIDTH - w) / 2;
this.root.y = h >= GAME_HEIGHT
? Phaser.Math.Clamp(this.root.y, GAME_HEIGHT - h, 0)
this.root.y = h + PAN_SLACK * 2 >= GAME_HEIGHT
? Phaser.Math.Clamp(this.root.y, GAME_HEIGHT - h - PAN_SLACK, PAN_SLACK)
: (GAME_HEIGHT - h) / 2;
}

View File

@ -18,7 +18,7 @@ import { describeTechEffects } from './VegaTechEffects.js';
// without taking the vignette out.
export const NOTABLE_TYPES = new Set([
'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', 'captured',
'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'council',
'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'tradeAgreementFormed', 'council',
'councilRefused', 'eliminated',
]);
@ -53,6 +53,7 @@ export const TYPE_LABEL = {
warDeclared: 'War Declared',
peace: 'Peace',
alliance: 'Alliance',
tradeAgreementFormed: 'Trade Agreement',
council: 'Galactic Council',
councilRefused: 'Council Refused',
eliminated: 'Empire Eliminated',
@ -62,7 +63,7 @@ export const TYPE_LABEL = {
// then discoveries, then research, then finished production, mirroring how
// the plan orders the list.
const CATEGORY_ORDER = {
contact: 0, warDeclared: 0, peace: 0, alliance: 0, council: 0, councilRefused: 0, eliminated: 0,
contact: 0, warDeclared: 0, peace: 0, alliance: 0, tradeAgreementFormed: 0, council: 0, councilRefused: 0, eliminated: 0,
captured: 1, colonyDestroyed: 1,
discovered: 2,
techDone: 3,
@ -169,7 +170,7 @@ function describeColonyDestroyed(rules, state, ev, name) {
let headline;
if (ev.target === me) headline = `The ${name(ev.empire)} have bombed ${star.name} out of existence — our colony is lost.`;
else if (ev.empire === me) headline = `We have bombed ${star.name} out of existence, destroying the ${name(ev.target)}'s colony.`;
else headline = `${star.name} has been bombed out of existence by the ${name(ev.empire)}.`;
else headline = `The ${name(ev.target)}'s colony ${star.name} has been bombed out of existence by the ${name(ev.empire)}.`;
return { headline, lines: [] };
}
@ -228,6 +229,7 @@ export function describeEvent(rules, state, ev) {
case 'warDeclared': out = describeTreaty(rules, state, ev, name, 'declare war on'); break;
case 'peace': out = describeTreaty(rules, state, ev, name, 'make peace with'); break;
case 'alliance': out = describeTreaty(rules, state, ev, name, 'form an alliance with'); break;
case 'tradeAgreementFormed': out = describeTreaty(rules, state, ev, name, 'form a trade agreement with'); break;
case 'council': out = describeCouncil(rules, state, ev, name); break;
case 'councilRefused': out = describeCouncilRefused(rules, state, ev, name); break;
case 'eliminated': out = describeEliminated(rules, state, ev, name); break;

View File

@ -11,6 +11,7 @@
import { modalShell, FONT } from './VegaScreens.js';
import { scrollColumn } from './VegaColonyView.js';
import { openSystemView } from './VegaSystemView.js';
import { openResearchScreen } from './VegaResearchScreen.js';
import { makeShipIcon, makeCommanderPortrait } from './VegaShipMedia.js';
import { openShipDetail } from './VegaShipDetail.js';
import { buildingFrame } from './VegaArt.js';
@ -235,6 +236,24 @@ export function openTurnReportScreen(scene, rules, state, events, onClose) {
col.content.add(viewBtn);
cy += 36;
}
// Same shortcut shape as View Star System above, for the one type it
// doesn't cover: a finished tech jumps straight to its own field on
// the Research screen. Also not via scene.openModal (same reasoning
// as the star-system button — modalOpen is already true), and hands
// through the same onClose so the turn-transition still completes
// exactly once, whichever button the player used to leave the report.
if (ev.type === 'techDone') {
const viewBtn = new Button(scene, w - 108, cy + 14, 'View Research', () => {
col.destroy();
shell.destroy();
openResearchScreen(scene, rules, state, ev.empire, scene.art, onClose, {
initialField: rules.techs[ev.techId].field,
});
}, { width: 168, height: 28, fontSize: 13 });
col.content.add(viewBtn);
cy += 36;
}
}
const bg = scene.add.rectangle(0, rowTop, w, cy - rowTop, ROW_BG, 0).setOrigin(0, 0)

View File

@ -192,42 +192,167 @@ section('1. Rules integrity');
!Logic.setResearchTarget(RULES, state, e, 'weapons', 'deathray'));
}
// The weapons T8 rung is 3-way (second branching pass) — the same
// rung-clearing logic already proven generic for 2-way rungs above must
// also hold for 3.
{
const emp = { known: {}, available: Object.fromEntries(RULES.techList.map((t) => [t.id, true])) };
const state = { empires: [emp] };
const e = 0;
for (const t of RULES.techsByField.weapons) if (t.tier < 8) emp.known[t.id] = true;
const rung8 = RULES.techRungsByField.weapons.find((r) => r.tier === 8);
check('the weapons T8 rung has three alternatives', rung8.techs.length === 3, `${rung8.techs.length}`);
check('all three T8 weapons are researchable before any is known',
rung8.techs.every((t) => Logic.canResearch(RULES, state, e, t)));
emp.known[rung8.techs[0].id] = true; // pick one of the three
const t9 = RULES.techsByField.weapons.find((t) => t.tier === 9);
check('the T8 rung clears after only ONE of three siblings is known', Logic.canResearch(RULES, state, e, t9));
check('the two un-picked T8 siblings stay researchable',
rung8.techs.slice(1).every((t) => Logic.canResearch(RULES, state, e, t)));
}
// fieldTechLevel (per-field level display): hand-computed against real
// computers tiers (electroniccomputer T0, battlescanner T1, optroniccomputer
// T2, securecommarray T1 sibling of battlescanner).
{
const emp = { known: {} };
const state = { empires: [emp] };
const e = 0;
check('an empire with nothing known is level 0 in every field',
Object.keys(RULES.techFields).every((f) => Logic.fieldTechLevel(RULES, state, e, f) === 0));
emp.known.electroniccomputer = true; // T0 freebie only
check('T0-only (the free opener) is level 0', Logic.fieldTechLevel(RULES, state, e, 'computers') === 0);
emp.known.battlescanner = true; // T1
emp.known.optroniccomputer = true; // T2, new frontier: knownTiers=[0,1,2], extra(<2)=2 -> 2*5+2=12
check('a clean T2 frontier is level 12', Logic.fieldTechLevel(RULES, state, e, 'computers') === 12,
`${Logic.fieldTechLevel(RULES, state, e, 'computers')}`);
emp.known.securecommarray = true; // T1 side-pick, sibling of battlescanner
// knownTiers=[0,1,1,2], highest=2, extra(<2)=3 -> 2*5+3=13
check('mopping up a T1 side-pick below the frontier adds exactly +1',
Logic.fieldTechLevel(RULES, state, e, 'computers') === 13,
`${Logic.fieldTechLevel(RULES, state, e, 'computers')}`);
}
// Corrected MOO1 availability roll: flat per-tech odds regardless of
// racial skill (skill affects cost only, techCostFactor above), 75% for
// Cerebrai, 50% for everyone else, and a branched rung can never end up
// with zero available techs. Statistical — one roll proves nothing.
//
// The "close to 50%/75%" claim is measured over UNBRANCHED (size-1) rungs
// only, deliberately — a branched rung's guaranteed-survivor pass only
// ever ADDS availability, never removes it, so the aggregate rate across
// the WHOLE tree drifts upward from the raw roll chance by an amount that
// scales with how much of the tree happens to be branched (61 of 85
// non-tier-0 techs sit in a branched rung as of the second branching
// pass, pulling the human aggregate to ~0.59 — a real, correct effect of
// the guarantee, not a bug). Measuring size-1 rungs isolates the actual
// unmodified flat roll, so this check stays accurate regardless of future
// branching density changes instead of needing re-tuning every time.
{
const TRIALS = 4000;
const rollRate = (spec) => {
let availableCount = 0;
let totalCount = 0;
let flatAvailableCount = 0;
let flatTotalCount = 0;
let everEmpty = false;
for (let seed = 1; seed <= TRIALS; seed += 1) {
const state = { rngState: (seed * 2654435761) | 0 };
const available = Logic.rollTechAvailability(state, RULES, {}, spec);
for (const field of Object.keys(RULES.techFields)) {
for (const rung of RULES.techRungsByField[field]) {
if (rung.techs.length > 1 && rung.techs.every((t) => !available[t.id])) everEmpty = true;
if (rung.tier === 0) continue;
if (rung.techs.length === 1) {
flatTotalCount += 1;
if (available[rung.techs[0].id]) flatAvailableCount += 1;
} else if (rung.techs.every((t) => !available[t.id])) {
everEmpty = true;
}
}
}
for (const t of RULES.techList) {
if (t.tier === 0) continue;
totalCount += 1;
if (available[t.id]) availableCount += 1;
}
}
return { rate: availableCount / totalCount, everEmpty };
return { rate: flatAvailableCount / flatTotalCount, everEmpty };
};
const human = rollRate(RULES.species.human);
check('human (flat 50%) empirical availability rate is close to 50%',
check('human (flat 50%) empirical availability rate on unbranched rungs is close to 50%',
human.rate > 0.45 && human.rate < 0.55, `${human.rate.toFixed(3)}`);
check('no branched rung is ever left with zero available techs (human roll)', !human.everEmpty);
const cerebrai = rollRate(RULES.species.cerebrai);
check("Cerebrai's (75%) empirical availability rate is close to 75% and above human's",
check("Cerebrai's (75%) empirical availability rate on unbranched rungs is close to 75% and above human's",
cerebrai.rate > 0.70 && cerebrai.rate < 0.80 && cerebrai.rate > human.rate, `${cerebrai.rate.toFixed(3)}`);
check('no branched rung is ever left with zero available techs (Cerebrai roll)', !cerebrai.everEmpty);
}
// openResearchChoices: the set of techs at a field's current frontier rung
// that are actually researchable — 0/1/2/3-way cases, using real data.
{
const emp = { known: {}, available: Object.fromEntries(RULES.techList.map((t) => [t.id, true])) };
const state = { empires: [emp] };
const e = 0;
check('a fresh empire has exactly 1 open choice at computers T0 (the free opener)',
Logic.openResearchChoices(RULES, state, e, 'computers').length === 1);
for (const t of RULES.techsByField.computers) if (t.tier < 3) emp.known[t.id] = true;
const twoWay = Logic.openResearchChoices(RULES, state, e, 'computers');
check('computers T3 (2-way) reports exactly 2 open choices', twoWay.length === 2, `${twoWay.length}`);
check('the 2-way choices are neuralscanner and tachyoncomputer',
new Set(twoWay.map((t) => t.id)).size === 2
&& twoWay.every((t) => ['neuralscanner', 'tachyoncomputer'].includes(t.id)));
emp.known.neuralscanner = true;
// The un-picked sibling is still the lowest open tier — it stays the
// sole (unambiguous) frontier pick even though T4 is also now
// independently canResearch-true; openResearchChoices only surfaces the
// minimum tier among candidates, so T4 isn't "seen" until tachyoncomputer
// itself resolves (known, in this case never force-required).
const oneLeft = Logic.openResearchChoices(RULES, state, e, 'computers');
check('the un-picked sibling remains the sole frontier choice after the other is known',
oneLeft.length === 1 && oneLeft[0].id === 'tachyoncomputer', `${oneLeft.map((t) => t.id)}`);
emp.known.tachyoncomputer = true;
const advanced = Logic.openResearchChoices(RULES, state, e, 'computers');
check('once both T3 siblings are known, T4 becomes the sole frontier choice',
advanced.length === 1 && advanced[0].id === 'positroniccomputer', `${advanced.map((t) => t.id)}`);
const emp2 = { known: {}, available: Object.fromEntries(RULES.techList.map((t) => [t.id, true])) };
const state2 = { empires: [emp2] };
for (const t of RULES.techsByField.weapons) if (t.tier < 8) emp2.known[t.id] = true;
const threeWay = Logic.openResearchChoices(RULES, state2, e, 'weapons');
check('the weapons T8 3-way rung reports exactly 3 open choices', threeWay.length === 3, `${threeWay.length}`);
const emp3 = { known: {}, available: {} };
const state3 = { empires: [emp3] };
check('a field with nothing researchable reports zero open choices',
Logic.openResearchChoices(RULES, state3, e, 'computers').length === 0);
}
// pendingResearchChoices' own filtering predicate (human-owned, source
// 'research', deduped by field, ambiguous rung only) — exercised directly
// since the real method lives on the Phaser scene, mirroring its exact
// filter/dedupe logic against a synthetic event list.
{
const emp = { known: {}, available: Object.fromEntries(RULES.techList.map((t) => [t.id, true])) };
for (const t of RULES.techsByField.computers) if (t.tier < 3) emp.known[t.id] = true;
const state = { empires: [emp, { known: {}, available: {} }] };
const humanIdx = 0;
const notable = [
{ type: 'techDone', empire: humanIdx, techId: 'optroniccomputer', source: 'research' }, // ambiguous field
{ type: 'techDone', empire: humanIdx, techId: 'optroniccomputer', source: 'research' }, // duplicate field, should dedupe
{ type: 'techDone', empire: humanIdx, techId: 'lasercannon', source: 'trade' }, // wrong source
{ type: 'techDone', empire: 1, techId: 'lasercannon', source: 'research' }, // wrong empire
{ type: 'contact', empire: humanIdx }, // wrong type
];
const pending = [];
const seen = new Set();
for (const ev of notable) {
if (ev.type !== 'techDone' || ev.empire !== humanIdx || ev.source !== 'research') continue;
const field = RULES.techs[ev.techId].field;
if (seen.has(field)) continue;
seen.add(field);
const choices = Logic.openResearchChoices(RULES, state, humanIdx, field);
if (choices.length > 1) pending.push({ field, choices, completedTechId: ev.techId });
}
check('pendingResearchChoices keeps exactly the one ambiguous human/research field',
pending.length === 1 && pending[0].field === 'computers', JSON.stringify(pending.map((p) => p.field)));
check('the surviving entry carries the completed tech id for the prompt copy',
pending[0].completedTechId === 'optroniccomputer');
}
// Species sanity: at least one clear strength each, and no species is
// strictly better than another on every axis.
for (const s of RULES.speciesList) {
@ -624,15 +749,30 @@ section('2. Procedural art');
}
// Soundtrack: every track the JSON names must be on disk, or the game boots
// into silence with a console error and nothing says why.
// into silence with a console error and nothing says why. Structure is
// per-category (VegaMusic.js's _poolFor reads d.fallback/d.menu/d.peace/
// d.combat/d.diplomacy.default/d.diplomacy.bySpecies[id], each a
// { volume?, tracks: [...] } pool) — not one flat top-level `tracks` array.
const music = JSON.parse(readFileSync(join(root, 'data/masterofvega-music.json'), 'utf8'));
check('soundtrack declares tracks', Array.isArray(music.tracks) && music.tracks.length > 0);
for (const t of music.tracks ?? []) {
check(`soundtrack file ${t.file} exists`, existsSync(join(root, 'assets/music', t.file)), t.file);
check(`soundtrack ${t.file} has artist and title`, !!t.artist && !!t.title);
}
if (typeof music.volume === 'number') {
check('soundtrack volume in range', music.volume > 0 && music.volume <= 1, `${music.volume}`);
const musicPools = [
['fallback', music.fallback], ['menu', music.menu], ['peace', music.peace], ['combat', music.combat],
['diplomacy.default', music.diplomacy?.default],
...Object.entries(music.diplomacy?.bySpecies ?? {}).map(([id, pool]) => [`diplomacy.bySpecies.${id}`, pool]),
];
check('soundtrack declares at least one track pool with tracks',
musicPools.some(([, pool]) => Array.isArray(pool?.tracks) && pool.tracks.length > 0));
// Every other pool falls back to this one when empty (_poolFor), so an
// empty fallback means the game can still boot into total silence.
check('soundtrack fallback pool has tracks',
Array.isArray(music.fallback?.tracks) && music.fallback.tracks.length > 0);
for (const [poolName, pool] of musicPools) {
for (const t of pool?.tracks ?? []) {
check(`soundtrack ${poolName} file ${t.file} exists`, existsSync(join(root, 'assets/music', t.file)), t.file);
check(`soundtrack ${poolName} ${t.file} has artist and title`, !!t.artist && !!t.title);
}
if (typeof pool?.volume === 'number') {
check(`soundtrack ${poolName} volume in range`, pool.volume > 0 && pool.volume <= 1, `${pool.volume}`);
}
}
}
@ -1042,7 +1182,7 @@ section('4c. Population transport');
const cap = Logic.maxSendablePopulation(source);
check('maxSendablePopulation leaves the floor behind', cap === before - 0.5);
const ok = Logic.sendPopulation(RULES, st, 0, home, target, cap);
const ok = Logic.sendPopulation(RULES, st, 0, source.id, second.id, cap);
check('sendPopulation succeeds for a reachable, owned destination', ok);
check('the source colony loses exactly what was sent', Math.abs(source.pop - 0.5) < 1e-9,
`${source.pop}`);
@ -1087,11 +1227,11 @@ section('4c. Population transport');
{
// Guardrails: a colony can never be fully emptied, and a request beyond
// what is available is clamped rather than refused outright.
const { st, home, target } = mkTwoColonyGame(4242);
const { st, home, target, second } = mkTwoColonyGame(4242);
if (target >= 0) {
const source = Logic.colonyAt(st, home);
const cap = Logic.maxSendablePopulation(source);
const ok = Logic.sendPopulation(RULES, st, 0, home, target, cap + 1000);
const ok = Logic.sendPopulation(RULES, st, 0, source.id, second.id, cap + 1000);
check('an oversized request is clamped to what the colony can spare', ok);
check('the source is left at exactly the floor', Math.abs(source.pop - 0.5) < 1e-9);
}
@ -1099,28 +1239,28 @@ section('4c. Population transport');
{
// Refusals: not your colony, same star, not your destination, nothing to spare.
const { st, home, target } = mkTwoColonyGame(4343);
const { st, home, target, second } = mkTwoColonyGame(4343);
if (target >= 0) {
const enemyHome = st.galaxy.homeIdx[1];
check('sending from a colony you do not own is refused',
Logic.sendPopulation(RULES, st, 0, enemyHome, target, 1) === false);
check('sending to a star with no colony of yours is refused',
Logic.sendPopulation(RULES, st, 0, home, st.galaxy.homeIdx[1], 1) === false);
check('sending to the same star is refused',
Logic.sendPopulation(RULES, st, 0, home, home, 1) === false);
const source = Logic.colonyAt(st, home);
const enemyColony = Logic.colonyAt(st, st.galaxy.homeIdx[1]);
check('sending from a colony you do not own is refused',
Logic.sendPopulation(RULES, st, 0, enemyColony.id, second.id, 1) === false);
check('sending to a star with no colony of yours is refused',
Logic.sendPopulation(RULES, st, 0, source.id, enemyColony.id, 1) === false);
check('sending to the same star is refused',
Logic.sendPopulation(RULES, st, 0, source.id, source.id, 1) === false);
source.pop = 0.5;
check('a colony already at the floor has nothing left to send',
Logic.sendPopulation(RULES, st, 0, home, target, 1) === false);
Logic.sendPopulation(RULES, st, 0, source.id, second.id, 1) === false);
}
}
{
// A poptransport in transit must never be swept up as invasion troops or
// combat power — it shares no hullId with 'transport' and has no weapons.
const { st, home, target } = mkTwoColonyGame(4444);
const { st, home, target, second } = mkTwoColonyGame(4444);
if (target >= 0) {
Logic.sendPopulation(RULES, st, 0, home, target, 1);
Logic.sendPopulation(RULES, st, 0, Logic.colonyAt(st, home).id, second.id, 1);
// Already in transit — see the note above about why this is
// state.fleets directly and not fleetsAt(home).
const fleet = st.fleets.find((f) => f.ships.some((s) => s.hullId === 'poptransport'));
@ -1715,6 +1855,312 @@ section('7. Diplomacy and the Galactic Council');
stD.events.find((e) => e.type === 'contact' && e.empire === 2).announced !== true);
}
// --- Gifts: BC moves giver -> receiver, attitude rises, a quick repeat is
// worth less (diminishing returns, not a hard cooldown), and an
// unaffordable gift is a safe no-op.
{
const stE = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', seed: 606, difficultyId: 'normal',
speciesIds: ['human', 'kestrelli'], humanIndex: -1,
});
stE.rules = RULES;
stE.empires[0].contacted[1] = true; stE.empires[1].contacted[0] = true;
stE.empires[0].bc = 500;
const bcBefore1 = stE.empires[1].bc;
const attBefore1 = stE.empires[1].attitude[0] ?? 0;
const tier0 = RULES.diplomacy.gift.tiers[0];
const ok1 = Diplo.giveGift(RULES, stE, 0, 1, tier0.id);
check('giveGift succeeds when affordable', ok1 === true);
check('giveGift moves BC from giver to receiver',
stE.empires[0].bc === 500 - tier0.bc && stE.empires[1].bc === bcBefore1 + tier0.bc);
check('giveGift raises the receiver\'s attitude toward the giver',
stE.empires[1].attitude[0] > attBefore1);
check('giveGift pushes a gift event',
stE.events.some((e) => e.type === 'gift' && e.empire === 0 && e.other === 1));
const attAfter1 = stE.empires[1].attitude[0];
Diplo.giveGift(RULES, stE, 0, 1, tier0.id);
const secondDelta = stE.empires[1].attitude[0] - attAfter1;
check('a repeat gift inside the cooldown window still helps, but less than the first did',
secondDelta > 0 && secondDelta < tier0.delta, `${secondDelta} vs ${tier0.delta}`);
stE.empires[0].bc = 1;
const bcBeforeFail = stE.empires[0].bc;
const failed = Diplo.giveGift(RULES, stE, 0, 1, RULES.diplomacy.gift.tiers.at(-1).id);
check('an unaffordable gift fails and changes nothing',
failed === false && stE.empires[0].bc === bcBeforeFail);
}
// --- Trade agreements: full propose/accept ceremony (mirrors alliance),
// symmetric formation, BC income isolated via two identical clones, and
// cancellation when war breaks out between the two parties.
{
const stF = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', seed: 707, difficultyId: 'normal',
speciesIds: ['human', 'kestrelli', 'ursaal'], humanIndex: 0,
});
stF.rules = RULES;
// AI-to-AI (1 -> 2): resolves immediately via proposeTreaty.
stF.empires[1].contacted[2] = true; stF.empires[2].contacted[1] = true;
stF.empires[2].attitude[1] = 50;
const formed = Diplo.proposeTreaty(RULES, stF, 1, 2, 'tradeAgreement');
check('an AI-to-AI trade-agreement proposal that clears the threshold succeeds', formed === true);
check('forming a trade agreement sets both sides symmetrically',
stF.empires[1].tradeAgreements[2] === true && stF.empires[2].tradeAgreements[1] === true);
check('forming a trade agreement pushes a tradeAgreementFormed event',
stF.events.some((e) => e.type === 'tradeAgreementFormed' && e.empire === 1 && e.other === 2));
check('forming a trade agreement raises attitude both ways',
stF.empires[1].attitude[2] > 0 && stF.empires[2].attitude[1] > 50);
// AI-to-human (1 -> 0): held, not resolved immediately — same
// proposeOrOffer/pendingOffers/respondToOffer machinery Alliance uses.
stF.empires[0].contacted[1] = true; stF.empires[1].contacted[0] = true;
const held = Diplo.proposeOrOffer(RULES, stF, 1, 0, 'tradeAgreement');
check('an AI trade-agreement proposal to the human is held, not resolved', held === 'pending');
check('a held trade-agreement offer does not touch the relationship yet',
!stF.empires[0].tradeAgreements[1]);
const accepted = Diplo.respondToOffer(RULES, stF, 0, 1, true);
check('accepting a held trade-agreement offer succeeds', accepted === true);
check('accepting applies the trade agreement both ways',
stF.empires[0].tradeAgreements[1] === true && stF.empires[1].tradeAgreements[0] === true);
// War cancels an active trade agreement.
Diplo.declareWar(RULES, stF, 0, 1);
check('declaring war clears an active trade agreement on both sides',
!stF.empires[0].tradeAgreements[1] && !stF.empires[1].tradeAgreements[0]);
check('war cancellation pushes a tradeAgreementEnded event',
stF.events.some((e) => e.type === 'tradeAgreementEnded' && e.reason === 'war'
&& [e.empire, e.other].includes(0) && [e.empire, e.other].includes(1)));
// BC income: two clones of the same state, differing ONLY in whether a
// trade agreement is active, isolates the income term from every other
// per-turn effect beginEmpireTurn also applies.
const baseState = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', seed: 808, difficultyId: 'normal',
speciesIds: ['human', 'kestrelli'], humanIndex: -1,
});
baseState.rules = RULES;
baseState.empires[0].contacted[1] = true; baseState.empires[1].contacted[0] = true;
Diplo.makePeace(RULES, baseState, 0, 1);
const savedBase = Logic.serialize(baseState);
const withoutTA = Logic.deserialize(savedBase);
withoutTA.rules = RULES;
const withTA = Logic.deserialize(savedBase);
withTA.rules = RULES;
withTA.empires[0].tradeAgreements[1] = true;
withTA.empires[1].tradeAgreements[0] = true;
const bcBeforeNo = withoutTA.empires[0].bc;
const bcBeforeYes = withTA.empires[0].bc;
Logic.beginEmpireTurn(RULES, withoutTA, 0);
Logic.beginEmpireTurn(RULES, withTA, 0);
check('an active trade agreement adds BC income beyond an identical empire without one',
(withTA.empires[0].bc - bcBeforeYes) > (withoutTA.empires[0].bc - bcBeforeNo));
}
// --- Border/fleet proximity tension: closer colonies/fleets lose more
// attitude than distant ones, and war/alliance pairs are untouched by
// these two passes specifically (driftAttitudes already covers war, and
// allies aren't meant to grind on each other's borders).
{
const stG = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'spiral', seed: 909, difficultyId: 'normal',
speciesIds: ['human', 'kestrelli', 'ursaal'], humanIndex: -1,
});
stG.rules = RULES;
stG.colonies.length = 0;
stG.fleets.length = 0;
stG.empires[0].contacted[1] = true; stG.empires[1].contacted[0] = true;
stG.empires[0].contacted[2] = true; stG.empires[2].contacted[0] = true;
const stars = stG.galaxy.stars;
let far = 0;
let maxD = -1;
for (let i = 1; i < stars.length; i += 1) {
const d = Math.hypot(stars[i].x - stars[0].x, stars[i].y - stars[0].y);
if (d > maxD) { maxD = d; far = i; }
}
const near = 0;
stG.colonies.push({
id: 9001, empireIdx: 0, starIdx: near, orbit: 0, pop: 5,
});
stG.colonies.push({
id: 9002, empireIdx: 1, starIdx: near, orbit: 1, pop: 5,
});
stG.colonies.push({
id: 9003, empireIdx: 2, starIdx: far, orbit: 0, pop: 5,
});
// Species carry non-zero starting attitude baselines — zero the pairs
// under test so a delta from this pass alone is unambiguous.
stG.empires[0].attitude[1] = 0; stG.empires[1].attitude[0] = 0;
stG.empires[0].attitude[2] = 0; stG.empires[2].attitude[0] = 0;
Diplo.applyBorderTension(RULES, stG);
check('colonies on the same star (minimum distance) lose attitude from border tension',
stG.empires[0].attitude[1] < 0 && stG.empires[1].attitude[0] < 0);
check('colonies far beyond the border range are untouched',
(stG.empires[0].attitude[2] ?? 0) === 0 && (stG.empires[2].attitude[0] ?? 0) === 0);
stG.empires[0].attitude[1] = 0; stG.empires[1].attitude[0] = 0;
stG.empires[0].treaties[1] = 'alliance'; stG.empires[1].treaties[0] = 'alliance';
Diplo.applyBorderTension(RULES, stG);
check('allied colonies at minimum distance are skipped by border tension',
stG.empires[0].attitude[1] === 0 && stG.empires[1].attitude[0] === 0);
stG.empires[0].treaties[1] = 'war'; stG.empires[1].treaties[0] = 'war';
Diplo.applyBorderTension(RULES, stG);
check('warring colonies at minimum distance are skipped by border tension (driftAttitudes covers war)',
stG.empires[0].attitude[1] === 0 && stG.empires[1].attitude[0] === 0);
stG.empires[0].treaties[1] = 'none'; stG.empires[1].treaties[0] = 'none';
stG.colonies.length = 0;
stG.fleets.push({
id: 9101, empireIdx: 0, starIdx: near, toStar: -1, fromStar: -1, ships: [{ hullId: 'scout', mark: 1, count: 1 }],
});
stG.fleets.push({
id: 9102, empireIdx: 1, starIdx: near, toStar: -1, fromStar: -1, ships: [{ hullId: 'scout', mark: 1, count: 1 }],
});
stG.fleets.push({
id: 9103, empireIdx: 2, starIdx: far, toStar: -1, fromStar: -1, ships: [{ hullId: 'scout', mark: 1, count: 1 }],
});
stG.empires[0].attitude[1] = 0; stG.empires[1].attitude[0] = 0;
stG.empires[0].attitude[2] = 0; stG.empires[2].attitude[0] = 0;
Diplo.applyFleetProximityTension(RULES, stG);
check('fleets sharing a star lose attitude from fleet proximity tension',
stG.empires[0].attitude[1] < 0 && stG.empires[1].attitude[0] < 0);
check('fleets far beyond the fleet range are untouched',
(stG.empires[0].attitude[2] ?? 0) === 0 && (stG.empires[2].attitude[0] ?? 0) === 0);
}
// --- Fleet intrusion: the arrival turn is free, penalties escalate the
// longer a foreign fleet dwells, leaving resets the tracker, allied and
// at-war fleets never accrue an entry, and claimFleetComplaints behaves
// exactly like claimAudienceContacts (capable-only, idempotent).
{
const stH = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', seed: 1010, difficultyId: 'normal',
speciesIds: ['human', 'kestrelli'], humanIndex: -1,
});
stH.rules = RULES;
stH.colonies.length = 0;
stH.fleets.length = 0;
const star = stH.galaxy.stars.length - 1;
stH.colonies.push({ id: 9201, empireIdx: 0, starIdx: star, orbit: 0, pop: 5 });
stH.fleets.push({
id: 9301, empireIdx: 1, starIdx: star, toStar: -1, fromStar: -1, ships: [{ hullId: 'scout', mark: 1, count: 1 }],
});
const attOnArrival = stH.empires[0].attitude[1] ?? 0;
stH.turn = 10;
Diplo.checkFleetIntrusions(RULES, stH);
check('a fleet on its arrival turn is free — no penalty, no event',
(stH.empires[0].attitude[1] ?? 0) === attOnArrival && !stH.events.some((e) => e.type === 'fleetComplaint'));
stH.turn = 11;
const attBeforeDwell1 = stH.empires[0].attitude[1] ?? 0;
Diplo.checkFleetIntrusions(RULES, stH);
const attAfterDwell1 = stH.empires[0].attitude[1];
check('past the grace period, a penalty applies', attAfterDwell1 < attBeforeDwell1);
check('the first violation pushes exactly one fleetComplaint event with first:true',
stH.events.filter((e) => e.type === 'fleetComplaint' && e.first === true).length === 1);
stH.turn = 12;
Diplo.checkFleetIntrusions(RULES, stH);
const attAfterDwell2 = stH.empires[0].attitude[1];
const mag1 = attBeforeDwell1 - attAfterDwell1;
const mag2 = attAfterDwell1 - attAfterDwell2;
check('the penalty escalates turn over turn while the fleet stays', mag2 > mag1, `${mag1} then ${mag2}`);
check('later turns of the same intrusion are not marked first',
stH.events.find((e) => e.type === 'fleetComplaint' && e.turn === 12)?.first === false);
stH.fleets.length = 0;
Diplo.checkFleetIntrusions(RULES, stH);
check('the fleet leaving clears the tracked intrusion entry',
Object.keys(stH.empires[0].fleetIntrusions[star] ?? {}).length === 0);
stH.fleets.push({
id: 9302, empireIdx: 1, starIdx: star, toStar: -1, fromStar: -1, ships: [{ hullId: 'scout', mark: 1, count: 1 }],
});
stH.turn = 13;
Diplo.checkFleetIntrusions(RULES, stH);
check('a later return restarts the grace period rather than resuming the old escalation',
stH.empires[0].fleetIntrusions[star]?.[1]?.sinceTurn === 13
&& (stH.empires[0].attitude[1] ?? 0) === attAfterDwell2); // dwell 0 on the new arrival — no fresh penalty yet
stH.empires[0].treaties[1] = 'alliance'; stH.empires[1].treaties[0] = 'alliance';
stH.empires[0].fleetIntrusions[star] = {};
stH.turn = 20;
Diplo.checkFleetIntrusions(RULES, stH);
check('an allied fleet never accrues an intrusion entry', !stH.empires[0].fleetIntrusions[star]?.[1]);
stH.empires[0].treaties[1] = 'war'; stH.empires[1].treaties[0] = 'war';
stH.turn = 21;
Diplo.checkFleetIntrusions(RULES, stH);
check('a fleet from an empire already at war never accrues an intrusion entry (a siege, not a faux-pas)',
!stH.empires[0].fleetIntrusions[star]?.[1]);
stH.empires[0].treaties[1] = 'none'; stH.empires[1].treaties[0] = 'none';
}
// --- claimFleetComplaints: mirrors claimAudienceContacts exactly.
{
const stI = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', seed: 1111, difficultyId: 'normal',
speciesIds: ['human', 'kestrelli', 'lithox'], humanIndex: 0,
});
stI.rules = RULES;
stI.empires[0].contacted[1] = true; stI.empires[1].contacted[0] = true;
stI.empires[0].contacted[2] = true; stI.empires[2].contacted[0] = true;
stI.events.push({ type: 'fleetComplaint', empire: 1, other: 0, starIdx: 5, dwell: 1, first: true, turn: stI.turn });
stI.events.push({ type: 'fleetComplaint', empire: 2, other: 0, starIdx: 6, dwell: 1, first: true, turn: stI.turn });
const claimed = Diplo.claimFleetComplaints(RULES, stI, 0);
check('claimFleetComplaints claims only the diplomacy-capable complaint',
claimed.length === 1 && claimed[0] === 1);
check('the claimed complaint event is marked announced',
stI.events.find((e) => e.type === 'fleetComplaint' && e.empire === 1).announced === true);
check('the lithox complaint is left unclaimed for the ordinary log line',
stI.events.find((e) => e.type === 'fleetComplaint' && e.empire === 2).announced !== true);
check('claiming is idempotent — a second call finds nothing new',
Diplo.claimFleetComplaints(RULES, stI, 0).length === 0);
}
// --- Attitude triangulation ("enemy of my enemy"): directional, clamped,
// and inert between empires already at war with each other.
{
const stJ = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', seed: 1212, difficultyId: 'normal',
speciesIds: ['human', 'kestrelli', 'ursaal'], humanIndex: -1,
});
stJ.rules = RULES;
for (const e of stJ.empires) for (const o of stJ.empires) if (e.idx !== o.idx) e.contacted[o.idx] = true;
Diplo.declareWar(RULES, stJ, 0, 1); // A(0) vs B(1)
Diplo.declareWar(RULES, stJ, 1, 2); // B(1) vs C(2)
const before = Diplo.attitudeOf(stJ, 2, 0);
Diplo.triangulateAttitudes(RULES, stJ);
const after = Diplo.attitudeOf(stJ, 2, 0);
check('a shared hostile third party nudges attitude upward (enemy of my enemy is my friend)',
after > before, `${before} -> ${after}`);
for (let i = 0; i < 300; i += 1) Diplo.triangulateAttitudes(RULES, stJ);
check('triangulation stays within [-100, 100] under repeated passes',
stJ.empires[2].attitude[0] >= -100 && stJ.empires[2].attitude[0] <= 100
&& Number.isFinite(stJ.empires[2].attitude[0]));
const beforeReverse = Diplo.attitudeOf(stJ, 0, 2);
Diplo.triangulateAttitudes(RULES, stJ);
check('triangulation is directional — the reverse pair is untouched by this function alone',
Diplo.attitudeOf(stJ, 0, 2) === beforeReverse);
check('no nudge between two empires already at war with each other', (() => {
Diplo.declareWar(RULES, stJ, 0, 2);
const b = Diplo.attitudeOf(stJ, 2, 0);
Diplo.triangulateAttitudes(RULES, stJ);
return Diplo.attitudeOf(stJ, 2, 0) === b;
})());
}
// --- VegaChat.js structural completeness: every diplomacy-capable species
// has every situation key the Audience screen's buttons and openers look
// up. pickLine's own empty-pool guard fails silently in production, so this
@ -1727,6 +2173,11 @@ section('7. Diplomacy and the Galactic Council');
'offerPeaceOpener', 'offerAllianceOpener', 'acceptOffer', 'rejectOffer',
'afterAccepted', 'afterRejected',
'askPeople', 'peopleLore', 'askHomeworld', 'homeworldLore', 'askStory', 'storyLore',
'giftOffer', 'replyGiftWarm', 'replyGiftNeutral',
'proposeTradeAgreement', 'replyTradeAgreementAccept', 'replyTradeAgreementReject',
'offerTradeAgreementOpener',
'fleetComplaintOpener', 'acknowledgeComplaintWithdraw', 'acknowledgeComplaintDefy',
'afterComplaintWithdrawPromise', 'afterComplaintDefy',
];
// Lore reply pools are meant to carry real variety — enforce a minimum
// beyond "non-empty" since pickLine's empty-pool fallback is silent and
@ -1836,6 +2287,27 @@ section('9. Serialisation');
check('chatLog survives a round-trip',
JSON.stringify(back2.chatLog) === JSON.stringify(st.chatLog));
// Diplomacy-expansion fields follow the exact same emp.allocLocked ??= {}
// precedent: an old save predating this feature is missing them entirely,
// and deserialize() must back-fill them rather than choke on their absence.
const oldSave = JSON.parse(json2);
for (const emp of oldSave.empires) {
delete emp.tradeAgreements; delete emp.lastGiftTurn; delete emp.fleetIntrusions;
}
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',
backOld.empires.every((e) => JSON.stringify(e.tradeAgreements) === '{}'));
check('lastGiftTurn is back-filled to {} on an old save',
backOld.empires.every((e) => JSON.stringify(e.lastGiftTurn) === '{}'));
check('fleetIntrusions is back-filled to {} on an old save',
backOld.empires.every((e) => JSON.stringify(e.fleetIntrusions) === '{}'));
backOld.rules = RULES;
check('the freshly-backfilled state tolerates a full galaxy diplomacy pass', (() => {
Diplo.runGalaxyDiplomacyPass(RULES, backOld);
return true;
})());
const bad = JSON.parse(json);
bad.version = 99;
check('a save from another version is rejected', Logic.deserialize(JSON.stringify(bad)) === null);
@ -1893,6 +2365,30 @@ section('10. AI self-play soak');
return `${label}: empire ${e.idx} contacted ${otherIdx} is not mutual`;
}
}
// Attitude must never leave its documented [-100, 100] range — every
// write path (adjust(), and the handful of direct writes in
// runEspionage/invade) is supposed to clamp on write.
for (const [otherIdx, v] of Object.entries(e.attitude)) {
if (!Number.isFinite(v) || v < -100 || v > 100) {
return `${label}: empire ${e.idx} attitude toward ${otherIdx} out of range (${v})`;
}
}
// Trade agreements are symmetric, formed/cleared together on both
// sides (formTradeAgreement/declareWar), same shape as contacted.
for (const otherIdx of Object.keys(e.tradeAgreements)) {
if (e.tradeAgreements[otherIdx] && !st.empires[otherIdx]?.tradeAgreements[e.idx]) {
return `${label}: empire ${e.idx} tradeAgreements ${otherIdx} is not mutual`;
}
}
// A dwell stamp from the future would mean the turn counter and the
// intrusion tracker have gotten out of sync.
for (const [starIdx, atStar] of Object.entries(e.fleetIntrusions)) {
for (const [otherIdx, rec] of Object.entries(atStar)) {
if (rec.sinceTurn > st.turn) {
return `${label}: empire ${e.idx} fleetIntrusions[${starIdx}][${otherIdx}] sinceTurn ${rec.sinceTurn} is in the future (turn ${st.turn})`;
}
}
}
}
for (const c of st.colonies) {
if (!Number.isFinite(c.pop) || c.pop < 0) return `${label}: colony ${c.id} pop ${c.pop}`;
@ -1995,6 +2491,21 @@ 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}`);
// 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
// checkFleetIntrusions) has silently broken, since AI fleets routinely
// pass through or park at contested systems over a full game. Skipped in
// --quick mode for the same reason council victories are (line above): 6
// short games is too small a sample for a scenario this specific to be
// guaranteed to occur, matching the existing QUICK-gating precedent.
if (!QUICK) {
const totalComplaints = games.reduce(
(t, g) => t + g.st.events.filter((e) => e.type === 'fleetComplaint').length, 0,
);
check('fleet-intrusion complaints occur somewhere in the sweep', totalComplaints > 0, `${totalComplaints}`);
}
}
// ---------------------------------------------------------------------------