feat(mastervega): expand diplomacy, trade, espionage, and map rendering
- Add full diplomacy subsystem: tiered gifts (BC payments with diminishing returns), trade agreements (passive BC income, coexists with treaties, cancelled on war), and espionage mission toggles (Off/Steal/Sabotage) in the Audience screen. - Implement whole-galaxy diplomacy passes: border and fleet proximity tension, fleet intrusion tracking with escalating penalties and force-opened Audience complaints, and "enemy of my enemy" attitude triangulation. - Update Audience screen UI with new action buttons, gift picker, intelligence toggle, and species-specific chat dialogue for all diplomacy events. - Add research choice prompts for ambiguous tech frontier picks, surfaced before the turn report. - Improve star map: Delaunay triangulation for controlled-space range darkness, staggered docked fleet markers, expanded pan slack to clear UI chrome, and zoom-aware colony info labels. - Backward-compatible save serialization for new diplomacy fields. - Add comprehensive test coverage for all new logic, chat completeness, and AI soak validation.
This commit is contained in:
parent
d8d127f33a
commit
b721b6221e
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -90,8 +90,24 @@
|
||||||
"cruiser": { "key": "vega-ship-cerebrai-cruiser", "path": "assets/videos/vega/ship-cerebrai-cruiser.mp4" },
|
"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" }
|
"battleship": { "key": "vega-ship-cerebrai-battleship", "path": "assets/videos/vega/ship-cerebrai-battleship.mp4" }
|
||||||
},
|
},
|
||||||
"ssakar": null,
|
"ssakar": {
|
||||||
"lithox": null
|
"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.",
|
"_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" }
|
"happy": { "key": "vega-audience-mekhan-happy", "path": "assets/videos/vega/audience-mekhan-happy.mp4" }
|
||||||
},
|
},
|
||||||
"rrashaa": {
|
"rrashaa": {
|
||||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
"angry": { "key": "vega-audience-rrashaa-angry", "path": "assets/videos/vega/audience-rrashaa-angry.mp4" },
|
||||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
"neutral": { "key": "vega-audience-rrashaa-neutral", "path": "assets/videos/vega/audience-rrashaa-neutral.mp4" },
|
||||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
"happy": { "key": "vega-audience-rrashaa-happy", "path": "assets/videos/vega/audience-rrashaa-happy.mp4" }
|
||||||
},
|
},
|
||||||
"cerebrai": {
|
"cerebrai": {
|
||||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
"angry": { "key": "vega-audience-cerebrai-angry", "path": "assets/videos/vega/audience-cerebrai-angry.mp4" },
|
||||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
"neutral": { "key": "vega-audience-cerebrai-neutral", "path": "assets/videos/vega/audience-cerebrai-neutral.mp4" },
|
||||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
|
"happy": { "key": "vega-audience-cerebrai-happy", "path": "assets/videos/vega/audience-cerebrai-happy.mp4" }
|
||||||
},
|
},
|
||||||
"ssakar": {
|
"ssakar": {
|
||||||
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
|
"angry": { "key": "vega-audience-ssakar-angry", "path": "assets/videos/vega/audience-ssakar-angry.mp4" },
|
||||||
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
|
"neutral": { "key": "vega-audience-ssakar-neutral", "path": "assets/videos/vega/audience-ssakar-neutral.mp4" },
|
||||||
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.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 },
|
"mekhan": { "key": "vega-research-mekhan", "path": null },
|
||||||
"rrashaa": { "key": "vega-research-rrashaa", "path": null },
|
"rrashaa": { "key": "vega-research-rrashaa", "path": null },
|
||||||
"cerebrai": { "key": "vega-research-cerebrai", "path": "assets/videos/vega/research-cerebrai.mp4" },
|
"cerebrai": { "key": "vega-research-cerebrai", "path": "assets/videos/vega/research-cerebrai.mp4" },
|
||||||
"ssakar": { "key": "vega-research-ssakar", "path": null },
|
"ssakar": { "key": "vega-research-ssakar", "path": "assets/videos/vega/research-ssakar.mp4" },
|
||||||
"lithox": { "key": "vega-research-lithox", "path": null }
|
"lithox": { "key": "vega-research-lithox", "path": "assets/videos/vega/research-lithox.mp4" }
|
||||||
},
|
},
|
||||||
|
|
||||||
"sheets": {
|
"sheets": {
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,58 @@
|
||||||
"submitAttitude": -25
|
"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": {
|
"economy": {
|
||||||
"_readme": "Core economic constants. Sliders are the five MOO1 allocation channels; buildings multiply the channel they name.",
|
"_readme": "Core economic constants. Sliders are the five MOO1 allocation channels; buildings multiply the channel they name.",
|
||||||
"channels": ["ships", "defense", "industry", "ecology", "research"],
|
"channels": ["ships", "defense", "industry", "ecology", "research"],
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,11 @@ import {
|
||||||
showVictoryOverlay,
|
showVictoryOverlay,
|
||||||
} from './VegaScreens.js';
|
} from './VegaScreens.js';
|
||||||
import { openResearchScreen } from './VegaResearchScreen.js';
|
import { openResearchScreen } from './VegaResearchScreen.js';
|
||||||
|
import { openResearchChoiceScreen } from './VegaResearchChoiceScreen.js';
|
||||||
import { openTurnReportScreen } from './VegaTurnReportScreen.js';
|
import { openTurnReportScreen } from './VegaTurnReportScreen.js';
|
||||||
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
|
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
|
||||||
import { openAudienceScreen } from './VegaAudience.js';
|
import { openAudienceScreen } from './VegaAudience.js';
|
||||||
import { claimAudienceContacts } from './VegaDiplomacy.js';
|
import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js';
|
||||||
|
|
||||||
const SAVE_KEY = 'mastervega-save';
|
const SAVE_KEY = 'mastervega-save';
|
||||||
|
|
||||||
|
|
@ -609,7 +610,6 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
|
|
||||||
this.buildHud();
|
this.buildHud();
|
||||||
this.refreshHud();
|
this.refreshHud();
|
||||||
this.log('The stars are yours to take.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ HUD
|
// ------------------------------------------------------------------ HUD
|
||||||
|
|
@ -652,6 +652,15 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
});
|
});
|
||||||
hud.add(this.logText);
|
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', () => {
|
const back = new Button(this, 96, GAME_HEIGHT - 40, '← Menu', () => {
|
||||||
this.writeSave();
|
this.writeSave();
|
||||||
this.scene.start('GameMenu');
|
this.scene.start('GameMenu');
|
||||||
|
|
@ -677,10 +686,59 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
this.logText?.setText(this.logLines.join('\n'));
|
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() {
|
refreshAll() {
|
||||||
this.map?.refresh();
|
this.map?.refresh();
|
||||||
this.panel?.refresh();
|
this.panel?.refresh();
|
||||||
this.refreshHud();
|
this.refreshHud();
|
||||||
|
this.refreshOfferNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- modals
|
// -------------------------------------------------------------- modals
|
||||||
|
|
@ -788,6 +846,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
// closes, via openModal's drain hook above.
|
// closes, via openModal's drain hook above.
|
||||||
this.pendingAudiences.push(
|
this.pendingAudiences.push(
|
||||||
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
|
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
|
||||||
|
...claimFleetComplaints(this.rules, this.state, this.state.humanIndex),
|
||||||
);
|
);
|
||||||
this.refreshAll();
|
this.refreshAll();
|
||||||
},
|
},
|
||||||
|
|
@ -830,6 +889,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
// else about this turn resolves. Meet before you fight.
|
// else about this turn resolves. Meet before you fight.
|
||||||
this.pendingAudiences.push(
|
this.pendingAudiences.push(
|
||||||
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
|
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
|
||||||
|
...claimFleetComplaints(this.rules, this.state, this.state.humanIndex),
|
||||||
);
|
);
|
||||||
this.runAudienceQueue(() => {
|
this.runAudienceQueue(() => {
|
||||||
this.playPlayerBattles(() => {
|
this.playPlayerBattles(() => {
|
||||||
|
|
@ -884,6 +944,7 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
// "contact made" always reads as the bigger beat.
|
// "contact made" always reads as the bigger beat.
|
||||||
this.pendingAudiences.push(
|
this.pendingAudiences.push(
|
||||||
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
|
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
|
||||||
|
...claimFleetComplaints(this.rules, this.state, this.state.humanIndex),
|
||||||
);
|
);
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
|
|
@ -895,10 +956,23 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
// openModal's own done() (MasterOfVegaGame.js:openModal) clears
|
// openModal's own done() (MasterOfVegaGame.js:openModal) clears
|
||||||
// modalOpen and refreshes; finish() on top of that re-arms the End
|
// modalOpen and refreshes; finish() on top of that re-arms the End
|
||||||
// Turn button, which openModal has no reason to touch on its own.
|
// Turn button, which openModal has no reason to touch on its own.
|
||||||
this.openModal((done) => openTurnReportScreen(this, this.rules, this.state, notable, () => {
|
// Any research-branch choice prompts run first, each opened
|
||||||
done();
|
// DIRECTLY (not via this.openModal — modalOpen is already true
|
||||||
finish();
|
// 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 {
|
} else {
|
||||||
this.refreshAll();
|
this.refreshAll();
|
||||||
finish();
|
finish();
|
||||||
|
|
@ -915,6 +989,25 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
||||||
step();
|
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
|
// Turn engine events into log lines, map pings, and — for the events worth
|
||||||
// interrupting the player for — rows in the "New Turn" popup. `announced`
|
// interrupting the player for — rows in the "New Turn" popup. `announced`
|
||||||
// marks a record consumed, since beginEmpireTurn trims rather than clears
|
// 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.`);
|
this.log(`The ${name(ev.empire)} seek an audience.`);
|
||||||
} else if (ev.type === 'populationDelivered') {
|
} else if (ev.type === 'populationDelivered') {
|
||||||
this.log(`${ev.amount.toFixed(1)} population arrived at ${star?.name}.`);
|
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;
|
return notable;
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ import {
|
||||||
} from './VegaArt.js';
|
} from './VegaArt.js';
|
||||||
import { atWar, grantTech } from './VegaLogic.js';
|
import { atWar, grantTech } from './VegaLogic.js';
|
||||||
import {
|
import {
|
||||||
attitudeOf, moodOf, videoMood, canNegotiate, declareWar, proposeTreaty, respondToOffer, tradeTech,
|
attitudeOf, moodOf, videoMood, canNegotiate, declareWar, proposeTreaty, respondToOffer, tradeTech, giveGift,
|
||||||
} from './VegaDiplomacy.js';
|
} from './VegaDiplomacy.js';
|
||||||
import { CHAT, pickLine } from './VegaChat.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_X = VIDEO_X + VIDEO_W + 60;
|
||||||
const CHAT_W = GAME_WIDTH - CHAT_X - 70;
|
const CHAT_W = GAME_WIDTH - CHAT_X - 70;
|
||||||
const CHAT_Y = VIDEO_Y;
|
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;
|
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
|
// 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() {
|
function statusLine() {
|
||||||
const treaty = state.empires[me].treaties[otherIdx] ?? 'none';
|
const treaty = state.empires[me].treaties[otherIdx] ?? 'none';
|
||||||
const att = attitudeOf(state, otherIdx, me);
|
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();
|
statusLine();
|
||||||
|
|
||||||
|
|
@ -522,10 +531,19 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
|
||||||
}
|
}
|
||||||
const openingOffer = state.empires[me].pendingOffers[otherIdx];
|
const openingOffer = state.empires[me].pendingOffers[otherIdx];
|
||||||
if (openingOffer) {
|
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));
|
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)));
|
moodVisual.show(videoMood(attitudeOf(state, otherIdx, me)));
|
||||||
|
|
||||||
// --- action buttons
|
// --- action buttons
|
||||||
|
|
@ -553,15 +571,52 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
|
||||||
}
|
}
|
||||||
|
|
||||||
function doPropose(kind) {
|
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 accepted = proposeTreaty(rules, state, me, otherIdx, kind);
|
||||||
const key = kind === 'peace'
|
const key = kind === 'peace'
|
||||||
? (accepted ? chat.replyPeaceAccept : chat.replyPeaceReject)
|
? (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));
|
say(pickLine(key, vars));
|
||||||
afterAction();
|
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() {
|
function doDeclareWar() {
|
||||||
pushChat('p', pickLine(chat.declareWar, vars));
|
pushChat('p', pickLine(chat.declareWar, vars));
|
||||||
declareWar(rules, state, me, otherIdx);
|
declareWar(rules, state, me, otherIdx);
|
||||||
|
|
@ -580,6 +635,17 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
|
||||||
afterAction();
|
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
|
// --- pure-flavor conversation topics: no treaty/attitude effect, so no
|
||||||
// afterAction() — pushChat()/say() already do everything the chat log
|
// afterAction() — pushChat()/say() already do everything the chat log
|
||||||
// needs, and nothing about the mood, status line or button set changes.
|
// 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. */
|
/** 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 = {}) {
|
function layoutRow(list, y, extraOpts = {}) {
|
||||||
const perRow = 3;
|
const perRow = 3;
|
||||||
|
|
@ -643,7 +738,12 @@ export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClo
|
||||||
function renderButtons() {
|
function renderButtons() {
|
||||||
buttonRow.removeAll(true);
|
buttonRow.removeAll(true);
|
||||||
const offer = state.empires[me].pendingOffers[otherIdx];
|
const offer = state.empires[me].pendingOffers[otherIdx];
|
||||||
|
const giftAffordable = state.empires[me].bc >= rules.diplomacy.gift.tiers[0].bc;
|
||||||
const items = [];
|
const items = [];
|
||||||
|
if (complaintPending) {
|
||||||
|
items.push(['We\'ll Withdraw', doComplaintWithdraw]);
|
||||||
|
items.push(['We\'re Staying', doComplaintDefy]);
|
||||||
|
}
|
||||||
if (offer) {
|
if (offer) {
|
||||||
items.push(['Accept', () => doPending(true)]);
|
items.push(['Accept', () => doPending(true)]);
|
||||||
items.push(['Reject', () => doPending(false)]);
|
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]);
|
if (!atWar(state, me, otherIdx)) items.push(['Declare War', doDeclareWar]);
|
||||||
} else if (atWar(state, me, otherIdx)) {
|
} else if (atWar(state, me, otherIdx)) {
|
||||||
items.push(['Sue for Peace', () => doPropose('peace')]);
|
items.push(['Sue for Peace', () => doPropose('peace')]);
|
||||||
|
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
|
||||||
} else {
|
} else {
|
||||||
items.push(['Declare War', doDeclareWar]);
|
items.push(['Declare War', doDeclareWar]);
|
||||||
if (state.empires[me].treaties[otherIdx] !== 'alliance') {
|
if (state.empires[me].treaties[otherIdx] !== 'alliance') {
|
||||||
items.push(['Propose Alliance', () => doPropose('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 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]);
|
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 (give.length && want.length) items.push(['Trade Tech', () => openTechPicker(give, want)]);
|
||||||
|
if (giftAffordable) items.push(['Send Gift', openGiftPicker]);
|
||||||
}
|
}
|
||||||
const treatyRows = layoutRow(items, BTN_Y);
|
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
|
// Lore/conversation topics — pure flavor, appended UNCONDITIONALLY after
|
||||||
// the treaty-branch logic above, gated purely on mood tier (never on
|
// 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]);
|
loreItems.push(['A Story', doAskStory]);
|
||||||
}
|
}
|
||||||
if (loreItems.length) {
|
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', {
|
buttonRow.add(scene.add.text(CHAT_X, labelY, 'CONVERSATION', {
|
||||||
fontFamily: FONT, fontSize: '13px', color: '#6f8aa8', letterSpacing: 2,
|
fontFamily: FONT, fontSize: '13px', color: '#6f8aa8', letterSpacing: 2,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,54 @@ export const CHAT = {
|
||||||
'Your loss, {you}. The offer won\'t stay on the table forever.',
|
'Your loss, {you}. The offer won\'t stay on the table forever.',
|
||||||
'Suit yourself, {you}. I\'ll remember this.',
|
'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: [
|
askPeople: [
|
||||||
'Tell me about your people, {me} — where you\'ve come from, what you value. I like knowing who I\'m dealing with.',
|
'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.',
|
'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.',
|
'A groundling\'s answer, {you}. We shall remember the insult.',
|
||||||
'Then the sky remains closed to you, {you}.',
|
'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: [
|
askPeople: [
|
||||||
'Tell me of your people, {me}. I would understand what shaped a race so certain of its own altitude.',
|
'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.',
|
'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.',
|
'Then we stand alone, {you}. So be it.',
|
||||||
'Your answer has weight, {you}. We will not forget 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: [
|
askPeople: [
|
||||||
'Tell me of your people, {me}. I want to understand what makes an Ursaal stand as you do.',
|
'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.',
|
'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.',
|
'As expected, {you}. Trust was always going to be the harder sell.',
|
||||||
'Fine, {you}. We\'ll remember this face you\'ve shown us.',
|
'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: [
|
askPeople: [
|
||||||
'Tell me about your people, {me}. I\'d like the truth, if that\'s a thing you deal in.',
|
'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.',
|
'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.',
|
'Rejection logged, {you}. Function reverts.',
|
||||||
'Noted, {you}. Proposal withdrawn from active memory.',
|
'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: [
|
askPeople: [
|
||||||
'Query: describe your civilization. Origin, structure, function.',
|
'Query: describe your civilization. Origin, structure, function.',
|
||||||
'Request information: your people. History and purpose.',
|
'Request information: your people. History and purpose.',
|
||||||
|
|
@ -759,6 +999,54 @@ export const CHAT = {
|
||||||
'Rejection logged, {you}. Recalculating alternatives.',
|
'Rejection logged, {you}. Recalculating alternatives.',
|
||||||
'Noted, {you}. Model adjusted accordingly.',
|
'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: [
|
askPeople: [
|
||||||
'Query your history, {me}. I\'d like to understand the upgrade path that led here.',
|
'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.',
|
'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.',
|
'Your loss, {you}. The offer doesn\'t come twice.',
|
||||||
'Fine. Remember you had the chance, {you}.',
|
'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: [
|
askPeople: [
|
||||||
'Tell me about your people, {me}. I want to understand what you were before you were gunners.',
|
'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.',
|
'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.',
|
'An illogical answer, {you}, but your prerogative.',
|
||||||
'Noted, {you}. I will revise my model of you accordingly.',
|
'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: [
|
askPeople: [
|
||||||
'Tell me about your people, {me}. I\'d like to understand the shape of your civilization\'s thought.',
|
'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.',
|
'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}.',
|
'Then the brood remembers this refusal, {you}.',
|
||||||
'So be it, {you}. Our clutches grow apart, then.',
|
'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: [
|
askPeople: [
|
||||||
'Tell me about your people, {me}. I want to understand the brood, not just its numbers.',
|
'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.',
|
'Speak of your history, {me} — how the clutches grew into what they are.',
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@
|
||||||
// decision reads. Species diplomacy traits bias it permanently — the Lithox sit
|
// 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.
|
// 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'];
|
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) {
|
export function declareWar(rules, state, a, b) {
|
||||||
state.empires[a].treaties[b] = 'war';
|
state.empires[a].treaties[b] = 'war';
|
||||||
state.empires[b].treaties[a] = '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, b, a, -35);
|
||||||
adjust(state, a, b, -15);
|
adjust(state, a, b, -15);
|
||||||
state.events.push({ type: 'warDeclared', empire: a, other: b, turn: state.turn });
|
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 });
|
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
|
// Relative strength, used everywhere an empire has to decide whether it can
|
||||||
// afford an opinion.
|
// afford an opinion.
|
||||||
export function powerOf(rules, state, e) {
|
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.
|
// The losing side sues for peace readily; a winning one has no reason to.
|
||||||
return att > -70 && (att > -20 || ratio > 1.4);
|
return att > -70 && (att > -20 || ratio > 1.4);
|
||||||
case 'alliance':
|
case 'alliance':
|
||||||
return att > 45 && !atWar(state, a, b);
|
return att > rules.diplomacy.alliance.attitudeThreshold && !atWar(state, a, b);
|
||||||
case 'techTrade':
|
case 'techTrade':
|
||||||
return att > 0;
|
return att > 0;
|
||||||
|
case 'tradeAgreement':
|
||||||
|
return att > rules.diplomacy.tradeAgreement.attitudeThreshold && !atWar(state, a, b);
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +135,7 @@ export function proposeTreaty(rules, state, a, b, kind) {
|
||||||
}
|
}
|
||||||
if (kind === 'peace') makePeace(rules, state, a, b);
|
if (kind === 'peace') makePeace(rules, state, a, b);
|
||||||
else if (kind === 'alliance') formAlliance(rules, state, a, b);
|
else if (kind === 'alliance') formAlliance(rules, state, a, b);
|
||||||
|
else if (kind === 'tradeAgreement') formTradeAgreement(rules, state, a, b);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,6 +165,7 @@ export function respondToOffer(rules, state, b, a, accept) {
|
||||||
if (accept) {
|
if (accept) {
|
||||||
if (offer.kind === 'peace') makePeace(rules, state, a, b);
|
if (offer.kind === 'peace') makePeace(rules, state, a, b);
|
||||||
else if (offer.kind === 'alliance') formAlliance(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 {
|
} else {
|
||||||
adjust(state, a, b, -8); // the proposer stings at being refused
|
adjust(state, a, b, -8); // the proposer stings at being refused
|
||||||
adjust(state, b, a, -2); // refusing is itself a small friction cost
|
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;
|
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
|
// Per-turn drift. Attitudes decay toward the species baseline, war grinds them
|
||||||
// down, and shared borders create friction.
|
// down, and shared borders create friction.
|
||||||
export function driftAttitudes(rules, state, e) {
|
export function driftAttitudes(rules, state, e) {
|
||||||
|
|
@ -251,7 +301,28 @@ export function runDiplomacyTurn(rules, state, e) {
|
||||||
declareWar(rules, state, e, other.idx);
|
declareWar(rules, state, e, other.idx);
|
||||||
continue;
|
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');
|
proposeOrOffer(rules, state, e, other.idx, 'alliance');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -273,3 +344,230 @@ export function breakStalemate(rules, state) {
|
||||||
declareWar(rules, state, aggressor.idx, victim.idx);
|
declareWar(rules, state, aggressor.idx, victim.idx);
|
||||||
return true;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { generateGalaxy, parsecs, mulberry32 } from './VegaGalaxyGen.js';
|
||||||
import { techCost, techCostFactor } from './VegaRules.js';
|
import { techCost, techCostFactor } from './VegaRules.js';
|
||||||
import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js';
|
import { designFor, bestComponents, markFor, refitCost } from './VegaShips.js';
|
||||||
import { createBattle, runBattle, resolveInvasion } from './VegaCombat.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'];
|
export const CHANNELS = ['ships', 'defense', 'industry', 'ecology', 'research'];
|
||||||
|
|
||||||
|
|
@ -425,8 +425,15 @@ export function createGame(rules, opts) {
|
||||||
treaties: {},
|
treaties: {},
|
||||||
attitude: {},
|
attitude: {},
|
||||||
pendingOffers: {},
|
pendingOffers: {},
|
||||||
|
tradeAgreements: {},
|
||||||
|
lastGiftTurn: {},
|
||||||
|
fleetIntrusions: {},
|
||||||
explored: {},
|
explored: {},
|
||||||
spyPoints: 0,
|
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,
|
totalPop: 0,
|
||||||
nameCursor: 0,
|
nameCursor: 0,
|
||||||
// A per-empire shuffle of its species' colonyNames indices, so
|
// A per-empire shuffle of its species' colonyNames indices, so
|
||||||
|
|
@ -632,6 +639,18 @@ export function nextResearchTarget(rules, state, e, field) {
|
||||||
return null;
|
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
|
// 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
|
// 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
|
// 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;
|
for (const l of emp.leaders) upkeep += rules.leaders[l.leaderId].upkeep;
|
||||||
|
|
||||||
emp.bc = Math.max(0, emp.bc + trade - upkeep);
|
// Active trade agreements add a modest ongoing income, scaled to the
|
||||||
emp.lastIncome = trade - upkeep;
|
// 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
|
const researchMult = spec.traits.researchMult
|
||||||
* comps.researchMult
|
* comps.researchMult
|
||||||
|
|
@ -904,7 +934,9 @@ function autoRefit(rules, state, e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Espionage is passive: a species with spies gets periodic attempts against
|
// 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) {
|
function runEspionage(rules, state, e) {
|
||||||
const emp = state.empires[e];
|
const emp = state.empires[e];
|
||||||
const spec = rules.species[emp.speciesId];
|
const spec = rules.species[emp.speciesId];
|
||||||
|
|
@ -923,26 +955,51 @@ function runEspionage(rules, state, e) {
|
||||||
if (emp.spyPoints < 1) return;
|
if (emp.spyPoints < 1) return;
|
||||||
emp.spyPoints -= 1;
|
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
|
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;
|
if (!targets.length) return;
|
||||||
const target = targets[randInt(state, targets.length)];
|
const target = targets[randInt(state, targets.length)];
|
||||||
|
const mission = emp.espionageMission[target.idx] ?? 'steal';
|
||||||
const tspec = rules.species[target.speciesId];
|
const tspec = rules.species[target.speciesId];
|
||||||
let defence = (tspec.traits.counterEspionage ?? 0) + empireComponents(rules, state, target.idx).counterEspionage;
|
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');
|
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));
|
const odds = Math.max(0.05, Math.min(0.8, 0.4 + (power - defence) / 200));
|
||||||
if (rand(state) > odds) {
|
if (rand(state) > odds) {
|
||||||
pushEvent(state, { type: 'spyCaught', empire: e, target: target.idx, turn: state.turn });
|
pushEvent(state, { type: 'spyCaught', empire: e, target: target.idx, mission, turn: state.turn });
|
||||||
target.attitude[e] = (target.attitude[e] ?? 0) - 12;
|
target.attitude[e] = Math.max(-100, Math.min(100, (target.attitude[e] ?? 0) - 12));
|
||||||
return;
|
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]);
|
const stealable = rules.techList.filter((t) => target.known[t.id] && !emp.known[t.id]);
|
||||||
if (!stealable.length) return;
|
if (!stealable.length) return;
|
||||||
const tech = stealable[randInt(state, stealable.length)];
|
const tech = stealable[randInt(state, stealable.length)];
|
||||||
grantTech(rules, state, e, tech.id, 'espionage');
|
grantTech(rules, state, e, tech.id, 'espionage');
|
||||||
pushEvent(state, { type: 'techStolen', empire: e, target: target.idx, techId: tech.id, turn: state.turn });
|
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) {
|
function recomputeTotals(rules, state) {
|
||||||
|
|
@ -1586,7 +1643,7 @@ export function invade(rules, state, e, starIdx) {
|
||||||
colony.queue = [];
|
colony.queue = [];
|
||||||
colony.defenseHp = 0;
|
colony.defenseHp = 0;
|
||||||
pushEvent(state, { type: 'captured', empire: e, from, starIdx, colonyId: colony.id, turn: state.turn });
|
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;
|
state.empires[e]._range = null; state.empires[e]._rangeAt = -1;
|
||||||
checkElimination(rules, state, from);
|
checkElimination(rules, state, from);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1742,6 +1799,11 @@ export function endEmpireTurn(rules, state, e, { skipMove = false } = {}) {
|
||||||
if (next === 0) {
|
if (next === 0) {
|
||||||
state.turn += 1;
|
state.turn += 1;
|
||||||
state.rules = rules;
|
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
|
if (rules.council.council !== false
|
||||||
&& state.turn >= state.council.nextTurn
|
&& state.turn >= state.council.nextTurn
|
||||||
&& colonizedFraction(state) >= rules.council.minColonizedFraction) {
|
&& 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
|
// 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.
|
// bumping the save version over one new always-empty-by-default field.
|
||||||
emp.allocLocked ??= {};
|
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;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ function ensureOneResearchVideo(scene, speciesId, onDone) {
|
||||||
// centred inside the taller 2:3 frame until a real research clip loads, then
|
// centred inside the taller 2:3 frame until a real research clip loads, then
|
||||||
// the clip itself scaled the same way.
|
// 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);
|
const container = scene.add.container(x, y);
|
||||||
|
|
||||||
function showFallback() {
|
function showFallback() {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export function compileRules(json) {
|
||||||
need(json.economy && typeof json.economy === 'object', 'economy block missing');
|
need(json.economy && typeof json.economy === 'object', 'economy block missing');
|
||||||
need(json.combat && typeof json.combat === 'object', 'combat block missing');
|
need(json.combat && typeof json.combat === 'object', 'combat block missing');
|
||||||
need(json.council && typeof json.council === 'object', 'council 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('; ')}`);
|
if (errors.length) throw new Error(`mastervega-rules invalid: ${errors.join('; ')}`);
|
||||||
|
|
||||||
const byId = (list, label) => {
|
const byId = (list, label) => {
|
||||||
|
|
@ -258,6 +259,7 @@ export function compileRules(json) {
|
||||||
economy: json.economy,
|
economy: json.economy,
|
||||||
combat: json.combat,
|
combat: json.combat,
|
||||||
council: json.council,
|
council: json.council,
|
||||||
|
diplomacy: json.diplomacy,
|
||||||
victory: json.victory ?? { conquest: true, council: true, turnCap: 800 },
|
victory: json.victory ?? { conquest: true, council: true, turnCap: 800 },
|
||||||
starNames: json.starNames,
|
starNames: json.starNames,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ const ACCENT = 0x6fc4ff;
|
||||||
const PANEL = 0x0b1220;
|
const PANEL = 0x0b1220;
|
||||||
|
|
||||||
/** The holographic frame every modal is built inside. */
|
/** 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 layer = scene.add.container(0, 0).setDepth(D.modal);
|
||||||
|
|
||||||
const veil = scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000914, 0.72)
|
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);
|
const rule = scene.add.rectangle(x + 30, y + 64, width - 60, 1, ACCENT, 0.4).setOrigin(0, 0);
|
||||||
layer.add(rule);
|
layer.add(rule);
|
||||||
|
|
||||||
|
// 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, '✕', () => {
|
const close = new Button(scene, x + width - 70, y + 40, '✕', () => {
|
||||||
layer.destroy();
|
layer.destroy();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
}, { width: 46, height: 40, variant: 'ghost' });
|
}, { width: 46, height: 40, variant: 'ghost' });
|
||||||
layer.add(close);
|
layer.add(close);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layer, x, y, width, height,
|
layer, x, y, width, height,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
} from './VegaLogic.js';
|
} from './VegaLogic.js';
|
||||||
import { starFrame } from './VegaArt.js';
|
import { starFrame } from './VegaArt.js';
|
||||||
import { buildZoomLadder, DEFAULT_ZOOM_INDEX } from './VegaZoom.js';
|
import { buildZoomLadder, DEFAULT_ZOOM_INDEX } from './VegaZoom.js';
|
||||||
|
import { delaunayTriangulate, triangulationEdges } from './VegaDelaunay.js';
|
||||||
import { describeStarTooltip } from './VegaTooltips.js';
|
import { describeStarTooltip } from './VegaTooltips.js';
|
||||||
import { ORBIT } from './VegaScreens.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_MIN_BAR_W = 80;
|
||||||
const COLONY_INFO_TEXT_SIZE = 26;
|
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
|
// The range and territory fields are painted into low-resolution
|
||||||
// RenderTextures and scaled up. A huge galaxy is 5400px wide — past the safe
|
// 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
|
// 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.laneGfx = scene.add.graphics();
|
||||||
this.root.add(this.laneGfx);
|
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);
|
.setOrigin(0, 0).setScale(FIELD_DIV);
|
||||||
this.root.add(this.rangeRT);
|
this.root.add(this.rangeRT);
|
||||||
|
|
||||||
|
|
@ -265,15 +295,72 @@ export default class VegaStarMap {
|
||||||
const emp = this.state.empires[this.viewerIdx];
|
const emp = this.state.empires[this.viewerIdx];
|
||||||
if (!emp) return;
|
if (!emp) return;
|
||||||
// One soft disc per explored star — sized to clear just that star's own
|
// 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
|
// neighbourhood, not to bridge gaps into a blanket "sphere of influence"
|
||||||
// the old fuel-range version did (that would re-imply territory the
|
// the way the old fuel-range version did (that would re-imply territory
|
||||||
// player hasn't actually scouted).
|
// 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 radius = PARSEC_PX * 1.1;
|
||||||
const img = this.stamp((radius * 2) / 256 / FIELD_DIV);
|
const img = this.stamp((radius * 2) / 256 / FIELD_DIV);
|
||||||
for (const star of this.state.galaxy.stars) {
|
for (const star of this.state.galaxy.stars) {
|
||||||
if (!emp.explored[star.idx]) continue;
|
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.
|
// 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
|
* 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
|
* 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
|
* 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
|
* center. Full text only at the closest zoom tier — same density tier as
|
||||||
* "N worlds · M habitable" subtext — to keep the mid/far zoom map legible.
|
* 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() {
|
refreshColonyInfo() {
|
||||||
this.colonyInfoLayer.removeAll(true);
|
this.colonyInfoLayer.removeAll(true);
|
||||||
if (this.isFarZoom || !this.isNearZoom) return;
|
if (this.isFarZoom) return;
|
||||||
const { state } = this;
|
const { state } = this;
|
||||||
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
|
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));
|
const textSize = Math.round(COLONY_INFO_TEXT_SIZE / Math.max(0.7, this.zoom));
|
||||||
|
|
||||||
for (const s of this.starSprites) {
|
for (const s of this.starSprites) {
|
||||||
|
|
@ -356,19 +447,19 @@ export default class VegaStarMap {
|
||||||
for (const emp of state.empires) {
|
for (const emp of state.empires) {
|
||||||
const own = cols.filter((c) => c.empireIdx === emp.idx).sort((a, b) => a.orbit - b.orbit);
|
const own = cols.filter((c) => c.empireIdx === emp.idx).sort((a, b) => a.orbit - b.orbit);
|
||||||
if (!own.length) continue;
|
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)})`
|
`${c.name ?? `${s.star.name} ${ORBIT[c.orbit] ?? c.orbit + 1}`} (pop. ${c.pop.toFixed(1)})`
|
||||||
)).join('\n'), {
|
)).join('\n'), {
|
||||||
fontFamily: FONT, fontSize: `${textSize}px`, color: '#f0f4fa', lineSpacing: 2,
|
fontFamily: FONT, fontSize: `${textSize}px`, color: '#f0f4fa', lineSpacing: 2,
|
||||||
}).setOrigin(1, 0);
|
}).setOrigin(1, 0) : null;
|
||||||
groups.push({ emp, text });
|
groups.push({ emp, text });
|
||||||
}
|
}
|
||||||
if (!groups.length) continue;
|
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 blockRightX = s.star.x - COLONY_INFO_GAP_X;
|
||||||
const totalHeight = groups.reduce((h, g) => (
|
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);
|
), 0) + COLONY_INFO_GROUP_GAP * (groups.length - 1);
|
||||||
let y = s.star.y - COLONY_INFO_GAP_Y - totalHeight;
|
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.lineStyle(3, colour, 0.7);
|
||||||
lines.lineBetween(blockRightX, y + COLONY_INFO_BAR_H / 2, s.star.x, s.star.y);
|
lines.lineBetween(blockRightX, y + COLONY_INFO_BAR_H / 2, s.star.x, s.star.y);
|
||||||
|
|
||||||
|
let groupHeight = COLONY_INFO_BAR_H;
|
||||||
|
if (g.text) {
|
||||||
g.text.setPosition(blockRightX, y + COLONY_INFO_BAR_H + COLONY_INFO_BAR_TEXT_GAP);
|
g.text.setPosition(blockRightX, y + COLONY_INFO_BAR_H + COLONY_INFO_BAR_TEXT_GAP);
|
||||||
this.colonyInfoLayer.add(g.text);
|
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;
|
const viewer = this.viewerIdx >= 0 ? state.empires[this.viewerIdx] : null;
|
||||||
this.fleetMarkers = [];
|
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) {
|
for (const fleet of state.fleets) {
|
||||||
const emp = state.empires[fleet.empireIdx];
|
const emp = state.empires[fleet.empireIdx];
|
||||||
if (!emp) continue;
|
if (!emp) continue;
|
||||||
|
|
@ -406,8 +512,9 @@ export default class VegaStarMap {
|
||||||
if (fleet.starIdx >= 0) {
|
if (fleet.starIdx >= 0) {
|
||||||
const star = state.galaxy.stars[fleet.starIdx];
|
const star = state.galaxy.stars[fleet.starIdx];
|
||||||
if (viewer && !own && !viewer.explored[fleet.starIdx]) continue;
|
if (viewer && !own && !viewer.explored[fleet.starIdx]) continue;
|
||||||
x = star.x + 26;
|
const slot = dockedByStar.get(fleet.starIdx).indexOf(fleet);
|
||||||
y = star.y - 22;
|
x = star.x + FLEET_STAGGER_DX + (slot % 2 === 1 ? FLEET_STAGGER_ZIGZAG : 0);
|
||||||
|
y = star.y + FLEET_STAGGER_DY0 - slot * FLEET_STAGGER_STEP;
|
||||||
} else {
|
} else {
|
||||||
const a = state.galaxy.stars[fleet.fromStar];
|
const a = state.galaxy.stars[fleet.fromStar];
|
||||||
const b = state.galaxy.stars[fleet.toStar];
|
const b = state.galaxy.stars[fleet.toStar];
|
||||||
|
|
@ -573,11 +680,13 @@ export default class VegaStarMap {
|
||||||
let ax;
|
let ax;
|
||||||
let ay;
|
let ay;
|
||||||
if (f.starIdx >= 0) {
|
if (f.starIdx >= 0) {
|
||||||
const star = this.state.galaxy.stars[f.starIdx];
|
// Read the marker's actual on-screen position rather than
|
||||||
if (!star) return null;
|
// recomputing the offset — docked fleets are staggered per-star, so
|
||||||
// Match the fleet marker's own offset from the star it sits at.
|
// the offset depends on how many other fleets share that star.
|
||||||
ax = star.x + 26;
|
const marker = this.fleetMarkers?.find((m) => m.fleet === f);
|
||||||
ay = star.y - 22;
|
if (!marker) return null;
|
||||||
|
ax = marker.container.x;
|
||||||
|
ay = marker.container.y;
|
||||||
} else {
|
} else {
|
||||||
const from = this.state.galaxy.stars[f.fromStar];
|
const from = this.state.galaxy.stars[f.fromStar];
|
||||||
if (!from) return null;
|
if (!from) return null;
|
||||||
|
|
@ -710,20 +819,21 @@ export default class VegaStarMap {
|
||||||
|
|
||||||
// ---------------------------------------------------------------- camera
|
// ---------------------------------------------------------------- camera
|
||||||
|
|
||||||
// Keep the viewport inside the galaxy. The old version allowed 160px of slack
|
// Keep the viewport close to the galaxy, with PAN_SLACK of give on every
|
||||||
// on every side, which let the player drag empty space into view even at a
|
// side. An earlier version clamped with zero slack, which was airtight but
|
||||||
// zoom that covered the screen — the same complaint as zooming out too far,
|
// meant the map's edge sat pinned exactly at the screen edge at the bottom
|
||||||
// just reached a different way.
|
// 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() {
|
clampPan() {
|
||||||
const w = this.worldW * this.zoom;
|
const w = this.worldW * this.zoom;
|
||||||
const h = this.worldH * this.zoom;
|
const h = this.worldH * this.zoom;
|
||||||
// At the bottom of the ladder w/h equal the viewport, so both bounds
|
this.root.x = w + PAN_SLACK * 2 >= GAME_WIDTH
|
||||||
// collapse to a single value and the map sits locked and centred.
|
? Phaser.Math.Clamp(this.root.x, GAME_WIDTH - w - PAN_SLACK, PAN_SLACK)
|
||||||
this.root.x = w >= GAME_WIDTH
|
|
||||||
? Phaser.Math.Clamp(this.root.x, GAME_WIDTH - w, 0)
|
|
||||||
: (GAME_WIDTH - w) / 2;
|
: (GAME_WIDTH - w) / 2;
|
||||||
this.root.y = h >= GAME_HEIGHT
|
this.root.y = h + PAN_SLACK * 2 >= GAME_HEIGHT
|
||||||
? Phaser.Math.Clamp(this.root.y, GAME_HEIGHT - h, 0)
|
? Phaser.Math.Clamp(this.root.y, GAME_HEIGHT - h - PAN_SLACK, PAN_SLACK)
|
||||||
: (GAME_HEIGHT - h) / 2;
|
: (GAME_HEIGHT - h) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { describeTechEffects } from './VegaTechEffects.js';
|
||||||
// without taking the vignette out.
|
// without taking the vignette out.
|
||||||
export const NOTABLE_TYPES = new Set([
|
export const NOTABLE_TYPES = new Set([
|
||||||
'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', 'captured',
|
'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', 'captured',
|
||||||
'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'council',
|
'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'tradeAgreementFormed', 'council',
|
||||||
'councilRefused', 'eliminated',
|
'councilRefused', 'eliminated',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -53,6 +53,7 @@ export const TYPE_LABEL = {
|
||||||
warDeclared: 'War Declared',
|
warDeclared: 'War Declared',
|
||||||
peace: 'Peace',
|
peace: 'Peace',
|
||||||
alliance: 'Alliance',
|
alliance: 'Alliance',
|
||||||
|
tradeAgreementFormed: 'Trade Agreement',
|
||||||
council: 'Galactic Council',
|
council: 'Galactic Council',
|
||||||
councilRefused: 'Council Refused',
|
councilRefused: 'Council Refused',
|
||||||
eliminated: 'Empire Eliminated',
|
eliminated: 'Empire Eliminated',
|
||||||
|
|
@ -62,7 +63,7 @@ export const TYPE_LABEL = {
|
||||||
// then discoveries, then research, then finished production, mirroring how
|
// then discoveries, then research, then finished production, mirroring how
|
||||||
// the plan orders the list.
|
// the plan orders the list.
|
||||||
const CATEGORY_ORDER = {
|
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,
|
captured: 1, colonyDestroyed: 1,
|
||||||
discovered: 2,
|
discovered: 2,
|
||||||
techDone: 3,
|
techDone: 3,
|
||||||
|
|
@ -169,7 +170,7 @@ function describeColonyDestroyed(rules, state, ev, name) {
|
||||||
let headline;
|
let headline;
|
||||||
if (ev.target === me) headline = `The ${name(ev.empire)} have bombed ${star.name} out of existence — our colony is lost.`;
|
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 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: [] };
|
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 'warDeclared': out = describeTreaty(rules, state, ev, name, 'declare war on'); break;
|
||||||
case 'peace': out = describeTreaty(rules, state, ev, name, 'make peace with'); 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 '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 'council': out = describeCouncil(rules, state, ev, name); break;
|
||||||
case 'councilRefused': out = describeCouncilRefused(rules, state, ev, name); break;
|
case 'councilRefused': out = describeCouncilRefused(rules, state, ev, name); break;
|
||||||
case 'eliminated': out = describeEliminated(rules, state, ev, name); break;
|
case 'eliminated': out = describeEliminated(rules, state, ev, name); break;
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,78 @@ section('1. Rules integrity');
|
||||||
check('no branched rung is ever left with zero available techs (Cerebrai roll)', !cerebrai.everEmpty);
|
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
|
// Species sanity: at least one clear strength each, and no species is
|
||||||
// strictly better than another on every axis.
|
// strictly better than another on every axis.
|
||||||
for (const s of RULES.speciesList) {
|
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
|
// 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'));
|
const music = JSON.parse(readFileSync(join(root, 'data/masterofvega-music.json'), 'utf8'));
|
||||||
check('soundtrack declares tracks', Array.isArray(music.tracks) && music.tracks.length > 0);
|
const musicPools = [
|
||||||
for (const t of music.tracks ?? []) {
|
['fallback', music.fallback], ['menu', music.menu], ['peace', music.peace], ['combat', music.combat],
|
||||||
check(`soundtrack file ${t.file} exists`, existsSync(join(root, 'assets/music', t.file)), t.file);
|
['diplomacy.default', music.diplomacy?.default],
|
||||||
check(`soundtrack ${t.file} has artist and title`, !!t.artist && !!t.title);
|
...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}`);
|
||||||
}
|
}
|
||||||
if (typeof music.volume === 'number') {
|
|
||||||
check('soundtrack volume in range', music.volume > 0 && music.volume <= 1, `${music.volume}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1095,7 +1182,7 @@ section('4c. Population transport');
|
||||||
const cap = Logic.maxSendablePopulation(source);
|
const cap = Logic.maxSendablePopulation(source);
|
||||||
check('maxSendablePopulation leaves the floor behind', cap === before - 0.5);
|
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('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,
|
check('the source colony loses exactly what was sent', Math.abs(source.pop - 0.5) < 1e-9,
|
||||||
`${source.pop}`);
|
`${source.pop}`);
|
||||||
|
|
@ -1140,11 +1227,11 @@ section('4c. Population transport');
|
||||||
{
|
{
|
||||||
// Guardrails: a colony can never be fully emptied, and a request beyond
|
// Guardrails: a colony can never be fully emptied, and a request beyond
|
||||||
// what is available is clamped rather than refused outright.
|
// 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) {
|
if (target >= 0) {
|
||||||
const source = Logic.colonyAt(st, home);
|
const source = Logic.colonyAt(st, home);
|
||||||
const cap = Logic.maxSendablePopulation(source);
|
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('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);
|
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.
|
// 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) {
|
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 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;
|
source.pop = 0.5;
|
||||||
check('a colony already at the floor has nothing left to send',
|
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
|
// 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.
|
// 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) {
|
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
|
// Already in transit — see the note above about why this is
|
||||||
// state.fleets directly and not fleetsAt(home).
|
// state.fleets directly and not fleetsAt(home).
|
||||||
const fleet = st.fleets.find((f) => f.ships.some((s) => s.hullId === 'poptransport'));
|
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);
|
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
|
// --- VegaChat.js structural completeness: every diplomacy-capable species
|
||||||
// has every situation key the Audience screen's buttons and openers look
|
// 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
|
// 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',
|
'offerPeaceOpener', 'offerAllianceOpener', 'acceptOffer', 'rejectOffer',
|
||||||
'afterAccepted', 'afterRejected',
|
'afterAccepted', 'afterRejected',
|
||||||
'askPeople', 'peopleLore', 'askHomeworld', 'homeworldLore', 'askStory', 'storyLore',
|
'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
|
// Lore reply pools are meant to carry real variety — enforce a minimum
|
||||||
// beyond "non-empty" since pickLine's empty-pool fallback is silent and
|
// 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',
|
check('chatLog survives a round-trip',
|
||||||
JSON.stringify(back2.chatLog) === JSON.stringify(st.chatLog));
|
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);
|
const bad = JSON.parse(json);
|
||||||
bad.version = 99;
|
bad.version = 99;
|
||||||
check('a save from another version is rejected', Logic.deserialize(JSON.stringify(bad)) === null);
|
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`;
|
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) {
|
for (const c of st.colonies) {
|
||||||
if (!Number.isFinite(c.pop) || c.pop < 0) return `${label}: colony ${c.id} pop ${c.pop}`;
|
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);
|
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}`);
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue