diff --git a/assets/images/vega/char-ssakar.png b/assets/images/vega/char-ssakar.png index cbc853f..3b435d6 100644 Binary files a/assets/images/vega/char-ssakar.png and b/assets/images/vega/char-ssakar.png differ diff --git a/assets/videos/vega/audience-ssakar-angry.mp4 b/assets/videos/vega/audience-ssakar-angry.mp4 new file mode 100644 index 0000000..cbe913c Binary files /dev/null and b/assets/videos/vega/audience-ssakar-angry.mp4 differ diff --git a/assets/videos/vega/audience-ssakar-happy.mp4 b/assets/videos/vega/audience-ssakar-happy.mp4 new file mode 100644 index 0000000..8a2f7e3 Binary files /dev/null and b/assets/videos/vega/audience-ssakar-happy.mp4 differ diff --git a/assets/videos/vega/audience-ssakar-neutral.mp4 b/assets/videos/vega/audience-ssakar-neutral.mp4 new file mode 100644 index 0000000..2dcb20b Binary files /dev/null and b/assets/videos/vega/audience-ssakar-neutral.mp4 differ diff --git a/assets/videos/vega/char-ssakar.mp4 b/assets/videos/vega/char-ssakar.mp4 index 6054b66..63a8c87 100644 Binary files a/assets/videos/vega/char-ssakar.mp4 and b/assets/videos/vega/char-ssakar.mp4 differ diff --git a/assets/videos/vega/research-lithox.mp4 b/assets/videos/vega/research-lithox.mp4 new file mode 100644 index 0000000..afe9c96 Binary files /dev/null and b/assets/videos/vega/research-lithox.mp4 differ diff --git a/assets/videos/vega/research-ssakar.mp4 b/assets/videos/vega/research-ssakar.mp4 new file mode 100644 index 0000000..7f542a3 Binary files /dev/null and b/assets/videos/vega/research-ssakar.mp4 differ diff --git a/assets/videos/vega/ship-lithox-battleship.mp4 b/assets/videos/vega/ship-lithox-battleship.mp4 new file mode 100644 index 0000000..1b4dee9 Binary files /dev/null and b/assets/videos/vega/ship-lithox-battleship.mp4 differ diff --git a/assets/videos/vega/ship-lithox-colony.mp4 b/assets/videos/vega/ship-lithox-colony.mp4 new file mode 100644 index 0000000..36e2e1b Binary files /dev/null and b/assets/videos/vega/ship-lithox-colony.mp4 differ diff --git a/assets/videos/vega/ship-lithox-cruiser.mp4 b/assets/videos/vega/ship-lithox-cruiser.mp4 new file mode 100644 index 0000000..e6385b0 Binary files /dev/null and b/assets/videos/vega/ship-lithox-cruiser.mp4 differ diff --git a/assets/videos/vega/ship-lithox-destroyer.mp4 b/assets/videos/vega/ship-lithox-destroyer.mp4 new file mode 100644 index 0000000..cdd410b Binary files /dev/null and b/assets/videos/vega/ship-lithox-destroyer.mp4 differ diff --git a/assets/videos/vega/ship-lithox-frigate.mp4 b/assets/videos/vega/ship-lithox-frigate.mp4 new file mode 100644 index 0000000..e59ca18 Binary files /dev/null and b/assets/videos/vega/ship-lithox-frigate.mp4 differ diff --git a/assets/videos/vega/ship-lithox-scout.mp4 b/assets/videos/vega/ship-lithox-scout.mp4 new file mode 100644 index 0000000..cd46746 Binary files /dev/null and b/assets/videos/vega/ship-lithox-scout.mp4 differ diff --git a/assets/videos/vega/ship-lithox-transport.mp4 b/assets/videos/vega/ship-lithox-transport.mp4 new file mode 100644 index 0000000..61f303a Binary files /dev/null and b/assets/videos/vega/ship-lithox-transport.mp4 differ diff --git a/assets/videos/vega/ship-ssakar-battleship.mp4 b/assets/videos/vega/ship-ssakar-battleship.mp4 new file mode 100644 index 0000000..4b79b75 Binary files /dev/null and b/assets/videos/vega/ship-ssakar-battleship.mp4 differ diff --git a/assets/videos/vega/ship-ssakar-colony.mp4 b/assets/videos/vega/ship-ssakar-colony.mp4 new file mode 100644 index 0000000..44a3232 Binary files /dev/null and b/assets/videos/vega/ship-ssakar-colony.mp4 differ diff --git a/assets/videos/vega/ship-ssakar-cruiser.mp4 b/assets/videos/vega/ship-ssakar-cruiser.mp4 new file mode 100644 index 0000000..a4f6f14 Binary files /dev/null and b/assets/videos/vega/ship-ssakar-cruiser.mp4 differ diff --git a/assets/videos/vega/ship-ssakar-destroyer.mp4 b/assets/videos/vega/ship-ssakar-destroyer.mp4 new file mode 100644 index 0000000..536034c Binary files /dev/null and b/assets/videos/vega/ship-ssakar-destroyer.mp4 differ diff --git a/assets/videos/vega/ship-ssakar-frigate.mp4 b/assets/videos/vega/ship-ssakar-frigate.mp4 new file mode 100644 index 0000000..847e0f7 Binary files /dev/null and b/assets/videos/vega/ship-ssakar-frigate.mp4 differ diff --git a/assets/videos/vega/ship-ssakar-scout.mp4 b/assets/videos/vega/ship-ssakar-scout.mp4 new file mode 100644 index 0000000..aa2c9ae Binary files /dev/null and b/assets/videos/vega/ship-ssakar-scout.mp4 differ diff --git a/assets/videos/vega/ship-ssakar-transport.mp4 b/assets/videos/vega/ship-ssakar-transport.mp4 new file mode 100644 index 0000000..10b10e5 Binary files /dev/null and b/assets/videos/vega/ship-ssakar-transport.mp4 differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index b37a9b0..c1eaaf0 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -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,19 +193,19 @@ "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" } } }, @@ -203,8 +219,8 @@ "mekhan": { "key": "vega-research-mekhan", "path": null }, "rrashaa": { "key": "vega-research-rrashaa", "path": null }, "cerebrai": { "key": "vega-research-cerebrai", "path": "assets/videos/vega/research-cerebrai.mp4" }, - "ssakar": { "key": "vega-research-ssakar", "path": null }, - "lithox": { "key": "vega-research-lithox", "path": null } + "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": { diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index 45c272f..f1ab079 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -404,6 +404,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"], diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 1a48f0c..6c3b9d7 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -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; diff --git a/src/games/mastervega/VegaAudience.js b/src/games/mastervega/VegaAudience.js index c53daab..0cc4b68 100644 --- a/src/games/mastervega/VegaAudience.js +++ b/src/games/mastervega/VegaAudience.js @@ -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, })); diff --git a/src/games/mastervega/VegaChat.js b/src/games/mastervega/VegaChat.js index 11f98ea..0ccffba 100644 --- a/src/games/mastervega/VegaChat.js +++ b/src/games/mastervega/VegaChat.js @@ -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.', diff --git a/src/games/mastervega/VegaDiplomacy.js b/src/games/mastervega/VegaDiplomacy.js index ecbc329..27e2bc4 100644 --- a/src/games/mastervega/VegaDiplomacy.js +++ b/src/games/mastervega/VegaDiplomacy.js @@ -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); +} diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 57b5c28..735178e 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -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,18 @@ 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 @@ -862,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 @@ -904,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]; @@ -923,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) { @@ -1586,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 { @@ -1742,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) { @@ -1954,6 +2016,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; } diff --git a/src/games/mastervega/VegaResearchScreen.js b/src/games/mastervega/VegaResearchScreen.js index 0cf4fb1..d4352bb 100644 --- a/src/games/mastervega/VegaResearchScreen.js +++ b/src/games/mastervega/VegaResearchScreen.js @@ -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() { diff --git a/src/games/mastervega/VegaRules.js b/src/games/mastervega/VegaRules.js index 9fc1a0e..1dd6f3c 100644 --- a/src/games/mastervega/VegaRules.js +++ b/src/games/mastervega/VegaRules.js @@ -17,6 +17,7 @@ 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'); if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`); const byId = (list, label) => { @@ -258,6 +259,7 @@ export function compileRules(json) { economy: json.economy, combat: json.combat, council: json.council, + diplomacy: json.diplomacy, victory: json.victory ?? { conquest: true, council: true, turnCap: 800 }, starNames: json.starNames, }; diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index ee15dc4..bf6699f 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -38,7 +38,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 +72,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, diff --git a/src/games/mastervega/VegaStarMap.js b/src/games/mastervega/VegaStarMap.js index dd672cb..65fd516 100644 --- a/src/games/mastervega/VegaStarMap.js +++ b/src/games/mastervega/VegaStarMap.js @@ -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; } diff --git a/src/games/mastervega/VegaTurnReport.js b/src/games/mastervega/VegaTurnReport.js index aebedcc..62f17ce 100644 --- a/src/games/mastervega/VegaTurnReport.js +++ b/src/games/mastervega/VegaTurnReport.js @@ -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; diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 2780427..94a8913 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -281,6 +281,78 @@ section('1. Rules integrity'); 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) { @@ -677,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}`); + } } } @@ -1095,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}`); @@ -1140,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); } @@ -1152,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')); @@ -1768,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 @@ -1780,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 @@ -1889,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); @@ -1946,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}`; @@ -2048,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}`); + } } // ---------------------------------------------------------------------------