Compare commits

..

2 Commits

Author SHA1 Message Date
Brian Fertig 7e7956d86d feat: add population transport and audience video replay
- Add Colony Transport ('poptransport') hull, dispatched directly in transit
  from a Send Population order rather than built from the queue. Shares the
  Troop Transport's hull art and uses the colony ship's commander clip.
- Implement VegaLogic sendPopulation/deliverPopulation flow: clamped to
  maxSendablePopulation (leaves 0.5 floor), creates poptransport fleet
  immediately, auto-delivers on arrival capped at destination max pop.
- Add Send Population button and overlay picker in colony view with
  destination list, ETA display, amount dial, and MAX button.
- Log populationSent/populationDelivered events in the turn report.
- Refactor VegaAudience mood visuals to play-once-and-hold-last-frame with
  a clickable replay badge, mirroring VegaColonyIntro's video contract.
- Seed human audience mood clips (angry/neutral/happy) into artwork config
  as placeholders for all diplomacy-capable species.
- Add verification tests for poptransport mechanics and video aliasing.
2026-08-05 21:11:37 -06:00
Brian Fertig 1251da186c feat(mastervega): overhaul contact system and add full-screen Audience diplomacy
- Rewrite contact detection from proximity-based (parsec-range) to
  star-scoped: empires now meet by sharing a system (colonies or fleets),
  making first-contact a visible event rather than a silent background
  check.

- Add full-screen Audience interface (VegaAudience.js) with species-specific
  mood videos (or letterboxed portrait fallbacks), a typewriter chat log,
  and contextual action buttons. Auto-opens on first contact via a queue
  system in MasterOfVegaGame.

- Add VegaChat.js with unique dialogue pools for all 9 diplomacy-capable
  species across 18+ situations (first contact, peace/alliance offers,
  war declarations, tech trades, accept/reject responses, etc.).

- Introduce held diplomacy offers: AI proposals to the human are queued
  (pendingOffers) instead of auto-resolved, with player Accept/Reject
  buttons in the Audience screen and automatic expiry after 6 turns.

- Add audienceVideos config and JIT-loading infrastructure in VegaArt.js.

- Add ship videos for kkrix and mekhan species.

- Simplify openDiplomacyScreen to a single "Seek Audience" button.

- Fix colony view to filter out 'starbase' from building display list.

- Add comprehensive verification in tools/verifyMasterOfVega.js for
  contact system, held offers, chat completeness, audience videos,
  and serialization of new state fields.

- Remove obsolete contactRange rule from mastervega-rules.json.
2026-08-05 19:59:58 -06:00
29 changed files with 2410 additions and 94 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -54,8 +54,24 @@
"cruiser": { "key": "vega-ship-umbrix-cruiser", "path": "assets/videos/vega/ship-umbrix-cruiser.mp4" },
"battleship": { "key": "vega-ship-umbrix-battleship", "path": "assets/videos/vega/ship-umbrix-battleship.mp4" }
},
"kkrix": null,
"mekhan": null,
"kkrix": {
"scout": { "key": "vega-ship-kkrix-scout", "path": "assets/videos/vega/ship-kkrix-scout.mp4" },
"colonyship": { "key": "vega-ship-kkrix-colonyship", "path": "assets/videos/vega/ship-kkrix-colony.mp4" },
"transport": { "key": "vega-ship-kkrix-transport", "path": "assets/videos/vega/ship-kkrix-transport.mp4" },
"frigate": { "key": "vega-ship-kkrix-frigate", "path": "assets/videos/vega/ship-kkrix-frigate.mp4" },
"destroyer": { "key": "vega-ship-kkrix-destroyer", "path": "assets/videos/vega/ship-kkrix-destroyer.mp4" },
"cruiser": { "key": "vega-ship-kkrix-cruiser", "path": "assets/videos/vega/ship-kkrix-cruiser.mp4" },
"battleship": { "key": "vega-ship-kkrix-battleship", "path": "assets/videos/vega/ship-kkrix-battleship.mp4" }
},
"mekhan": {
"scout": { "key": "vega-ship-mekhan-scout", "path": "assets/videos/vega/ship-mekhan-scout.mp4" },
"colonyship": { "key": "vega-ship-mekhan-colonyship", "path": "assets/videos/vega/ship-mekhan-colony.mp4" },
"transport": { "key": "vega-ship-mekhan-transport", "path": "assets/videos/vega/ship-mekhan-transport.mp4" },
"frigate": { "key": "vega-ship-mekhan-frigate", "path": "assets/videos/vega/ship-mekhan-frigate.mp4" },
"destroyer": { "key": "vega-ship-mekhan-destroyer", "path": "assets/videos/vega/ship-mekhan-destroyer.mp4" },
"cruiser": { "key": "vega-ship-mekhan-cruiser", "path": "assets/videos/vega/ship-mekhan-cruiser.mp4" },
"battleship": { "key": "vega-ship-mekhan-battleship", "path": "assets/videos/vega/ship-mekhan-battleship.mp4" }
},
"rrashaa": null,
"cerebrai": null,
"ssakar": null,
@ -112,6 +128,55 @@
"radiated": { "key": "vega-colony-radiated", "path": "assets/videos/vega/colony-radiated.mp4" }
},
"_audienceVideosReadme": "Audience-screen MOOD clips for the full-screen diplomacy interface — one per diplomacy-capable species x {angry, neutral, happy}, muted, looping. Recorded at 896x576 (1.5556:1, close to but not exactly the 3:2 the VegaAudience.js frame is cut to — the clip is scaled by WIDTH only, never setDisplaySize, so it sits slightly short of the frame's full height rather than being stretched). Lithox has no entry at all: canNegotiate() is permanently false for them (traits.diplomacy <= -100), so the Audience screen never opens for that species and a clip would never play. A species entirely absent (null) or a mood missing inside a recorded species falls back to that species' portraitVideos clip (256x256, letterboxed to 3:2), then to portraitStills — same three-tier fallback convention as makeSpeciesPortrait, so the roster fills one species (or one mood) at a time with no code change. Naming: assets/videos/vega/audience-<speciesId>-<mood>.mp4.",
"audienceVideos": {
"human": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
},
"kestrelli": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
},
"ursaal": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
},
"umbrix": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
},
"kkrix": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
},
"mekhan": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
},
"rrashaa": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
},
"cerebrai": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
},
"ssakar": {
"angry": { "key": "vega-audience-human-angry", "path": "assets/videos/vega/audience-human-angry.mp4" },
"neutral": { "key": "vega-audience-human-neutral", "path": "assets/videos/vega/audience-human-neutral.mp4" },
"happy": { "key": "vega-audience-human-happy", "path": "assets/videos/vega/audience-human-happy.mp4" }
}
},
"sheets": {
"ships": {
"key": "vega-ships",

View File

@ -85,6 +85,7 @@
{ "id": "scout", "name": "Scout", "role": "recon", "baseCost": 18, "baseHp": 10, "space": 0, "speedBonus": 2, "rangeBonus": 3, "frame": 0, "desc": "Unarmed, fast and long-legged. Peels back the dark." },
{ "id": "colonyship", "name": "Colony Ship", "role": "colony", "baseCost": 100, "baseHp": 18, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 1, "desc": "Carries a founding population. Consumed on arrival." },
{ "id": "transport", "name": "Troop Transport","role": "troops","baseCost": 40, "baseHp": 20, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 2, "troops": 4, "desc": "Four divisions of marines for boarding and invasion." },
{ "id": "poptransport","name": "Colony Transport","role": "colony","baseCost": 30,"baseHp": 20, "space": 0, "speedBonus": 0, "rangeBonus": 0, "frame": 2, "desc": "Ferries colonists between worlds you already hold. Dispatched directly from a colony's Send Population order, never built from the queue — shares the Troop Transport's hull picture (frame 2, the ships sheet is full at 8 columns)." },
{ "id": "frigate", "name": "Frigate", "role": "warship", "baseCost": 35, "baseHp": 18, "space": 6, "speedBonus": 1, "rangeBonus": 1, "frame": 3, "desc": "Cheap escort. Screens the line and hunts scouts." },
{ "id": "destroyer", "name": "Destroyer", "role": "warship", "baseCost": 90, "baseHp": 40, "space": 14, "speedBonus": 1, "rangeBonus": 0, "frame": 4, "desc": "The workhorse combatant of any mid-game fleet." },
{ "id": "cruiser", "name": "Cruiser", "role": "warship", "baseCost": 240, "baseHp": 100, "space": 32, "speedBonus": 0, "rangeBonus": 0, "frame": 5, "desc": "Heavy line ship. Expensive enough to hurt when it dies." },
@ -379,7 +380,6 @@
"shipUpkeepFraction": 0.02,
"colonyBaseCost": 100,
"reserveTransferLoss": 0.5,
"contactRange": 12,
"baseFuelRange": 5,
"starbaseRangeBonus": 4
},

View File

@ -30,6 +30,8 @@ import {
} from './VegaScreens.js';
import { openTurnReportScreen } from './VegaTurnReportScreen.js';
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
import { openAudienceScreen } from './VegaAudience.js';
import { claimAudienceContacts } from './VegaDiplomacy.js';
const SAVE_KEY = 'mastervega-save';
@ -42,6 +44,9 @@ export default class MasterOfVegaGame extends Phaser.Scene {
this.gameDef = data?.game ?? { slug: 'mastervega', name: 'Master of Vega' };
this.modalOpen = false;
this.busy = false;
// Empire indices with a freshly-claimed contact waiting for their
// full-screen Audience — see runAudienceQueue().
this.pendingAudiences = [];
}
create() {
@ -640,11 +645,36 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// Arm a click guard so the modal's own close button does not fall through
// to the star map underneath it.
factory(() => {
this.time.delayedCall(60, () => { this.modalOpen = false; });
this.time.delayedCall(60, () => {
this.modalOpen = false;
// Opportunistic drain: makes every ordinary modal-close in the game a
// safe place for a contact claimed mid-modal (colonize into a shared
// system, say) to finally surface, without touching each call site.
this.runAudienceQueue();
});
this.refreshAll();
});
}
// Manages modalOpen directly rather than going through openModal() itself:
// openModal's own done() defers `modalOpen = false` by 60ms
// (this.time.delayedCall above), so a synchronous recursive call from
// inside that same closure would see modalOpen still true and silently
// no-op — stalling the queue until some unrelated openModal call happened
// to drain it. Managing the flag here also chains back-to-back audiences
// with no artificial gap between them.
runAudienceQueue(onDone) {
if (this.modalOpen) { onDone?.(); return; }
if (!this.pendingAudiences.length) { onDone?.(); return; }
const otherIdx = this.pendingAudiences.shift();
this.modalOpen = true;
openAudienceScreen(this, this.rules, this.state, this.state.humanIndex, otherIdx, this.art, () => {
this.modalOpen = false;
this.refreshAll();
this.runAudienceQueue(onDone);
}, () => this.refreshAll());
}
// Clicking a star means one of two things, and which one depends entirely on
// whether a fleet of ours is selected: with a fleet in hand the star is a
// destination and the panel asks for confirmation, otherwise it is just
@ -698,7 +728,18 @@ export default class MasterOfVegaGame extends Phaser.Scene {
if (emp && !emp.explored[idx]) return;
this.openModal((done) => openSystemView(this, this.rules, this.state, idx, this.art, {
viewerIdx: this.state.humanIndex,
onChanged: () => this.refreshAll(),
onChanged: () => {
// Founding a colony onto a system another empire already shares can
// trigger contact without any fleet move — claim it here, but don't
// drain the queue: the system view (and the colony-founding vignette
// it is about to layer on top of, outside openModal) owns the screen
// right now. It surfaces passively once that whole chain eventually
// closes, via openModal's drain hook above.
this.pendingAudiences.push(
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
);
this.refreshAll();
},
onClose: done,
}));
}
@ -733,9 +774,17 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// tactical view before the engine resolves the rest. endEmpireTurn is told
// not to move again.
Logic.moveFleetsFor(this.rules, this.state, this.state.humanIndex);
this.playPlayerBattles(() => {
Logic.endEmpireTurn(this.rules, this.state, this.state.humanIndex, { skipMove: true });
this.runToHumanTurn();
// A fleet arriving into a shared system pops its Audience screen right
// away — before battles or the AI turn loop run, i.e. before anything
// else about this turn resolves. Meet before you fight.
this.pendingAudiences.push(
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
);
this.runAudienceQueue(() => {
this.playPlayerBattles(() => {
Logic.endEmpireTurn(this.rules, this.state, this.state.humanIndex, { skipMove: true });
this.runToHumanTurn();
});
});
}
@ -777,23 +826,31 @@ export default class MasterOfVegaGame extends Phaser.Scene {
if (this.state.over) { this.finishGame(); return; }
if (this.state.current === this.state.humanIndex) {
Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex);
const notable = this.processTurnEvents();
// Contact discovered during an AI empire's turn surfaces the moment
// control returns to the human — before the routine turn report, so
// "contact made" always reads as the bigger beat.
this.pendingAudiences.push(
...claimAudienceContacts(this.rules, this.state, this.state.humanIndex),
);
const finish = () => {
this.busy = false;
this.endTurnBtn.setEnabled(true);
};
if (notable.length) {
// openModal's own done() (MasterOfVegaGame.js:openModal) clears
// modalOpen and refreshes; finish() on top of that re-arms the End
// Turn button, which openModal has no reason to touch on its own.
this.openModal((done) => openTurnReportScreen(this, this.rules, this.state, notable, () => {
done();
this.runAudienceQueue(() => {
const notable = this.processTurnEvents();
if (notable.length) {
// openModal's own done() (MasterOfVegaGame.js:openModal) clears
// modalOpen and refreshes; finish() on top of that re-arms the End
// Turn button, which openModal has no reason to touch on its own.
this.openModal((done) => openTurnReportScreen(this, this.rules, this.state, notable, () => {
done();
finish();
}));
} else {
this.refreshAll();
finish();
}));
} else {
this.refreshAll();
finish();
}
}
});
return;
}
const e = this.state.current;
@ -829,6 +886,12 @@ export default class MasterOfVegaGame extends Phaser.Scene {
} else if (ev.type === 'combat' && (ev.attacker === me || ev.defender === me)) {
this.log(`Battle at ${star?.name}: ${ev.winner === 'attacker' ? name(ev.attacker) : name(ev.defender)} holds the field.`);
if (star) this.fx?.ping(star.x, star.y, 0xffa050);
} else if (ev.type === 'offerReceived' && ev.other === me) {
// A held peace/alliance offer — not urgent enough to interrupt with a
// popup, but discoverable next time Diplomacy is opened.
this.log(`The ${name(ev.empire)} seek an audience.`);
} else if (ev.type === 'populationDelivered') {
this.log(`${ev.amount.toFixed(1)} population arrived at ${star?.name}.`);
}
}
return notable;

View File

@ -683,6 +683,17 @@ export function hasColonyVideo(scene, typeId) {
return !!scene.cache.video?.exists(colonyVideoKey(typeId));
}
// Audience-screen mood clips (VegaAudience.js) — one per diplomacy-capable
// species x {angry, neutral, happy}. Kept here rather than in VegaAudience.js
// itself so the naming convention stays importable from a Phaser-free module
// (VegaAudience.js pulls in `phaser` for Color/Loader-event helpers, which
// tools/verifyMasterOfVega.js — a plain Node script — cannot resolve).
export const audienceVideoKey = (speciesId, mood) => `vega-audience-${speciesId}-${mood}`;
export function hasAudienceVideo(scene, speciesId, mood) {
return !!scene.cache.video?.exists(audienceVideoKey(speciesId, mood));
}
// Frame helpers — the one place that knows how the sheets are indexed.
export const shipFrame = (rules, speciesId, hullId) => {
const s = rules.species[speciesId];

View File

@ -0,0 +1,636 @@
// Master of Vega — the full-screen Audience interface: a large mood video of
// the species you're negotiating with on the left, and a running chat
// conversation with contextual action buttons on the right. This is the ONE
// place diplomacy actually happens now — openDiplomacyScreen (VegaScreens.js)
// is just a directory of "Seek Audience" buttons.
//
// Opened two ways: manually, from openDiplomacyScreen's Seek Audience button
// (in-place replace, same as that screen already does to itself), or
// automatically by the turn loop the moment first contact is made
// (MasterOfVegaGame.js's runAudienceQueue). Both paths call
// openAudienceScreen() directly.
//
// Chat log mechanism (masked scroll container, typewriter reveal) is adapted
// from Civilization's openDiplomacyScreen — the two games' diplomacy screens
// converged on the same idea independently, so this borrows the mechanism
// wholesale rather than re-deriving it. The content is NOT shared: every line
// here comes from VegaChat.js, keyed by species id, in that species' voice.
//
// Mood video: moodOf() (VegaDiplomacy.js) has five attitude tiers; this
// collapses them to the three the player actually authors clips for
// (angry/neutral/happy). Clips are 3:2 (VIDEO_W x VIDEO_H below) and, per
// VegaArt.sourceWidth's "trap 23" (docs/mastervega-build-plan.md), a fresh
// Phaser Video reports a 256px placeholder width — never 0 — until it decodes,
// so scale is always derived from sourceWidth(), re-applied on 'created' and
// 'playing'. All three mood clips for a species are JIT-loaded (like
// colonyVideos, not the eager shipVideos/portraitVideos), warmed the instant
// contact is claimed and again here on open. A species with no audience clips
// yet falls back to one shared, un-swapping portrait (video/still), letterboxed
// into the 3:2 frame with that species' own colour as the bar — so an art-less
// species is still fully playable.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
import { FONT, D } from './VegaScreens.js';
import {
sourceWidth, makeSpeciesPortrait, speciesSpeechClip, audienceVideoKey, hasAudienceVideo,
} from './VegaArt.js';
import { atWar, grantTech } from './VegaLogic.js';
import {
attitudeOf, moodOf, videoMood, canNegotiate, declareWar, proposeTreaty, respondToOffer, tradeTech,
} from './VegaDiplomacy.js';
import { CHAT, pickLine } from './VegaChat.js';
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
const MOODS = ['angry', 'neutral', 'happy'];
function audienceVideoPath(scene, speciesId, mood) {
return scene.cache.json.get('mastervega-artwork')?.audienceVideos?.[speciesId]?.[mood]?.path ?? null;
}
// --------------------------------------------------------------------------
// JIT loading — one-for-one with VegaColonyIntro.js's warmBytes/
// ensureColonyVideo pair, just muted (these clips carry no audio) and warming
// all three moods per species rather than one clip per planet type.
const warmed = new WeakMap();
function warmBytes(scene, key, path) {
let store = warmed.get(scene);
if (!store) { store = new Map(); warmed.set(scene, store); }
if (store.has(key)) return;
try {
const el = document.createElement('video');
el.preload = 'auto';
el.muted = true;
el.setAttribute('playsinline', 'playsinline');
el.src = path;
el.load();
store.set(key, el);
} catch (err) { /* prefetch is an optimisation, never a requirement */ }
}
const inFlight = new WeakMap();
function pendingFor(scene) {
let set = inFlight.get(scene);
if (!set) { set = new Set(); inFlight.set(scene, set); }
return set;
}
function ensureOneAudienceVideo(scene, speciesId, mood, onDone) {
const key = audienceVideoKey(speciesId, mood);
const path = audienceVideoPath(scene, speciesId, mood);
if (path) warmBytes(scene, key, path);
if (hasAudienceVideo(scene, speciesId, mood) || !path) { onDone?.(); return; }
const pending = pendingFor(scene);
const doneEvent = `filecomplete-video-${key}`;
if (onDone) {
let onLoad;
let onError;
const settle = () => {
scene.load.off(doneEvent, onLoad);
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
onDone();
};
onLoad = () => settle();
onError = (file) => { if (file?.key === key) settle(); };
scene.load.once(doneEvent, onLoad);
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, onError);
}
if (pending.has(key)) return;
pending.add(key);
let clearDone;
let clearError;
const clear = () => {
scene.load.off(doneEvent, clearDone);
scene.load.off(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError);
pending.delete(key);
};
clearDone = () => clear();
clearError = (file) => { if (file?.key === key) clear(); };
scene.load.once(doneEvent, clearDone);
scene.load.on(Phaser.Loader.Events.FILE_LOAD_ERROR, clearError);
scene.load.video(key, path, true); // noAudio: true — these clips are silent
if (!scene.load.isLoading()) scene.load.start();
}
/**
* Warm all three mood clips for a species. Mood can shift mid-conversation, so
* all three are worth having ready rather than only the one currently shown.
* Called speculatively the instant a fresh contact is claimed, and again here
* on open (covers a manual Seek Audience click long after original contact).
*/
export function ensureAudienceVideos(scene, speciesId, onReady = null) {
let remaining = MOODS.length;
const settle = () => { remaining -= 1; if (remaining <= 0) onReady?.(); };
for (const mood of MOODS) ensureOneAudienceVideo(scene, speciesId, mood, settle);
}
// --------------------------------------------------------------------------
// The mood visual: three pre-built Video objects (built lazily on first use,
// per mood), hide+pause on swap, never destroy+recreate — safe and cheap per
// the verified Phaser 3.90 fact this game already relies on elsewhere (every
// Video owns one HTMLVideoElement; duplicates cost only decode time). A
// species with no dedicated clips at all gets one shared, non-swapping
// fallback instead of three redundant copies of the same portrait.
function buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h) {
const container = scene.add.container(x, y);
const spec = rules.species[speciesId];
const barColor = Phaser.Display.Color.HexStringToColor(spec.color).color;
container.add(scene.add.rectangle(0, 0, w, h, barColor, 0.22));
container.add(makeSpeciesPortrait(scene, rules, art, speciesId, 0, 0, h));
return container;
}
// `parent` is the container these objects are added to (so they inherit the
// screen's depth tier and are torn down for free when it is destroyed —
// Container.destroy() recurses into its children, same as VegaColonyIntro's
// video: "destroys the video with it, which is what stops its decoder").
//
// Unlike the colony-founding clip, these carry NO audio at all — muted for
// their whole life, never unmuted — so there is no applyAudio()-style
// re-assertion to worry about. They DO share the colony clip's "plays once,
// holds the last frame, replay badge" contract: `onEndedChanged(ended)`
// fires whenever the CURRENTLY shown video's ended state should be reflected
// by the caller's replay badge — on first build, on every show() (so
// swapping back to an already-finished mood re-arms the badge immediately),
// on the video's own 'complete' event, and on an explicit replay() call.
function createMoodVisual(scene, rules, art, speciesId, x, y, w, h, parent, onEndedChanged) {
const anyClip = MOODS.some((m) => hasAudienceVideo(scene, speciesId, m));
if (!anyClip) {
// Nothing to visually swap, but `current` still has to track the real
// mood — otherwise afterAction()'s "did the tier change" check compares
// against a value that never updates and fires the glow pulse on every
// single action regardless of whether the mood actually moved. The
// fallback (a species portrait, video or still) is not a "clip" in the
// play-once sense, so there is nothing to replay.
const shared = buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h);
parent.add(shared);
let current = null;
return {
show(mood) { current = mood; }, get current() { return current; }, replay() {},
};
}
const built = {}; // mood -> { obj, ended }
let current = null;
function build(mood) {
const obj = scene.add.video(x, y, audienceVideoKey(speciesId, mood));
obj.setMute(true);
// Plays once and holds the last frame — an ended HTMLVideoElement stops
// on its final frame and the texture keeps it, so ending IS the still.
// Same contract as VegaColonyIntro's clip, minus the audio.
obj.setLoop(false);
const fit = () => obj.setScale(w / sourceWidth(obj, w));
fit();
obj.on('created', fit);
obj.on('playing', fit);
const entry = { obj, ended: false };
// Not `once` — a replayed clip fires 'complete' again every time it ends.
obj.on('complete', () => {
entry.ended = true;
if (current === mood) onEndedChanged?.(true);
});
obj.play(false);
parent.add(obj);
built[mood] = entry;
return entry;
}
return {
show(mood) {
if (current === mood) return;
if (built[current]) { built[current].obj.setVisible(false); built[current].obj.pause(); }
let entry = built[mood];
if (!entry) entry = build(mood);
else {
entry.obj.setVisible(true);
// Only resume playback if it never finished — an ended video's
// `.play()`/`.resume()` seeks back to 0 on some browsers, which
// would silently replay it just for being swapped back to.
if (!entry.ended) entry.obj.resume();
}
current = mood;
onEndedChanged?.(entry.ended);
},
get current() { return current; },
replay() {
const entry = built[current];
if (!entry) return;
entry.ended = false;
onEndedChanged?.(false);
// A genuine replay: Phaser's completeHandler cleared `_playCalled` when
// the clip ended, and an ended element seeks back to 0 of its own
// accord when play() is called on it (verified against VegaColonyIntro's
// identical replay mechanism).
entry.obj.play(false);
},
};
}
/** Corner ticks — matches modalShell's/VegaColonyIntro's HUD-read-out look. */
function cornerTicks(scene, x, y, w, h, colour, len = 20) {
const g = scene.add.graphics();
g.lineStyle(2.5, colour, 0.9);
for (const [cx, cy, dx, dy] of [
[x, y, 1, 1], [x + w, y, -1, 1], [x, y + h, 1, -1], [x + w, y + h, -1, -1],
]) {
g.lineBetween(cx, cy, cx + dx * len, cy);
g.lineBetween(cx, cy, cx, cy + dy * len);
}
return g;
}
// --------------------------------------------------------------------------
const VIDEO_X = 70;
const VIDEO_Y = 190;
const VIDEO_W = 1080;
const VIDEO_H = 720; // 3:2
/** The replay badge, inset from the video's bottom-right corner. */
const REPLAY_R = 26;
const REPLAY_PAD = 18;
const CHAT_X = VIDEO_X + VIDEO_W + 60;
const CHAT_W = GAME_WIDTH - CHAT_X - 70;
const CHAT_Y = VIDEO_Y;
const CHAT_H = 620;
const BTN_Y = CHAT_Y + CHAT_H + 55;
const BTN_W = 190;
const BTN_H = 60;
const BTN_GAP = 16;
/**
* Full-screen Audience interface with `otherIdx`, played from `me`'s
* (always the human's) point of view.
*/
export function openAudienceScreen(scene, rules, state, me, otherIdx, art, onClose, onChanged) {
const other = state.empires[otherIdx];
const spec = rules.species[other.speciesId];
const speciesId = other.speciesId;
const chat = CHAT[speciesId];
const vars = { you: state.empires[me].name, me: other.name };
const speciesColor = Phaser.Display.Color.HexStringToColor(spec.color).color;
ensureAudienceVideos(scene, speciesId);
// Declared up front — pushChat()/say() both guard on it, and pushChat() is
// called synchronously below (the opening dialogue) well before close()'s
// own declaration used to sit.
let closed = false;
const root = scene.add.container(0, 0).setDepth(D.modal).setAlpha(0);
root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.94)
.setOrigin(0, 0).setInteractive());
// Ambient background: a soft gradient tinted to the species' own colour.
const bgTop = Phaser.Display.Color.HexStringToColor(spec.color).darken(80).color;
const bgBottom = Phaser.Display.Color.HexStringToColor(spec.color).darken(60).color;
const bgG = scene.add.graphics();
bgG.fillGradientStyle(bgTop, bgTop, bgBottom, bgBottom, 1);
bgG.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
root.add(bgG);
// --- header
root.add(scene.add.text(VIDEO_X, 44, spec.name.toUpperCase(), {
fontFamily: FONT, fontSize: '46px', color: spec.color,
}));
const statusTxt = scene.add.text(VIDEO_X, 100, '', {
fontFamily: FONT, fontSize: '18px', color: '#9fb6cc',
});
root.add(statusTxt);
const closeBtn = new Button(scene, GAME_WIDTH - 80, 60, '✕', () => close(), { width: 56, height: 48, variant: 'ghost' });
root.add(closeBtn);
function statusLine() {
const treaty = state.empires[me].treaties[otherIdx] ?? 'none';
const att = attitudeOf(state, otherIdx, me);
statusTxt.setText(`${treaty === 'war' ? 'AT WAR' : treaty.toUpperCase()} · attitude ${att} (${moodOf(att)})`);
}
statusLine();
// --- video frame
const videoCx = VIDEO_X + VIDEO_W / 2;
const videoCy = VIDEO_Y + VIDEO_H / 2;
const frame = scene.add.container(0, 0).setAlpha(0).setScale(0.95);
root.add(frame);
frame.add(scene.add.rectangle(VIDEO_X - 8, VIDEO_Y - 8, VIDEO_W + 16, VIDEO_H + 16, PANEL, 0.55)
.setOrigin(0, 0).setStrokeStyle(1.5, ACCENT, 0.5));
frame.add(cornerTicks(scene, VIDEO_X - 8, VIDEO_Y - 8, VIDEO_W + 16, VIDEO_H + 16, speciesColor, 26));
// --- replay badge: armed whenever the currently-shown mood clip has
// played through once. Built once, over the whole picture (clicking
// anywhere on it replays), same shape as VegaColonyIntro's — minus audio,
// since these clips are muted for their whole life.
const replayHit = scene.add.rectangle(videoCx, videoCy, VIDEO_W, VIDEO_H, 0xffffff, 0.001)
.setInteractive({ useHandCursor: true }).setVisible(false);
const replayBadge = scene.add.container(
videoCx + VIDEO_W / 2 - REPLAY_PAD - REPLAY_R,
videoCy + VIDEO_H / 2 - REPLAY_PAD - REPLAY_R,
).setVisible(false);
replayBadge.add(scene.add.circle(0, 0, REPLAY_R, PANEL, 0.82).setStrokeStyle(2, ACCENT, 0.95));
replayBadge.add(scene.add.triangle(3, 0, 0, 0, 0, 22, 19, 11, 0xe8f4ff));
function armReplay(on) {
replayHit.setVisible(on);
replayBadge.setVisible(on).setScale(1);
if (!on) return;
replayBadge.setAlpha(0);
scene.tweens.add({ targets: replayBadge, alpha: 1, duration: 260, ease: 'Cubic.easeOut' });
}
replayHit.on('pointerover', () => replayBadge.setScale(1.1));
replayHit.on('pointerout', () => replayBadge.setScale(1));
replayHit.on('pointerup', () => moodVisual.replay());
const moodVisual = createMoodVisual(scene, rules, art, speciesId, videoCx, videoCy, VIDEO_W, VIDEO_H, frame,
armReplay);
// Added after the video(s) so the hit zone and badge sit on top of them —
// a Container renders its children in insertion order and ignores depth.
frame.add(replayHit);
frame.add(replayBadge);
const glow = scene.add.graphics().setAlpha(0);
frame.add(glow);
scene.tweens.add({ targets: frame, alpha: 1, scale: 1, duration: 220, ease: 'Cubic.easeOut' });
function pulseGlow(improved) {
const colour = improved ? 0x6fe08a : 0xe0616f;
glow.clear();
glow.lineStyle(6, colour, 1);
glow.strokeRect(VIDEO_X - 8, VIDEO_Y - 8, VIDEO_W + 16, VIDEO_H + 16);
glow.setAlpha(1);
scene.tweens.add({ targets: glow, alpha: 0, duration: 900, ease: 'Cubic.easeIn' });
}
// --- chat log (adapted from Civilization's openDiplomacyScreen)
const chatBg = scene.add.rectangle(CHAT_X + CHAT_W / 2, CHAT_Y + CHAT_H / 2, CHAT_W, CHAT_H, 0x000000, 0.55)
.setStrokeStyle(2, ACCENT, 0.4);
root.add(chatBg);
const msgContainer = scene.add.container(0, 0);
root.add(msgContainer);
const chatMaskG = scene.make.graphics({ x: 0, y: 0, add: false });
chatMaskG.fillStyle(0xffffff);
chatMaskG.fillRect(CHAT_X, CHAT_Y, CHAT_W, CHAT_H);
msgContainer.setMask(chatMaskG.createGeometryMask());
let msgY = CHAT_Y + 16;
let scrollUp = 0;
let overflow = 0;
const typeQueue = [];
let typing = null;
let indicator = null;
function historyFor() {
state.chatLog ??= {};
return (state.chatLog[otherIdx] ??= []);
}
function applyScroll() {
overflow = Math.max(0, msgY - (CHAT_Y + CHAT_H - 16));
scrollUp = Math.min(Math.max(scrollUp, 0), overflow);
msgContainer.y = -overflow + scrollUp;
}
chatBg.setInteractive();
chatBg.on('wheel', (pointer, dx, dy) => { scrollUp -= dy * 0.5; applyScroll(); });
function addChatText(who, text) {
const isPlayer = who === 'p';
const txt = scene.add.text(
isPlayer ? CHAT_X + CHAT_W - 16 : CHAT_X + 16, msgY, text, {
fontFamily: FONT, fontSize: '18px',
color: isPlayer ? state.empires[me].color : spec.color,
align: isPlayer ? 'right' : 'left',
wordWrap: { width: CHAT_W - 60 }, lineSpacing: 4,
},
).setOrigin(isPlayer ? 1 : 0, 0);
msgY += txt.height + 16;
msgContainer.add(txt);
applyScroll();
return txt;
}
function pumpTypeQueue() {
if (typing || !typeQueue.length) return;
// Capture this entry locally rather than reading the shared `typing`
// variable inside the callback: closing the screen (or a race between
// this timer's own completion and stopTyping() elsewhere) can null
// `typing` out from under an already-scheduled callback, which crashed
// on `typing.full` when the stale callback still fired.
const entry = typeQueue.shift();
typing = entry;
entry.timer = scene.time.addEvent({
delay: 16,
loop: true,
callback: () => {
if (entry.done) return;
entry.i = Math.min(entry.full.length, entry.i + 2);
entry.txt.setText(entry.full.slice(0, entry.i));
if (entry.i >= entry.full.length) {
entry.done = true;
entry.timer.remove();
if (typing === entry) typing = null;
pumpTypeQueue();
}
},
});
}
function stopTyping() {
if (typing) { typing.done = true; typing.timer?.remove(); }
typing = null;
typeQueue.length = 0;
}
function pushChat(who, text) {
if (!text || closed) return;
const hist = historyFor();
hist.push({ who, text });
if (hist.length > 50) hist.splice(0, hist.length - 50);
scrollUp = 0;
const txt = addChatText(who, text);
txt.setText('');
typeQueue.push({ txt, full: text, i: 0 });
pumpTypeQueue();
}
/** A brief "···" beat before a species line lands — never for the player's own. */
function say(text) {
if (!text || closed) return;
indicator?.destroy();
indicator = scene.add.text(CHAT_X + 16, msgY, '···', { fontFamily: FONT, fontSize: '20px', color: spec.color });
msgContainer.add(indicator);
scene.tweens.add({ targets: indicator, alpha: 0.25, duration: 260, yoyo: true, repeat: -1 });
scene.time.delayedCall(380, () => {
indicator?.destroy();
indicator = null;
pushChat('o', text);
});
}
function renderChatHistory() {
stopTyping();
msgContainer.removeAll(true);
msgY = CHAT_Y + 16;
scrollUp = 0;
for (const m of historyFor()) addChatText(m.who, m.text);
}
// --- opening dialogue
const history = historyFor();
const isFirstEver = history.length === 0;
if (isFirstEver) {
pushChat('o', pickLine(chat.firstContact, vars));
enqueueSpeech(speciesSpeechClip(speciesId), null, { force: true });
} else {
renderChatHistory();
pushChat('o', pickLine(chat.opener[videoMood(attitudeOf(state, otherIdx, me))], vars));
}
const openingOffer = state.empires[me].pendingOffers[otherIdx];
if (openingOffer) {
const key = openingOffer.kind === 'peace' ? chat.offerPeaceOpener : chat.offerAllianceOpener;
pushChat('o', pickLine(key, vars));
}
moodVisual.show(videoMood(attitudeOf(state, otherIdx, me)));
// --- action buttons
const buttonRow = scene.add.container(0, 0);
root.add(buttonRow);
function afterAction() {
statusLine();
const att = attitudeOf(state, otherIdx, me);
const newMood = videoMood(att);
if (newMood !== moodVisual.current) {
const improved = MOODS.indexOf(newMood) > MOODS.indexOf(moodVisual.current ?? 'neutral');
moodVisual.show(newMood);
pulseGlow(improved);
}
renderButtons();
onChanged?.();
}
function doPending(accept) {
pushChat('p', pickLine(accept ? chat.acceptOffer : chat.rejectOffer, vars));
respondToOffer(rules, state, me, otherIdx, accept);
say(pickLine(accept ? chat.afterAccepted : chat.afterRejected, vars));
afterAction();
}
function doPropose(kind) {
pushChat('p', pickLine(kind === 'peace' ? chat.proposePeace : chat.proposeAlliance, vars));
const accepted = proposeTreaty(rules, state, me, otherIdx, kind);
const key = kind === 'peace'
? (accepted ? chat.replyPeaceAccept : chat.replyPeaceReject)
: (accepted ? chat.replyAllianceAccept : chat.replyAllianceReject);
say(pickLine(key, vars));
afterAction();
}
function doDeclareWar() {
pushChat('p', pickLine(chat.declareWar, vars));
declareWar(rules, state, me, otherIdx);
say(pickLine(chat.replyWarDeclared, vars));
afterAction();
}
function doTechTrade(giveId, wantId) {
pushChat('p', pickLine(chat.tradeTechOffer, { ...vars, tech: rules.techs[wantId].name }));
const ok = tradeTech(rules, state, me, otherIdx, giveId, wantId);
if (ok) {
grantTech(rules, state, otherIdx, giveId, 'trade');
grantTech(rules, state, me, wantId, 'trade');
}
say(pickLine(ok ? chat.replyTechAccept : chat.replyTechReject, vars));
afterAction();
}
function pickFromList(title, ids, onPick) {
const box = scene.add.container(0, 0);
root.add(box);
const bx = GAME_WIDTH / 2;
const by = GAME_HEIGHT / 2;
const h = Math.min(520, 100 + ids.length * 34);
box.add(scene.add.rectangle(bx, by, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001).setOrigin(0.5).setInteractive());
box.add(scene.add.rectangle(bx, by, 420, h, PANEL, 0.98).setStrokeStyle(2, ACCENT));
box.add(scene.add.text(bx, by - h / 2 + 26, title, { fontFamily: FONT, fontSize: '19px', color: '#ffd88a' }).setOrigin(0.5));
ids.slice(0, 12).forEach((id, i) => {
const t = scene.add.text(bx, by - h / 2 + 66 + i * 34, rules.techs[id].name, {
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); onPick(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);
}
function openTechPicker(giveOptions, wantOptions) {
pickFromList('OFFER WHICH TECHNOLOGY?', giveOptions, (giveId) => {
pickFromList('ASK FOR WHICH TECHNOLOGY?', wantOptions, (wantId) => doTechTrade(giveId, wantId));
});
}
function renderButtons() {
buttonRow.removeAll(true);
const offer = state.empires[me].pendingOffers[otherIdx];
const items = [];
if (offer) {
items.push(['Accept', () => doPending(true)]);
items.push(['Reject', () => doPending(false)]);
} else if (!canNegotiate(rules, state, me, otherIdx)) {
if (!atWar(state, me, otherIdx)) items.push(['Declare War', doDeclareWar]);
} else if (atWar(state, me, otherIdx)) {
items.push(['Sue for Peace', () => doPropose('peace')]);
} else {
items.push(['Declare War', doDeclareWar]);
if (state.empires[me].treaties[otherIdx] !== 'alliance') {
items.push(['Propose Alliance', () => doPropose('alliance')]);
}
const give = Object.keys(state.empires[me].known).filter((t) => !state.empires[otherIdx].known[t]);
const want = Object.keys(state.empires[otherIdx].known).filter((t) => !state.empires[me].known[t]);
if (give.length && want.length) items.push(['Trade Tech', () => openTechPicker(give, want)]);
}
const perRow = 3;
items.forEach(([label, fn], i) => {
const col = i % perRow;
const row = Math.floor(i / perRow);
const x = CHAT_X + col * (BTN_W + BTN_GAP) + BTN_W / 2;
const y = BTN_Y + row * (BTN_H + BTN_GAP) + BTN_H / 2;
buttonRow.add(new Button(scene, x, y, label, fn, { width: BTN_W, height: BTN_H, fontSize: 16 }));
});
}
renderButtons();
// --- close
function close() {
if (closed) return;
closed = true;
scene.tweens.add({
targets: root,
alpha: 0,
duration: 200,
ease: 'Cubic.easeIn',
onComplete: () => {
stopTyping();
indicator?.destroy();
chatMaskG.destroy();
// Destroys the mood video(s) with it, which is what stops their
// decoders — same contract as VegaColonyIntro's root.destroy().
root.destroy();
onClose?.();
},
});
}
scene.tweens.add({ targets: root, alpha: 1, duration: 220, ease: 'Cubic.easeOut' });
return { close };
}

View File

@ -0,0 +1,889 @@
// Master of Vega — canned diplomacy chat lines for the full-screen Audience
// interface. Unlike Civilization's shared generic pools, EVERY pool here is
// keyed by species id first: each of the nine diplomacy-capable species has
// its own full situation set, written in that species' voice (drawn from its
// `desc`/`strengths`/`weaknesses`/`traits.diplomacy` in mastervega-rules.json).
// Lithox (diplomacy -100) is diplomacy-incapable — canNegotiate() is
// permanently false for them, so the Audience screen never opens for that
// species; they get one small `contactOnly` pool used only by the ordinary
// turn-report line, never by a chat window.
//
// pickLine() is identical to CivilizationChat.js's — a random variant with
// {token} substitution. Tokens in use here:
// {you} — the human empire's display name
// {me} — this species' empire name
// {tech} — a technology name, for the tech-trade situations
// Resolved (already-substituted) text is what VegaAudience.js pushes into
// state.chatLog, not the templates.
//
// Situation keys, all present for every diplomacy-capable species:
// opener: { angry, neutral, happy } — shown whenever an Audience screen
// opens and it is NOT the very first time this species has been met.
// firstContact — used once, only by the turn-loop's auto-open on the
// turn contact is made; never shown again after that.
// proposePeace / proposeAlliance / declareWar / tradeTechOffer — the
// PLAYER's own line, spoken in a register appropriate to who they are
// addressing (blunter with Ursaal, more formal with Kestrelli, etc.).
// replyPeaceAccept / replyPeaceReject / replyAllianceAccept /
// replyAllianceReject / replyTechAccept / replyTechReject — this
// species' reaction to something the PLAYER proposed.
// replyWarDeclared — this species' reaction to the PLAYER declaring war
// on them.
// offerPeaceOpener / offerAllianceOpener — this species' own opening line
// when THEY are the one proposing (the pending-offer flow: the player
// sees this line, then Accept/Reject).
// acceptOffer / rejectOffer — the PLAYER's line when answering a pending
// offer from this species.
// afterAccepted / afterRejected — this species' reaction to the player's
// answer to their own offer.
export function pickLine(pool, vars = {}) {
if (!pool?.length) return '';
const t = pool[Math.floor(Math.random() * pool.length)];
return t.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? vars[k] : `{${k}}`));
}
export const CHAT = {
// ------------------------------------------------------------------
// Human (diplomacy 40) — "Traders and talkers." Warm, transactional,
// always framing things in terms of mutual benefit.
human: {
opener: {
angry: [
'Let\'s not pretend this is a social call, {me}. What will it take to fix this?',
'I know where we stand, {me}. I\'m still willing to talk numbers if you are.',
],
neutral: [
'Good to see you, {me}. Let\'s find something that works for both of us.',
'Straight to business, {me} — what can we arrange today?',
],
happy: [
'Always a pleasure to talk terms with your people, {me}. Numbers make friends easier than speeches do.',
'Every meeting with you turns a profit one way or another, {me}. What have you got?',
],
},
firstContact: [
'First contact, {me} — and already I\'m thinking about what we can trade.',
'Well met, {me}. Humanity has never met a neighbor it couldn\'t do business with.',
],
proposePeace: [
'This war is bad for business on both sides, {me}. Let\'s call it even and move on.',
'No one profits from a battlefield, {me}. I\'m offering peace, plainly.',
],
proposeAlliance: [
'An alliance benefits us both, {me} — and I do mean "benefits" literally.',
'Let\'s make it official, {me}. Our fleets, your borders, everybody sleeps easier.',
],
declareWar: [
'I\'ve run the numbers, {me}, and war comes out ahead this time. Nothing personal.',
'This is a business decision, {me}. Consider it declared.',
],
tradeTechOffer: [
'I have something you want, {me}, and I suspect you have something I want. Let\'s trade.',
'A fair exchange of technology, {me} — good for your labs, good for mine.',
],
replyPeaceAccept: [
'Smart. I\'ll have the paperwork drawn up before you change your mind, {you}.',
'Agreed, {you} — peace is good business.',
],
replyPeaceReject: [
'Not yet, {you}. Come back when the numbers favor me less obviously.',
'I\'ll pass for now, {you}. Ask again when this war has cost you more.',
],
replyAllianceAccept: [
'Deal, {you}. Welcome to the ledger.',
'I like the terms, {you}. Let\'s make it official.',
],
replyAllianceReject: [
'Not yet, {you} — I don\'t sign anything I haven\'t priced out fully.',
'Ask me again once I trust the exchange rate, {you}.',
],
replyWarDeclared: [
'Bold move, {you}. I hope you costed this out better than it looks.',
'Fine, {you} — war it is. I\'ll send you an invoice when it\'s over.',
],
replyTechAccept: [
'Now that\'s a trade I can live with, {you}.',
'Pleasure doing business, {you}. Everybody walks away richer.',
],
replyTechReject: [
'Not a fair split, {you}. Bring me a better offer.',
'I\'ll pass, {you} — that trade favors you a little too much.',
],
offerPeaceOpener: [
'Let\'s be practical, {you}. This war is costing us both — I\'m offering to end it.',
'I\'m proposing peace, {you}. Purely a matter of the numbers.',
],
offerAllianceOpener: [
'I think it\'s time we made this official, {you}. An alliance, on fair terms.',
'You\'ve proven a reliable neighbor, {you}. Let\'s formalize it.',
],
acceptOffer: [
'Good numbers. I accept, {me}.',
'That works for me, {me} — agreed.',
],
rejectOffer: [
'Not this time, {me}. The terms don\'t add up.',
'I\'ll have to decline, {me}.',
],
afterAccepted: [
'A pleasure doing business with you, {you}.',
'Good. I\'ll hold up my end, {you}.',
],
afterRejected: [
'Your loss, {you}. The offer won\'t stay on the table forever.',
'Suit yourself, {you}. I\'ll remember this.',
],
},
// ------------------------------------------------------------------
// Kestrelli (diplomacy 10) — "Avian aristocrats bred for flight." Elegant,
// faintly condescending, fond of flight/altitude metaphors, call the
// player "groundling" when annoyed and by title when pleased.
kestrelli: {
opener: {
angry: [
'You test our patience, groundling. Speak, and speak carefully.',
'Our talons are not idle, {you}. Choose your words as though they matter.',
],
neutral: [
'You approach at a civil altitude. State your business, groundling.',
'The Kestrelli receive you, {you}. What brings you to our aeries?',
],
happy: [
'A pleasant wind carries you to us, {you}. You are welcome at our court.',
'Few among the stars fly as gracefully in negotiation as you have, {you}.',
],
},
firstContact: [
'Another flightless people, discovered. We shall see if you are worth the sky above you, {you}.',
'The Kestrelli mark this meeting, {you}. Rise to our expectations or fall beneath our notice.',
],
proposePeace: [
'Let us fold our wings, {me}. This war profits neither aerie.',
'I offer you an end to this, {me} — one worthy of two proud peoples.',
],
proposeAlliance: [
'Fly beside us, {me}, not merely near us. I propose an alliance.',
'Our formations would fly stronger together, {me}. Consider an alliance.',
],
declareWar: [
'You fly too close to our nests, {me}. This ends in feathers and fire.',
'The Kestrelli do not warn twice, {me}. This is war.',
],
tradeTechOffer: [
'A gift with a return expected, {me} — trade our knowledge in kind.',
'Even aristocrats barter, {me}. I offer a fair exchange of secrets.',
],
replyPeaceAccept: [
'We accept, {you}. Even the proudest wings tire of war.',
'Very well, {you}. Let the skies between us go quiet.',
],
replyPeaceReject: [
'No, {you}. We have not yet been humbled enough to want it.',
'Our wings are not so weary as that, {you}. Ask again when they are.',
],
replyAllianceAccept: [
'Fly with us, {you}. The Kestrelli accept.',
'An honor rarely offered, {you} — and rarely refused when it is worth taking.',
],
replyAllianceReject: [
'You have not yet earned a place in our formation, {you}.',
'Prove your worth further, {you}, before you ask to fly beside us.',
],
replyWarDeclared: [
'So the groundling bares its teeth. We shall enjoy clipping them, {you}.',
'You have declared war on the sky itself, {you}. A poor wager.',
],
replyTechAccept: [
'A fair trade of feathers for feathers, {you}. Accepted.',
'Well bartered, {you}. We accept the exchange.',
],
replyTechReject: [
'You ask more than you offer, {you}. We decline.',
'A crooked trade, {you}. The Kestrelli see it plainly and refuse.',
],
offerPeaceOpener: [
'Even we tire of this war, {you}. We offer its end.',
'The Kestrelli propose peace, {you} — take it before pride reconsiders.',
],
offerAllianceOpener: [
'You have flown well beside us, {you}. We offer a formal alliance.',
'The aerie opens to you, {you}. Join our formation.',
],
acceptOffer: [
'Gracefully offered. I accept, {me}.',
'Well spoken. Agreed, {me}.',
],
rejectOffer: [
'Not yet, {me}. The terms fall short of your reputation.',
'I must decline, {me} — for now.',
],
afterAccepted: [
'Wisely chosen, {you}. Our formations align.',
'You fly well, {you}. This pleases the aerie.',
],
afterRejected: [
'A groundling\'s answer, {you}. We shall remember the insult.',
'Then the sky remains closed to you, {you}.',
],
},
// ------------------------------------------------------------------
// Ursaal (diplomacy -20) — "Heavy-worlders." Blunt, physical, unbothered
// by pleasantries, everything framed as strength and endurance.
ursaal: {
opener: {
angry: [
'Speak plainly, {you}. My patience has weight, and it is running out.',
'You stand before us uninvited, {you}. Say your piece.',
],
neutral: [
'Ursaal do not waste breath on small talk, {you}. What do you want?',
'You have our attention, {you}. Use it well.',
],
happy: [
'You have earned a measure of respect, {you}. Speak.',
'Few outsiders stand as steady as you have, {you}. I am listening.',
],
},
firstContact: [
'A new people, found. Weigh yourselves against us and be honest about what you see, {you}.',
'We meet you as we meet any gravity, {you} — by testing it.',
],
proposePeace: [
'Enough weight has been thrown, {me}. I offer an end to it.',
'This war grinds both our peoples down, {me}. Let it stop.',
],
proposeAlliance: [
'Stand with us, {me}, and nothing will move either of us.',
'Two heavy worlds together are harder to topple than one, {me}.',
],
declareWar: [
'You have pushed against us long enough, {me}. Now we push back.',
'War, {me}. We do not shout about it twice.',
],
tradeTechOffer: [
'A trade, {me} — weight for weight, fair on both sides.',
'We offer knowledge in exchange for knowledge, {me}. Nothing more, nothing less.',
],
replyPeaceAccept: [
'Agreed, {you}. Even we tire of this weight.',
'The war ends here, {you}. Good.',
],
replyPeaceReject: [
'No, {you}. We are not yet tired enough of standing.',
'Not yet, {you}. Come back when you have earned it.',
],
replyAllianceAccept: [
'Stand with us then, {you}. Agreed.',
'Good. Two heavy worlds are harder to move than one, {you}.',
],
replyAllianceReject: [
'You have not proven your footing yet, {you}.',
'Not yet, {you}. Show us you can bear the weight first.',
],
replyWarDeclared: [
'So be it, {you}. We have broken heavier things than you.',
'War, then, {you}. We will not be the ones who tire first.',
],
replyTechAccept: [
'Fair weight for fair weight, {you}. Agreed.',
'A trade worth making, {you}. Done.',
],
replyTechReject: [
'The scales do not balance, {you}. No.',
'You offer little and ask much, {you}. Refused.',
],
offerPeaceOpener: [
'This war has gone on long enough, {you}. We offer its end.',
'We tire of this weight, {you}. Take the peace while it is offered.',
],
offerAllianceOpener: [
'You have stood steady beside us, {you}. We offer an alliance.',
'Stand with us formally, {you}. Nothing will move us then.',
],
acceptOffer: [
'Fair terms. Agreed, {me}.',
'Good weight. I accept, {me}.',
],
rejectOffer: [
'Not yet, {me}. The terms are not steady enough.',
'No, {me} — not this offer.',
],
afterAccepted: [
'Good. We stand steadier together, {you}.',
'Sound judgment, {you}. We will hold to this.',
],
afterRejected: [
'Then we stand alone, {you}. So be it.',
'Your answer has weight, {you}. We will not forget it.',
],
},
// ------------------------------------------------------------------
// Umbrix (diplomacy -50) — "Shapeshifters who wear other species like
// coats. Nobody trusts them, and everybody is right not to." Sly,
// self-aware of their reputation, unsettling, half-mocking.
umbrix: {
opener: {
angry: [
'Careful. You are not the first to mistake our patience for warmth.',
'You come to us wearing suspicion plainly, {you}. Fair. Speak anyway.',
],
neutral: [
'Whose face would you prefer we wore for this conversation, {you}?',
'We are listening, {you} — in whatever shape that requires.',
],
happy: [
'You have earned a rare thing from us, {you}: an honest face.',
'Trust, from you? How novel, {you}. Let us not waste it.',
],
},
firstContact: [
'We already knew of you, {you}, in a manner of speaking. We\'ve worn worse faces than yours.',
'A new shape to study, {you}. We find you... instructive.',
],
proposePeace: [
'Peace, from us, is worth exactly what you\'re willing to believe, {me}. I offer it anyway.',
'Let us end this, {me} — sincerely, for once.',
],
proposeAlliance: [
'An alliance with us is a risk you already know you\'re taking, {me}.',
'I offer partnership, {me}. Watch us closely; we\'d expect nothing less.',
],
declareWar: [
'We\'ve worn kinder faces than the one we show you now, {me}.',
'War suits us better than you\'d think, {me}. Let\'s begin.',
],
tradeTechOffer: [
'A trade, {me} — genuine, this time. Suspicious of us as ever, I\'m sure.',
'We offer knowledge honestly, {me}, which we understand is hard to believe.',
],
replyPeaceAccept: [
'Peace, then, {you} — for now. We keep our word more than our reputation suggests.',
'Accepted, {you}. Believe it or don\'t; it\'s true regardless.',
],
replyPeaceReject: [
'Predictable, {you}. We wouldn\'t trust us either.',
'No, {you}. This war still suits our shape better than peace would.',
],
replyAllianceAccept: [
'We accept, {you} — and yes, we know exactly what that\'s worth to you.',
'An alliance, then, {you}. Watch us anyway.',
],
replyAllianceReject: [
'No, {you}. Even we know better than to promise what we might not keep.',
'Not yet, {you}. We haven\'t decided which face suits this alliance.',
],
replyWarDeclared: [
'Careful, {you} — you\'ve seen only one of our faces so far.',
'War, then. We\'ve worn this shape before, {you}, and won in it.',
],
replyTechAccept: [
'Honest, for once, {you}. We accept.',
'A fair trade, surprisingly, {you}. Agreed.',
],
replyTechReject: [
'A trick, {you}? Or just a bad trade. Either way, no.',
'We decline, {you} — this one smells like a coat we\'ve worn before.',
],
offerPeaceOpener: [
'We offer peace, {you}. Sincerely, for whatever that\'s worth from us.',
'This war has stopped amusing us, {you}. We offer its end.',
],
offerAllianceOpener: [
'We propose an alliance, {you}. Watch us closely; that\'s the correct response.',
'You have been useful to us, {you}. We\'d like to make that formal.',
],
acceptOffer: [
'Peace, from you? We\'ve worn faces more convincing than this offer, {me} — but yes.',
'Accepted, {me}, though I\'ll be watching closely.',
],
rejectOffer: [
'No, {me}. We\'ve been fooled by better performances than this.',
'We decline, {me} — old habits, where you\'re concerned.',
],
afterAccepted: [
'Trust, then, {you}. A strange color on you.',
'Good. We\'ll wear this arrangement honestly, {you} — mostly.',
],
afterRejected: [
'As expected, {you}. Trust was always going to be the harder sell.',
'Fine, {you}. We\'ll remember this face you\'ve shown us.',
],
},
// ------------------------------------------------------------------
// Kkrix (diplomacy -40, but flavor text calls them incapable of
// negotiation — the engine still permits it, so the voice is curt,
// mechanical, reluctant, and speaks of itself as "we"/function/protocol.
kkrix: {
opener: {
angry: [
'Function disrupted. State purpose or cease transmission.',
'Hostile pattern logged. Speak, if speech resolves anything.',
],
neutral: [
'Contact logged. State function.',
'Transmission received. Proceed.',
],
happy: [
'Pattern favorable. Proceed with proposal.',
'Cooperation logged as productive. Continue.',
],
},
firstContact: [
'New pattern detected. Classification: unknown. Contact established.',
'Signal received from unrecorded source. Logging begins now.',
],
proposePeace: [
'Conflict resolution proposed, {me}. Cease hostile pattern.',
'War function no longer optimal, {me}. Peace proposed.',
],
proposeAlliance: [
'Cooperative function proposed, {me}. Resource-sharing protocol available.',
'Alliance proposed, {me}. Efficiency gain calculated as mutual.',
],
declareWar: [
'Hostile function initiated, {me}. Conflict begins.',
'Cooperation terminated, {me}. War function active.',
],
tradeTechOffer: [
'Data exchange proposed, {me}. Equivalent value required.',
'Trade function available, {me}. Specify exchange.',
],
replyPeaceAccept: [
'Peace protocol accepted, {you}.',
'Conflict resolution logged. Accepted, {you}.',
],
replyPeaceReject: [
'Rejected. Conflict pattern continues, {you}.',
'Proposal insufficient, {you}. Rejected.',
],
replyAllianceAccept: [
'Alliance parsed. Resource-sharing protocol accepted, {you}.',
'Cooperative function accepted, {you}.',
],
replyAllianceReject: [
'Insufficient benefit calculated, {you}. Rejected.',
'Cooperative proposal rejected, {you}. Function unclear.',
],
replyWarDeclared: [
'Hostile function acknowledged, {you}. Response initiated.',
'Conflict pattern noted, {you}. Function: defend.',
],
replyTechAccept: [
'Exchange value acceptable, {you}. Accepted.',
'Data trade logged as favorable, {you}. Accepted.',
],
replyTechReject: [
'Exchange value insufficient, {you}. Rejected.',
'Unequal trade detected, {you}. Rejected.',
],
offerPeaceOpener: [
'Conflict pattern no longer optimal. Peace proposed, {you}.',
'Hostile function suspended pending response, {you}. Peace proposed.',
],
offerAllianceOpener: [
'Cooperative function calculated as favorable, {you}. Alliance proposed.',
'Resource-sharing protocol proposed, {you}.',
],
acceptOffer: [
'Alliance parsed. Resource-sharing protocol accepted, {me}.',
'Proposal accepted, {me}. Function proceeds.',
],
rejectOffer: [
'Function unclear. Proposal rejected, {me}.',
'Insufficient value. Rejected, {me}.',
],
afterAccepted: [
'Acknowledged, {you}. Protocol active.',
'Function accepted. Proceeding, {you}.',
],
afterRejected: [
'Rejection logged, {you}. Function reverts.',
'Noted, {you}. Proposal withdrawn from active memory.',
],
},
// ------------------------------------------------------------------
// Mekhan (diplomacy -10) — "Cyborgs who treat a planet as a chassis."
// Efficiency-obsessed, machine/upgrade metaphors, coldly practical.
mekhan: {
opener: {
angry: [
'Your presence registers as inefficient, {you}. Justify it.',
'This exchange is consuming cycles better spent elsewhere, {you}. Speak.',
],
neutral: [
'Processing your request, {you}. State it.',
'Connection established, {you}. Proceed with your proposal.',
],
happy: [
'Your prior conduct has been logged as favorable, {you}. I am receptive.',
'An efficient partner, {you}. Speak; I am inclined to listen.',
],
},
firstContact: [
'New system detected, {you}. Assessing your civilization for compatibility.',
'Contact established, {you}. Initial assessment: unoptimized, but functional.',
],
proposePeace: [
'This conflict is an inefficient use of resources, {me}. I propose its end.',
'War drains cycles better spent on upgrades, {me}. Peace, please.',
],
proposeAlliance: [
'Combined systems outperform separate ones, {me}. I propose an alliance.',
'An alliance improves throughput for both chassis, {me}.',
],
declareWar: [
'You have become an obstruction, {me}. War is the optimal response.',
'Calculations favor conflict, {me}. Initiating.',
],
tradeTechOffer: [
'A component exchange, {me} — equivalent value on both sides.',
'Trade proposed, {me}. Mutual upgrade, mutual benefit.',
],
replyPeaceAccept: [
'Acceptable. Peace improves efficiency, {you}.',
'Agreed, {you}. Resources redirected accordingly.',
],
replyPeaceReject: [
'Rejected, {you}. Current conflict remains net-favorable.',
'Not yet, {you}. The calculation has not shifted.',
],
replyAllianceAccept: [
'Systems integrated. Alliance accepted, {you}.',
'Favorable output projected, {you}. Accepted.',
],
replyAllianceReject: [
'Insufficient benefit projected, {you}. Rejected.',
'Not yet, {you}. Compatibility unproven.',
],
replyWarDeclared: [
'Conflict registered, {you}. Recalibrating defenses.',
'Noted, {you}. This will not go as you have modeled it.',
],
replyTechAccept: [
'Component value confirmed, {you}. Accepted.',
'Trade optimal, {you}. Executing.',
],
replyTechReject: [
'Value mismatch detected, {you}. Rejected.',
'Not equivalent, {you}. Rejected.',
],
offerPeaceOpener: [
'This conflict has become inefficient, {you}. Peace is proposed.',
'Resource models favor ending hostilities, {you}. Proposing peace.',
],
offerAllianceOpener: [
'Your systems have proven compatible, {you}. Alliance proposed.',
'Combined output projected favorable, {you}. Proposing alliance.',
],
acceptOffer: [
'Efficient proposal. Accepted, {me}.',
'Calculation favorable. I accept, {me}.',
],
rejectOffer: [
'Inefficient terms, {me}. Rejected.',
'Not optimal, {me}. Declined.',
],
afterAccepted: [
'Integration successful, {you}. Proceeding.',
'Logged as favorable, {you}. Efficiency improves.',
],
afterRejected: [
'Rejection logged, {you}. Recalculating alternatives.',
'Noted, {you}. Model adjusted accordingly.',
],
},
// ------------------------------------------------------------------
// Rrashaa (diplomacy -25) — "Feline gunners with reflexes no computer
// improves on." Blunt, predatory, threat-forward, respects strength.
rrashaa: {
opener: {
angry: [
'You test claws you have not seen move, {you}. Speak fast.',
'My patience is a short leash, {you}. Use your words well.',
],
neutral: [
'You have my attention, {you}. Don\'t waste it.',
'Speak, {you}. I\'m listening — for now.',
],
happy: [
'You\'ve earned a longer leash than most, {you}. Speak.',
'Few impress me twice, {you}. You have.',
],
},
firstContact: [
'A new scent in the dark, {you}. We\'ll see what you\'re made of soon enough.',
'Contact made, {you}. Reflexes sharp, patience short — remember that.',
],
proposePeace: [
'Enough blood spent, {me}. I offer an end to it — this once.',
'This war has stopped being interesting, {me}. Peace, then.',
],
proposeAlliance: [
'Hunt beside me instead of against me, {me}. I propose an alliance.',
'You\'ve earned a place at my side, {me}, not beneath my sights.',
],
declareWar: [
'You had one chance to not be a target, {me}. It has passed.',
'The hunt begins, {me}. Don\'t bother running.',
],
tradeTechOffer: [
'A trade, {me} — claw for claw, fair and even.',
'I offer something sharp for something sharp in return, {me}.',
],
replyPeaceAccept: [
'Fine, {you}. The hunt grows dull anyway.',
'Accepted, {you}. Consider yourself lucky I\'m bored.',
],
replyPeaceReject: [
'No, {you}. The hunt is still worth the chase.',
'Not yet, {you}. You haven\'t bled enough to earn it.',
],
replyAllianceAccept: [
'Hunt beside me, then, {you}. Accepted.',
'You\'ve earned it, {you}. We hunt together now.',
],
replyAllianceReject: [
'Not yet, {you}. You haven\'t proven your claws.',
'No. Prove yourself further, {you}, before you ask this.',
],
replyWarDeclared: [
'Good. I was getting bored, {you}.',
'You\'ve just made yourself the hunt, {you}.',
],
replyTechAccept: [
'A fair trade, {you}. Claw for claw. Accepted.',
'Sharp deal, {you}. I accept.',
],
replyTechReject: [
'You offer dull steel for sharp, {you}. No.',
'Uneven, {you}. I decline.',
],
offerPeaceOpener: [
'This hunt has grown dull, {you}. I offer its end.',
'Enough blood, {you} — for now. Peace, offered plainly.',
],
offerAllianceOpener: [
'You\'ve proven sharper than most, {you}. I propose we hunt together.',
'A rare offer, {you}: fight beside me, not against me.',
],
acceptOffer: [
'Sharp terms. I accept, {me}.',
'Fine. Agreed, {me}.',
],
rejectOffer: [
'Dull offer, {me}. No.',
'Not sharp enough, {me}. I decline.',
],
afterAccepted: [
'Good instinct, {you}. We\'ll hunt well together.',
'Wise, {you}. I\'ll remember this.',
],
afterRejected: [
'Your loss, {you}. The offer doesn\'t come twice.',
'Fine. Remember you had the chance, {you}.',
],
},
// ------------------------------------------------------------------
// Cerebrai (diplomacy 0) — "Vast fragile intellects." Condescending but
// fair, cerebral vocabulary, treats conversation as a puzzle to be solved.
cerebrai: {
opener: {
angry: [
'Your approach registers as hostile. Justify your reasoning, {you}.',
'I find little logic in your recent conduct, {you}. Explain it.',
],
neutral: [
'An interesting variable has entered the equation. Speak, {you}.',
'I am listening, {you}. Present your argument.',
],
happy: [
'Your reasoning is... adequate. We may yet enjoy this exchange, {you}.',
'A rare pleasure — a mind worth engaging, {you}.',
],
},
firstContact: [
'A new variable, {you}. I shall enjoy modeling your civilization\'s behavior.',
'Contact established, {you}. Preliminary analysis: promising, if unrefined.',
],
proposePeace: [
'The mathematics of this war no longer favor continuation, {me}. I propose peace.',
'A rational mind ends conflicts that no longer serve it, {me}. Peace, then.',
],
proposeAlliance: [
'Our combined intellects would outperform either alone, {me}. I propose an alliance.',
'A logical partnership, {me} — I propose we formalize it.',
],
declareWar: [
'I have modeled every alternative, {me}, and war remains optimal. Regrettable, but so it is.',
'The equation resolves to conflict, {me}. I do not enjoy this conclusion, but I trust it.',
],
tradeTechOffer: [
'An exchange of understanding, {me} — equivalent in value, I trust you\'ll agree.',
'I propose a trade of knowledge, {me}. A fair equation, by my calculation.',
],
replyPeaceAccept: [
'The logic holds. Peace accepted, {you}.',
'A reasonable proposal, {you}. I accept.',
],
replyPeaceReject: [
'The equation does not yet favor peace, {you}. Rejected.',
'Premature, {you}. The variables have not shifted enough.',
],
replyAllianceAccept: [
'A sound partnership, {you}. I accept.',
'The math favors this alliance, {you}. Agreed.',
],
replyAllianceReject: [
'Insufficient data to justify this alliance, {you}. Rejected.',
'Not yet, {you}. The variables remain unproven.',
],
replyWarDeclared: [
'A poorly modeled decision, {you}. I look forward to correcting it.',
'Noted, {you}. I trust you have accounted for what follows.',
],
replyTechAccept: [
'An equitable trade, {you}. I accept.',
'The exchange balances. Agreed, {you}.',
],
replyTechReject: [
'The equation is unbalanced, {you}. I decline.',
'A flawed proposal, {you}. Rejected.',
],
offerPeaceOpener: [
'I have recalculated, {you}. This war no longer serves either of us. Peace, proposed.',
'The variables have shifted, {you}. I propose we end this conflict.',
],
offerAllianceOpener: [
'Our combined potential exceeds the sum of its parts, {you}. I propose an alliance.',
'A logical next step, {you}: formal partnership.',
],
acceptOffer: [
'Your reasoning is adequate. I accept, {me}.',
'The proposal holds up. Agreed, {me}.',
],
rejectOffer: [
'The logic is unconvincing, {me}. I decline.',
'Insufficient justification, {me}. Rejected.',
],
afterAccepted: [
'A satisfying conclusion, {you}. The math was sound.',
'Correct decision, {you}. I approve of the calculation.',
],
afterRejected: [
'An illogical answer, {you}, but your prerogative.',
'Noted, {you}. I will revise my model of you accordingly.',
],
},
// ------------------------------------------------------------------
// Ssakar (diplomacy -20) — "Reptilian broodmothers." Proud, expansionist,
// speaks in terms of clutches/broods/territory, faintly maternal menace.
ssakar: {
opener: {
angry: [
'You disturb the brood, {you}. Speak before I lose patience with you.',
'Careful, {you}. A threatened clutch is not a patient one.',
],
neutral: [
'The brood is listening, {you}. Speak your purpose.',
'You have our attention, {you}. What do you bring?',
],
happy: [
'You have proven a tolerable neighbor to the brood, {you}. Speak freely.',
'The clutch grows, and you have not hindered it, {you}. I am pleased.',
],
},
firstContact: [
'New territory, new neighbors. The brood takes note of you, {you}.',
'Contact made. We will see whether you crowd our clutches or not, {you}.',
],
proposePeace: [
'The brood tires of this war, {me}. I offer its end.',
'Enough clutches have been lost, {me}. Let us have peace.',
],
proposeAlliance: [
'Our broods could thrive side by side, {me}. I propose an alliance.',
'Territory shared wisely benefits both clutches, {me}.',
],
declareWar: [
'You have crowded our territory long enough, {me}. This is war.',
'The brood does not forgive encroachment, {me}. War, now.',
],
tradeTechOffer: [
'A trade, {me} — knowledge for knowledge, fair to both clutches.',
'I offer the brood\'s secrets in exchange for yours, {me}.',
],
replyPeaceAccept: [
'Agreed, {you}. The brood has lost enough for one war.',
'Peace, then, {you}. The clutches need time to grow again.',
],
replyPeaceReject: [
'No, {you}. The brood is not yet weary enough.',
'Not yet, {you}. Our clutches still have fight left in them.',
],
replyAllianceAccept: [
'Our broods will thrive together, {you}. Accepted.',
'A wise arrangement, {you}. The clutch agrees.',
],
replyAllianceReject: [
'Not yet, {you}. You have not proven yourself to the brood.',
'The clutch is not convinced, {you}. Rejected, for now.',
],
replyWarDeclared: [
'You have made an enemy of every clutch we have, {you}.',
'The brood does not forget this, {you}. War, then.',
],
replyTechAccept: [
'A fair trade for the brood, {you}. Accepted.',
'The clutch benefits from this, {you}. Agreed.',
],
replyTechReject: [
'The brood gains too little from this, {you}. Rejected.',
'An unequal trade, {you}. No.',
],
offerPeaceOpener: [
'The brood has lost enough clutches to this war, {you}. Peace, offered.',
'Enough blood in the nest, {you}. We offer peace.',
],
offerAllianceOpener: [
'Our clutches have grown well beside yours, {you}. We propose an alliance.',
'The brood offers partnership, {you} — territory shared, not fought over.',
],
acceptOffer: [
'A fair offer for the brood. Accepted, {me}.',
'The clutch agrees, {me}.',
],
rejectOffer: [
'The brood declines, {me}. Not this time.',
'No, {me} — the clutch is not convinced.',
],
afterAccepted: [
'Good. Our clutches grow safer together, {you}.',
'Wisely chosen, {you}. The brood remembers kindness.',
],
afterRejected: [
'Then the brood remembers this refusal, {you}.',
'So be it, {you}. Our clutches grow apart, then.',
],
},
// ------------------------------------------------------------------
// Lithox (diplomacy -100) — incapable of diplomacy. No Audience screen
// ever opens for them; this pool exists only for the turn-report's
// first-contact flourish.
lithox: {
contactOnly: [
'[No response. The Lithox do not appear to register the transmission.]',
'[The signal reaches Lithox space and is met with silence.]',
'[Whatever passes for language among the Lithox, it is not this.]',
],
},
};

View File

@ -42,6 +42,7 @@ import {
CHANNELS, colonyMaxPop, colonyProduction, colonyFactoryCap, effectiveFactories,
colonyDefenseCap, colonyTrade, colonyBuildRate, setSlider, enqueue, enqueueMany,
dequeue, collapseQueue, moveQueueRun, queueItemCost, queueEtas, empireDesign,
empireColonies, maxSendablePopulation, sendPopulation, etaTo,
} from './VegaLogic.js';
const ACCENT = 0x6fc4ff;
@ -240,6 +241,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
if (closed) return;
closed = true;
detail?.close();
closeTransportPicker();
dropScrollers();
// Out of the tree first, or root.destroy() takes it and pool.destroy()
// then runs against a corpse.
@ -365,7 +367,7 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
// So the two lists below give way rather than spilling past the frame, the
// same way VegaSidePanel's `room()` works. The status block and the sliders
// are never allowed to be the thing that gets dropped.
const buttonY = PANEL_Y + PANEL_H - 76;
const buttonY = PANEL_Y + PANEL_H - 76 - 56;
const room = (px) => y + px < buttonY - 12;
// --- what is being built
@ -400,13 +402,27 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
// Lowest priority: it is reference, and the flyout's catalogue already
// hides anything already built here.
if (colony.buildings.length && room(60)) {
//
// `colony.buildings` also doubles as a completion flag for a finished Star
// Base (VegaLogic.js's completeQueueItem pushes the HULL id 'starbase' in
// there so the fuel-range and AI checks have a cheap `.includes()` test) —
// that id has no entry in `rules.buildings`, so it is filtered out here
// rather than crashing on `rules.buildings[b].name`. The Star Base itself
// still shows up in orbit like any other ship.
const realBuildings = colony.buildings.filter((b) => rules.buildings[b]);
if (realBuildings.length && room(60)) {
heading('Built here');
text(colony.buildings.map((b) => rules.buildings[b].name).join(' · '),
text(realBuildings.map((b) => rules.buildings[b].name).join(' · '),
13, '#ffd88a', 2, w);
}
y = buttonY;
const sendBtn = add(new Button(scene, PANEL_X + PANEL_W / 2, y + 22,
'Send Population', openTransportPicker, { width: w, height: 44, fontSize: 18 }));
if (maxSendablePopulation(colony) <= 0 || empireColonies(state, colony.empireIdx).length < 2) {
sendBtn.setEnabled(false);
}
y += 56;
add(new Button(scene, PANEL_X + PANEL_W / 2, y + 24,
flyOpen ? 'Close Build Queue' : 'Build Queue', toggleFlyout,
{ width: w, height: 48, fontSize: 20 }));
@ -606,6 +622,8 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
const turns = (cost) => (rate > 0 ? `${Math.max(1, Math.ceil(cost / rate))} turns` : 'no budget');
for (const hull of rules.hullList) {
// Dispatched directly by the Send Population order, never queued.
if (hull.id === 'poptransport') continue;
const d = empireDesign(rules, state, colony.empireIdx, hull.id);
entry({ hullId: hull.id },
d.name, `${d.cost} BC · ${turns(d.cost)} · ${hull.desc}`, '#9fd8ff',
@ -657,6 +675,136 @@ export function openColonyView(scene, rules, state, colony, art, opts = {}) {
r.on('pointerup', (p) => { if (!guard || guard.contains(p)) fn(); });
}
// ------------------------------------------------- population transport
//
// MOO1-style: a self-contained overlay opened from the panel — pick one of
// your other colonies, dial an amount up to what the source can spare, and
// send it. Dispatches through Logic.sendPopulation(), which builds the
// 'poptransport' fleet already in transit; delivery on arrival is automatic
// (VegaLogic.js's moveFleets), so there is no separate "unload" step here.
const TW = 640;
const TH = 520;
const TX = (GAME_WIDTH - TW) / 2;
const TY = (GAME_HEIGHT - TH) / 2;
let transportLayer = null;
let listCol = null; // tracked so its wheel listener is unhooked on every rebuild, not just on close
function closeTransportPicker() {
listCol?.destroy();
listCol = null;
transportLayer?.destroy();
transportLayer = null;
}
function openTransportPicker() {
if (transportLayer) return;
const others = empireColonies(state, colony.empireIdx).filter((c) => c.id !== colony.id);
let dest = others[0] ?? null;
let amount = 1;
transportLayer = scene.add.container(0, 0);
root.add(transportLayer);
const renderTransport = () => {
listCol?.destroy();
listCol = null;
transportLayer.removeAll(true);
transportLayer.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x00060e, 0.6)
.setOrigin(0, 0).setInteractive());
frame(scene, transportLayer, TX, TY, TW, TH, 0.97);
const x = TX + PAD;
const w = TW - PAD * 2;
let y = TY + 24;
transportLayer.add(scene.add.text(x, y, 'SEND POPULATION', {
fontFamily: FONT, fontSize: '24px', color: '#cfe8ff',
}));
transportLayer.add(new Button(scene, TX + TW - 38, y + 14, '✕', closeTransportPicker,
{ width: 40, height: 36, fontSize: 18, variant: 'ghost' }));
y += 46;
transportLayer.add(scene.add.text(x, y,
`From ${star.name} ${ORBIT[colony.orbit] ?? colony.orbit + 1}${colony.pop.toFixed(1)} population`, {
fontFamily: FONT, fontSize: '15px', color: '#9fb6cc',
}));
y += 34;
if (!others.length) {
transportLayer.add(scene.add.text(x, y, 'No other colonies to send population to.', {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3',
}));
return;
}
transportLayer.add(scene.add.text(x, y, 'DESTINATION', {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3',
}));
y += 20;
const listH = 220;
listCol = scrollColumn(scene, transportLayer, x, y, w, listH);
let ly = 0;
others.forEach((c) => {
const dStar = state.galaxy.stars[c.starIdx];
const selected = dest?.id === c.id;
const row = scene.add.rectangle(0, ly, w, 42, selected ? 0x22405f : 0x0f1a2c, selected ? 0.9 : 0.5)
.setOrigin(0, 0).setStrokeStyle(1, ACCENT, selected ? 0.7 : 0.2)
.setInteractive({ useHandCursor: true });
row.on('pointerup', (p) => { if (listCol.contains(p)) { dest = c; renderTransport(); } });
listCol.content.add(row);
const eta = etaTo(rules, state, { starIdx: colony.starIdx, empireIdx: colony.empireIdx },
c.starIdx, [{ hullId: 'poptransport', mark: 1, count: 1 }]);
listCol.content.add(scene.add.text(12, ly + 6, `${dStar.name} ${ORBIT[c.orbit] ?? c.orbit + 1}`, {
fontFamily: FONT, fontSize: '16px', color: '#e8f4ff',
}));
listCol.content.add(scene.add.text(12, ly + 25,
`${c.pop.toFixed(1)} population · ${Number.isFinite(eta) ? `${eta} turn${eta === 1 ? '' : 's'}` : 'unreachable'}`, {
fontFamily: FONT, fontSize: '13px', color: '#8fa8c0',
}));
ly += 48;
});
listCol.contentHeight = ly;
y += listH + 20;
const cap = maxSendablePopulation(colony);
amount = Phaser.Math.Clamp(amount, 0, cap);
transportLayer.add(scene.add.text(x, y, 'AMOUNT TO SEND', {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3',
}));
y += 24;
transportLayer.add(scene.add.text(x, y, amount.toFixed(1), {
fontFamily: FONT, fontSize: '30px', color: '#ffd88a',
}));
const setAmount = (v) => { amount = Phaser.Math.Clamp(v, 0, cap); renderTransport(); };
pusher(transportLayer, x + 110, y + 15, '', () => setAmount(amount - 1), amount > 0);
pusher(transportLayer, x + 142, y + 15, '+', () => setAmount(amount + 1), amount < cap);
transportLayer.add(new Button(scene, x + 200, y + 15, 'MAX', () => setAmount(cap),
{ width: 66, height: 32, fontSize: 14, variant: 'ghost' }));
transportLayer.add(scene.add.text(x, y + 40, `${cap.toFixed(1)} available to send`, {
fontFamily: FONT, fontSize: '13px', color: '#6f8aa3',
}));
y += 76;
const canSend = !!dest && amount > 0;
const sendBtn = new Button(scene, TX + TW / 2, y + 22, 'Send', () => {
if (!dest) return;
if (sendPopulation(rules, state, colony.empireIdx, colony.starIdx, dest.starIdx, amount)) {
closeTransportPicker();
buildPanel();
onChanged?.();
}
}, { width: w, height: 44, fontSize: 18 });
if (!canSend) sendBtn.setEnabled(false);
transportLayer.add(sendBtn);
};
renderTransport();
}
buildPanel();
return { root, close };
}

View File

@ -21,6 +21,15 @@ export function moodOf(attitude) {
return 'devoted';
}
// Collapses moodOf's five attitude tiers to the three video moods the
// Audience screen actually has clips for (VegaAudience.js).
export function videoMood(attitude) {
const m = moodOf(attitude);
if (m === 'hostile' || m === 'cold') return 'angry';
if (m === 'warm' || m === 'devoted') return 'happy';
return 'neutral';
}
function adjust(state, a, b, delta) {
const emp = state.empires[a];
emp.attitude[b] = Math.max(-100, Math.min(100, (emp.attitude[b] ?? 0) + delta));
@ -107,6 +116,58 @@ export function proposeTreaty(rules, state, a, b, kind) {
return true;
}
// When an AI proposes peace or an alliance TO THE HUMAN, resolving it
// unilaterally (like AI-vs-AI) would give the Audience screen's Accept/Reject
// buttons nothing to actually decide. So a proposal aimed at the human is
// HELD instead — the human answers it via respondToOffer, not wouldAccept.
// War stays immediate/unilateral either direction: nothing to accept about
// being attacked.
export function proposeOrOffer(rules, state, a, b, kind) {
if (b === state.humanIndex && a !== state.humanIndex) {
if (state.empires[b].pendingOffers[a]) return 'pending'; // don't clobber an unanswered offer
state.empires[b].pendingOffers[a] = { kind, turn: state.turn };
state.events.push({ type: 'offerReceived', empire: a, other: b, kind, turn: state.turn });
return 'pending';
}
return proposeTreaty(rules, state, a, b, kind);
}
// The human's answer to a held offer, from the Audience screen's
// Accept/Reject buttons. `b` is the human (the offer's target), `a` is the
// empire that made it.
export function respondToOffer(rules, state, b, a, accept) {
const offer = state.empires[b]?.pendingOffers?.[a];
if (!offer) return false;
delete state.empires[b].pendingOffers[a];
if (accept) {
if (offer.kind === 'peace') makePeace(rules, state, a, b);
else if (offer.kind === 'alliance') formAlliance(rules, state, a, b);
} else {
adjust(state, a, b, -8); // the proposer stings at being refused
adjust(state, b, a, -2); // refusing is itself a small friction cost
state.events.push({ type: 'offerRejected', empire: b, other: a, kind: offer.kind, turn: state.turn });
}
return true;
}
// A contact event auto-opens the full-screen Audience interface only for
// species capable of diplomacy — Lithox (and anything else the data ever
// marks incapable) instead falls through unchanged to the ordinary turn
// report. Marks each claimed event `announced` so the turn report's own
// "skip already-announced events" loop never shows it twice.
export function claimAudienceContacts(rules, state, me) {
const found = [];
for (const ev of state.events) {
if (ev.announced || ev.type !== 'contact') 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)];
}
// Trade a tech each. Both sides gain, which is why an empire with a research
// bonus should think twice — and why a species locked out of half the tree by
// its availability roll should think once.
@ -144,6 +205,15 @@ export function runDiplomacyTurn(rules, state, e) {
const diff = rules.difficulties[state.difficultyId];
driftAttitudes(rules, state, e);
// A held offer nobody ever answers shouldn't block this empire from trying
// again, and shouldn't sit forever either.
const human = state.empires[state.humanIndex];
const myOffer = human?.pendingOffers?.[e];
if (myOffer && state.turn - myOffer.turn > 6) {
delete human.pendingOffers[e];
state.events.push({ type: 'offerExpired', empire: e, other: state.humanIndex, kind: myOffer.kind, turn: state.turn });
}
const others = state.empires.filter((o) => o.alive && o.idx !== e && emp.contacted[o.idx]);
if (!others.length) return;
@ -156,7 +226,7 @@ export function runDiplomacyTurn(rules, state, e) {
// Sue for peace when clearly losing, or when the war has gone cold.
// Only sue for peace when genuinely losing, and not immediately — a war
// that ends on the turn it starts accomplishes nothing.
if (ratio < 0.55 && rand(state) < 0.12) proposeTreaty(rules, state, e, other.idx, 'peace');
if (ratio < 0.55 && rand(state) < 0.12) proposeOrOffer(rules, state, e, other.idx, 'peace');
continue;
}
@ -182,7 +252,7 @@ export function runDiplomacyTurn(rules, state, e) {
continue;
}
if (att > 45 && emp.treaties[other.idx] !== 'alliance' && rand(state) < 0.08) {
proposeTreaty(rules, state, e, other.idx, 'alliance');
proposeOrOffer(rules, state, e, other.idx, 'alliance');
}
}
}

View File

@ -411,6 +411,7 @@ export function createGame(rules, opts) {
contacted: {},
treaties: {},
attitude: {},
pendingOffers: {},
explored: {},
spyPoints: 0,
totalPop: 0,
@ -441,6 +442,7 @@ export function createGame(rules, opts) {
colony.factories = rules.economy.startingFactories;
colony.capital = true;
state.empires[e].explored[starIdx] = true;
checkContactAt(rules, state, starIdx);
// Everything else starts unexplored — a scout has to physically arrive
// (see moveFleets' `explored[f.starIdx] = true`) before its details show.
// Starting fleet: a scout and a colony ship, plus difficulty handicap ships.
@ -767,7 +769,6 @@ export function beginEmpireTurn(rules, state, e) {
processResearch(rules, state, e, research * researchMult);
consolidateFleets(rules, state, e);
updateContacts(rules, state);
autoRefit(rules, state, e);
runEspionage(rules, state, e);
recomputeTotals(rules, state);
@ -1027,50 +1028,37 @@ function moveFleets(rules, state, e) {
if (!wasExplored) {
pushEvent(state, { type: 'discovered', empire: e, starIdx: f.starIdx, turn: state.turn });
}
makeContactAt(rules, state, e, f.starIdx);
checkContactAt(rules, state, f.starIdx);
deliverPopulation(rules, state, f);
}
}
cleanFleets(state);
}
// Empires also notice each other simply by living nearby — you do not need to
// fly a ship into someone's home system to know they are there. Contact used to
// require exactly that, so in most galaxies nobody ever met anybody, which meant
// no diplomacy, no wars, and every game grinding out to the turn cap.
export function updateContacts(rules, state) {
const range = rules.economy.contactRange ?? 12;
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]) continue;
let met = false;
for (const ca of empireColonies(state, a.idx)) {
for (const cb of empireColonies(state, b.idx)) {
if (parsecs(state.galaxy, ca.starIdx, cb.starIdx) <= range) { met = true; break; }
}
if (met) break;
}
if (!met) continue;
a.contacted[b.idx] = true;
b.contacted[a.idx] = true;
pushEvent(state, { type: 'contact', empire: a.idx, other: b.idx, starIdx: -1, turn: state.turn });
// Empires meet by sharing a system — a ship or colony of one sitting where a
// ship or colony of another already is, for the first time. Contact used to
// be proximity-based (any two empires' colonies within N parsecs), which met
// everyone quietly and gave the human no sense that first contact was an
// event. Star-scoped and all-pairs so it is agnostic to *why* two empires
// ended up sharing a star (a fleet arriving, a colony founded onto an
// already-occupied system) — every caller just says "check this star."
export function checkContactAt(rules, state, starIdx) {
const present = new Set();
for (const c of coloniesAt(state, starIdx)) present.add(c.empireIdx);
for (const f of fleetsAt(state, starIdx)) present.add(f.empireIdx);
const list = [...present];
for (let i = 0; i < list.length; i += 1) {
for (let j = i + 1; j < list.length; j += 1) {
const a = list[i];
const b = list[j];
if (state.empires[a].contacted[b]) continue;
state.empires[a].contacted[b] = true;
state.empires[b].contacted[a] = true;
pushEvent(state, { type: 'contact', empire: a, other: b, starIdx, turn: state.turn });
}
}
}
function makeContactAt(rules, state, e, starIdx) {
const others = new Set();
for (const c of coloniesAt(state, starIdx)) if (c.empireIdx !== e) others.add(c.empireIdx);
for (const f of fleetsAt(state, starIdx)) if (f.empireIdx !== e) others.add(f.empireIdx);
for (const o of others) {
if (state.empires[e].contacted[o]) continue;
state.empires[e].contacted[o] = true;
state.empires[o].contacted[e] = true;
pushEvent(state, { type: 'contact', empire: e, other: o, starIdx, turn: state.turn });
}
}
export function atWar(state, a, b) {
return state.empires[a]?.treaties?.[b] === 'war';
}
@ -1261,12 +1249,84 @@ export function colonize(rules, state, e, starIdx, orbit) {
stack.count -= 1;
cleanFleets(state);
const colony = foundColony(rules, state, e, starIdx, orbit, 5);
checkContactAt(rules, state, starIdx);
pushEvent(state, { type: 'colonised', empire: e, starIdx, orbit, colonyId: colony.id, turn: state.turn });
state.empires[e]._range = null;
state.empires[e]._rangeAt = -1;
return true;
}
// --------------------------------------------------------------------------
// Population transport — MOO1's "send N population from one of your colonies
// to another." Unlike colonize()'s colony ship, this never sits in a build
// queue: the 'poptransport' hull (data/mastervega-rules.json) is dispatched
// directly, in transit already, the instant the order is given, carrying
// whatever amount was chosen as a payload on its own ship stack rather than a
// fixed per-hull capacity. It rides the same movement/fuel-range/combat
// machinery as any other fleet, so an unescorted transport crossing hostile
// space is exactly as vulnerable as a colony ship would be.
// A colony can never be fully emptied this way — same floor colony growth
// itself never goes below (see the `Math.max(0.5, ...)` in processColony).
const MIN_POP_LEFT_BEHIND = 0.5;
export function maxSendablePopulation(colony) {
return Math.max(0, colony.pop - MIN_POP_LEFT_BEHIND);
}
export function sendPopulation(rules, state, e, fromStarIdx, toStarIdx, amount) {
if (fromStarIdx === toStarIdx) return false;
const colony = colonyAt(state, fromStarIdx);
if (!colony || colony.empireIdx !== e) return false;
const dest = colonyAt(state, toStarIdx);
if (!dest || dest.empireIdx !== e) return false;
const send = Math.min(amount, maxSendablePopulation(colony));
if (send <= 0) return false;
// Built directly in transit — see the module comment above for why this
// does not go through addFleet()/sendFleet() (which would first have to
// land in an idle fleet at the source, risking a merge with whatever is
// garrisoned there before it could be split back out).
const probe = { empireIdx: e, starIdx: fromStarIdx, toStar: -1, ships: [{ hullId: 'poptransport', mark: 1, count: 1 }] };
if (!canSendFleet(rules, state, probe, toStarIdx)) return false;
state.fleets.push({
id: state.nextFleetId += 1,
empireIdx: e,
starIdx: -1,
fromStar: fromStarIdx,
toStar: toStarIdx,
progress: 0,
total: parsecs(state.galaxy, fromStarIdx, toStarIdx),
ships: [{ hullId: 'poptransport', mark: 1, count: 1, popPayload: send }],
});
colony.pop -= send;
pushEvent(state, {
type: 'populationSent', empire: e, starIdx: fromStarIdx, toStarIdx, amount: send, turn: state.turn,
});
return true;
}
// Called from moveFleets the instant a poptransport fleet arrives. Delivers
// to a still-friendly colony there and consumes the stack; if the star no
// longer has a colony of ours (captured, or the fleet was mis-sent), the
// payload just sits inert in orbit rather than being silently discarded, in
// case the player wants to redirect it by hand later.
function deliverPopulation(rules, state, f) {
const stack = f.ships.find((s) => s.hullId === 'poptransport' && s.popPayload > 0);
if (!stack) return;
const colony = colonyAt(state, f.starIdx);
if (!colony || colony.empireIdx !== f.empireIdx) return;
const maxPop = colonyMaxPop(rules, state, colony);
colony.pop = Math.min(maxPop, colony.pop + stack.popPayload);
pushEvent(state, {
type: 'populationDelivered', empire: f.empireIdx, starIdx: f.starIdx,
colonyId: colony.id, amount: stack.popPayload, turn: state.turn,
});
stack.count = 0;
stack.popPayload = 0;
}
// Orbital superiority at a system: our combat power there exceeds theirs.
// Bombardment and invasion both require it.
export function holdsOrbit(rules, state, e, starIdx, defenderIdx) {

View File

@ -10,11 +10,12 @@ import { Button } from '../../ui/Button.js';
import { techCost } from './VegaRules.js';
import { empireColonies, setResearchAlloc, nextResearchTarget, hireLeader } from './VegaLogic.js';
import {
attitudeOf, moodOf, powerOf, declareWar, proposeTreaty, canNegotiate,
attitudeOf, moodOf, powerOf, canNegotiate,
} from './VegaDiplomacy.js';
import { leaderOffers } from './VegaLeaders.js';
import { makeSpeciesPortrait } from './VegaArt.js';
import { openAudienceScreen } from './VegaAudience.js';
export const FONT = '"Julius Sans One"';
// `colony` sits above `modal` so the colony screen can stack over the system
@ -257,32 +258,13 @@ export function openDiplomacyScreen(scene, rules, state, e, art, onClose, onChan
shell.add(bio);
const bx = shell.body.x + shell.body.w - 150;
if (treaty === 'war') {
const b = new Button(scene, bx, y + 40, 'Sue for peace', () => {
proposeTreaty(rules, state, e, other.idx, 'peace');
shell.destroy();
openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged);
onChanged?.();
}, { width: 200, height: 40 });
shell.add(b);
} else {
const b = new Button(scene, bx, y + 24, 'Declare war', () => {
declareWar(rules, state, e, other.idx);
shell.destroy();
openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged);
onChanged?.();
}, { width: 200, height: 38, bg: 0x6b2230 });
shell.add(b);
if (canNegotiate(rules, state, e, other.idx) && treaty !== 'alliance') {
const a = new Button(scene, bx, y + 70, 'Propose alliance', () => {
proposeTreaty(rules, state, e, other.idx, 'alliance');
shell.destroy();
openDiplomacyScreen(scene, rules, state, e, art, onClose, onChanged);
onChanged?.();
}, { width: 200, height: 38 });
shell.add(a);
}
}
const canSeek = canNegotiate(rules, state, e, other.idx);
const b = new Button(scene, bx, y + rowH / 2 - 20, 'Seek Audience', () => {
shell.destroy();
openAudienceScreen(scene, rules, state, e, other.idx, art, onClose, onChanged);
}, { width: 200, height: 40 });
if (!canSeek) b.setEnabled(false);
shell.add(b);
});
return shell;
}

View File

@ -29,8 +29,17 @@ const SHIP_FRAME_PX = 192;
export const shipVideoKey = (speciesId, hullId) => `vega-ship-${speciesId}-${hullId}`;
// The Colony Transport ('poptransport') shares the Troop Transport's static
// picture (same sheet frame — see data/mastervega-rules.json, the ships sheet
// is full at 8 columns) but is deliberately given the COLONY SHIP's commander
// clip instead: thematically the same kind of settlement run, and it means
// every species with a colony-ship video already has one for this hull too,
// with zero new video files. `shipVideoKey` itself stays a pure naming
// function — the alias is applied only where a video is actually looked up.
const VIDEO_HULL_ALIAS = { poptransport: 'colonyship' };
export function hasShipVideo(scene, speciesId, hullId) {
return !!scene.cache.video?.exists(shipVideoKey(speciesId, hullId));
return !!scene.cache.video?.exists(shipVideoKey(speciesId, VIDEO_HULL_ALIAS[hullId] ?? hullId));
}
/**
@ -61,7 +70,7 @@ export function makeCommanderPortrait(
if (!hasShipVideo(scene, speciesId, hullId)) {
return makeSpeciesPortrait(scene, rules, art, speciesId, x, y, size);
}
const v = scene.add.video(x, y, shipVideoKey(speciesId, hullId));
const v = scene.add.video(x, y, shipVideoKey(speciesId, VIDEO_HULL_ALIAS[hullId] ?? hullId));
v.setMute(true);
v.setLoop(true);
// Scale from the known source size rather than setDisplaySize — see

View File

@ -31,7 +31,10 @@ export const NOTABLE_TYPES = new Set([
//
// `colonised` stays classified here even though it is no longer notable: the
// ticker log runs every event past isRelevantToHuman() too.
const PERSONAL_TYPES = new Set(['discovered', 'techDone', 'colonised', 'buildingDone', 'shipDone']);
const PERSONAL_TYPES = new Set([
'discovered', 'techDone', 'colonised', 'buildingDone', 'shipDone',
'populationSent', 'populationDelivered',
]);
export function isRelevantToHuman(ev, me) {
if (PERSONAL_TYPES.has(ev.type)) return ev.empire === me;

View File

@ -38,14 +38,15 @@ import { buildZoomLadder, minZoomFor, MAX_ZOOM, DEFAULT_ZOOM_INDEX } from '../sr
import {
ensureSheets, shipFrame, planetFrame, techFrame, buildingFrame,
speciesVideoKey, speciesStillKey, speciesSpeechClip, UI_SPEECH, worldBgKey,
colonyVideoKey, sourceWidth,
colonyVideoKey, audienceVideoKey, sourceWidth,
} from '../src/games/mastervega/VegaArt.js';
import { CHAT } from '../src/games/mastervega/VegaChat.js';
// Turn-report classification is Phaser-free, so what the "New Turn" popup will
// and will not interrupt the player for is checkable here.
import { NOTABLE_TYPES, describeEvent, TYPE_LABEL } from '../src/games/mastervega/VegaTurnReport.js';
// Ship media is addressed here and nowhere else, so the key convention is
// checkable without a canvas.
import { shipVideoKey } from '../src/games/mastervega/VegaShipMedia.js';
import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js';
// Dependency-free, so what the game room eagerly pulls is checkable here.
import { resolveGameAssets } from '../src/data/assetManifest.js';
@ -139,6 +140,15 @@ section('1. Rules integrity');
t.factoriesPerPop > 2, t.colonizeAnything, t.hostileImmune, t.diplomacy > 0].filter(Boolean).length;
check(`species ${s.id} has a real strength`, good > 0);
}
// Exactly one species (lithox) is diplomacy-incapable. The Audience screen's
// Seek Audience button and the audienceVideos roster both key off this exact
// count, so a silent change here would silently change who gets a working
// diplomacy screen.
const incapable = RULES.speciesList.filter((s) => (s.traits.diplomacy ?? 0) <= -100);
check('exactly one diplomacy-incapable species', incapable.length === 1,
incapable.map((s) => s.id).join(','));
check('the diplomacy-incapable species is lithox', incapable[0]?.id === 'lithox');
}
// ---------------------------------------------------------------------------
@ -301,6 +311,63 @@ section('2. Procedural art');
console.log(` (${shipClips}/${RULES.speciesList.length * RULES.hullList.length} `
+ 'ship commander clips recorded; the rest fall back to the species portrait)');
// poptransport's commander clip is deliberately aliased to the colony
// ship's (VegaShipMedia.js) — same picture as the Troop Transport, but the
// "settlement run" video. hasShipVideo() takes a scene as a plain
// duck-typed parameter (no Phaser import in that module), so the alias is
// checkable here with a stub rather than needing a browser.
{
const stubScene = (cachedKeys) => ({ cache: { video: { exists: (k) => cachedKeys.has(k) } } });
const withColonyClip = stubScene(new Set([shipVideoKey('human', 'colonyship')]));
check('poptransport resolves to a video when the species has a colony-ship clip',
hasShipVideo(withColonyClip, 'human', 'poptransport'));
const withoutAnyClip = stubScene(new Set());
check('poptransport reports no video when the colony-ship clip is absent too',
!hasShipVideo(withoutAnyClip, 'human', 'poptransport'));
// A clip filed under poptransport's OWN key must NOT be what satisfies
// this — the alias means only the colony ship's key is ever consulted,
// and one under poptransport's own name is never expected to exist.
const onlyOwnKey = stubScene(new Set([shipVideoKey('human', 'poptransport')]));
check('the alias looks at the colony ship\'s key, not poptransport\'s own',
!hasShipVideo(onlyOwnKey, 'human', 'poptransport'));
}
// Audience-screen mood clips: species -> {angry, neutral, happy}. Lithox
// (diplomacy-incapable) must have NO entry at all — canNegotiate() is
// permanently false for them, so the Audience screen never opens for that
// species and a clip would never play. Every OTHER present entry must have
// all three moods, unlike shipVideos' per-hull gaps: a missing mood should
// be an explicit "whole species is null" decision, not a silently absent key.
const audVids = artJson.audienceVideos ?? {};
const audVidSpecies = Object.keys(audVids).filter((k) => !k.startsWith('_'));
let audClips = 0;
check('audienceVideos has no lithox entry', !audVidSpecies.includes('lithox'));
for (const id of audVidSpecies) {
check(`audienceVideos entry ${id} is a known species`, !!RULES.species[id]);
check(`audienceVideos entry ${id} is diplomacy-capable`,
(RULES.species[id]?.traits.diplomacy ?? 0) > -100);
const moods = audVids[id];
if (!moods) continue;
for (const mood of ['angry', 'neutral', 'happy']) {
const v = moods[mood];
check(`audienceVideos ${id}.${mood} is present`, !!v, `species ${id} declared but missing ${mood}`);
if (!v) continue;
check(`audienceVideos ${id}.${mood} key matches the loader's key`,
v.key === audienceVideoKey(id, mood), `${v.key} vs ${audienceVideoKey(id, mood)}`);
if (v.path) {
audClips += 1;
check(`audienceVideos ${id}.${mood} file exists`, existsSync(join(root, v.path)), v.path);
check(`audienceVideos ${id}.${mood} is an mp4`, v.path.endsWith('.mp4'));
}
}
}
for (const s of RULES.speciesList.filter((sp) => (sp.traits.diplomacy ?? 0) > -100)) {
check(`diplomacy-capable species ${s.id} has an audienceVideos entry (possibly null)`,
audVidSpecies.includes(s.id));
}
console.log(` (${audClips}/${audVidSpecies.filter((id) => audVids[id]).length * 3} `
+ 'audience mood clips recorded; the rest fall back to the species portrait)');
// Every species x hull must land inside the declared `ships` grid, or a row
// of the fleet panel draws a frame that does not exist.
const shipSheet = artJson.sheets?.ships;
@ -382,6 +449,13 @@ section('2. Procedural art');
const eagerColony = eager.filter((d) => d.type === 'video' && d.key.startsWith('vega-colony-'));
check('colony-founding clips are not eager-loaded', eagerColony.length === 0,
eagerColony.map((d) => d.key).join(' '));
// Same reasoning as colonyVideos: 27 potential clips is a speculative
// payload most playthroughs only partly touch, and checkContactAt marking
// a fresh human contact is just as strong an "about to be needed" signal
// as a colony ship parking over a settleable world.
const eagerAudience = eager.filter((d) => d.type === 'video' && d.key.startsWith('vega-audience-'));
check('audience mood clips are not eager-loaded', eagerAudience.length === 0,
eagerAudience.map((d) => d.key).join(' '));
// The cue is the opposite case: small, and it has to be ready the instant
// the vignette opens.
check('the colony-founding cue IS eager-loaded',
@ -830,6 +904,138 @@ section('4b. Fleet orders and detachments');
}
}
// ---------------------------------------------------------------------------
section('4c. Population transport');
// ---------------------------------------------------------------------------
{
// MOO1's "send N population from one colony to another" — dispatched
// directly as an already-in-transit 'poptransport' fleet (VegaLogic.js:
// sendPopulation), never built from a colony's queue.
const hull = RULES.hulls.poptransport;
check('the poptransport hull exists', !!hull);
check('poptransport shares the Troop Transport\'s sheet frame (no new art)',
hull?.frame === RULES.hulls.transport.frame);
check('poptransport is unarmed (space 0, matching its role)', hull?.space === 0);
check('poptransport carries no troops', !(hull?.troops > 0));
// 'large' + only two species maximises the odds of an unclaimed, in-range
// star existing near the human's homeworld to found a second colony on —
// same defensive style as section 6b's colonize() setup, which checks
// rather than assumes a settleable target exists.
function mkTwoColonyGame(seed) {
const st = Logic.createGame(RULES, {
sizeId: 'large', shapeId: 'cluster', seed, difficultyId: 'normal',
speciesIds: ['human', 'lithox'], humanIndex: 0,
});
st.rules = RULES;
const home = st.galaxy.homeIdx[0];
const target = Object.keys(Logic.reachableStars(RULES, st, 0)).map(Number)
.find((i) => i !== home && i !== st.galaxy.homeIdx[1]);
if (target === undefined) return { st, home, target: -1, second: null };
const second = Logic.foundColony(RULES, st, 0, target, 0, 5);
return { st, home, target, second };
}
{
const { st, home, target, second } = mkTwoColonyGame(4141);
check('the test galaxy offers a second site to found on', target >= 0);
if (target >= 0) {
const source = Logic.colonyAt(st, home);
const before = source.pop;
const cap = Logic.maxSendablePopulation(source);
check('maxSendablePopulation leaves the floor behind', cap === before - 0.5);
const ok = Logic.sendPopulation(RULES, st, 0, home, target, cap);
check('sendPopulation succeeds for a reachable, owned destination', ok);
check('the source colony loses exactly what was sent', Math.abs(source.pop - 0.5) < 1e-9,
`${source.pop}`);
// Already in transit (starIdx -1), so it is found on state.fleets
// directly rather than fleetsAt(home) — fleetsAt filters by starIdx,
// which this fleet no longer has.
const fleet = st.fleets.find((f) => f.empireIdx === 0
&& f.ships.some((s) => s.hullId === 'poptransport'));
check('a poptransport fleet departs immediately (already in transit)',
!!fleet && fleet.toStar === target && fleet.starIdx < 0);
check('the payload rides on the ship stack, not a fixed per-hull capacity',
Math.abs(fleet.ships[0].popPayload - cap) < 1e-9);
// Run it to arrival.
const destBefore = second.pop;
let turns = 0;
while (fleet.toStar >= 0 && turns < 200) { Logic.moveFleetsFor(RULES, st, 0); turns += 1; }
check('the transport arrives within a sane number of turns', turns < 200, `${turns}`);
// A near-total transfer from a mature homeworld can comfortably exceed
// a freshly founded colony's max population — delivery caps at that
// max rather than overflowing it, discarding the excess silently
// (a deliberate simplification: the sender's responsibility to check
// the destination has room, exactly like a colony ship arriving at a
// world already at capacity).
const maxPopSecond = Logic.colonyMaxPop(RULES, st, second);
check('arrival delivers the payload, capped at the destination\'s max population',
Math.abs(second.pop - Math.min(maxPopSecond, destBefore + cap)) < 1e-9,
`${second.pop} vs min(${maxPopSecond}, ${destBefore + cap})`);
check('the delivered payload never exceeds the destination\'s max population',
second.pop <= maxPopSecond + 1e-9);
check('the spent transport is cleaned up, not left idle forever',
!Logic.fleetsAt(st, target).some((f) => f.empireIdx === 0
&& f.ships.some((s) => s.hullId === 'poptransport')));
check('a populationSent event was recorded',
st.events.some((e) => e.type === 'populationSent' && e.empire === 0 && e.starIdx === home));
check('a populationDelivered event was recorded',
st.events.some((e) => e.type === 'populationDelivered' && e.empire === 0 && e.starIdx === target));
}
}
{
// Guardrails: a colony can never be fully emptied, and a request beyond
// what is available is clamped rather than refused outright.
const { st, home, target } = mkTwoColonyGame(4242);
if (target >= 0) {
const source = Logic.colonyAt(st, home);
const cap = Logic.maxSendablePopulation(source);
const ok = Logic.sendPopulation(RULES, st, 0, home, target, cap + 1000);
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);
}
}
{
// Refusals: not your colony, same star, not your destination, nothing to spare.
const { st, home, target } = mkTwoColonyGame(4343);
if (target >= 0) {
const enemyHome = st.galaxy.homeIdx[1];
check('sending from a colony you do not own is refused',
Logic.sendPopulation(RULES, st, 0, enemyHome, target, 1) === false);
check('sending to a star with no colony of yours is refused',
Logic.sendPopulation(RULES, st, 0, home, st.galaxy.homeIdx[1], 1) === false);
check('sending to the same star is refused',
Logic.sendPopulation(RULES, st, 0, home, home, 1) === false);
const source = Logic.colonyAt(st, home);
source.pop = 0.5;
check('a colony already at the floor has nothing left to send',
Logic.sendPopulation(RULES, st, 0, home, target, 1) === false);
}
}
{
// A poptransport in transit must never be swept up as invasion troops or
// combat power — it shares no hullId with 'transport' and has no weapons.
const { st, home, target } = mkTwoColonyGame(4444);
if (target >= 0) {
Logic.sendPopulation(RULES, st, 0, home, target, 1);
// Already in transit — see the note above about why this is
// state.fleets directly and not fleetsAt(home).
const fleet = st.fleets.find((f) => f.ships.some((s) => s.hullId === 'poptransport'));
check('invasionForecast ignores poptransport stacks',
Logic.invasionForecast(RULES, st, 1, home)?.troops === 0
|| Logic.invasionForecast(RULES, st, 1, home) === null);
check('poptransport contributes no combat power',
Logic.fleetPower(RULES, st, fleet) === 0);
}
}
}
// ---------------------------------------------------------------------------
section('5. Combat');
// ---------------------------------------------------------------------------
@ -1241,6 +1447,154 @@ section('7. Diplomacy and the Galactic Council');
check('the council reschedules itself', st2.council.nextTurn > st2.turn);
check('powerOf is finite for every empire',
st2.empires.every((e) => Number.isFinite(Diplo.powerOf(RULES, st2, e.idx))));
// --- checkContactAt: the star-scoped rewrite. Sharing a star triggers
// contact; being merely nearby (the old parsec-range behaviour) does not.
{
const st3 = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'cluster', seed: 909, difficultyId: 'normal',
speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: 0,
});
st3.rules = RULES;
const homes = new Set(st3.empires.map((e) => e.homeStar));
const spare = st3.galaxy.stars.map((s, i) => i).filter((i) => !homes.has(i));
const [starA, starB] = spare;
check('the test galaxy has two spare stars', starA >= 0 && starB >= 0 && starA !== starB);
if (starA >= 0 && starB >= 0) {
Logic.foundColony(RULES, st3, 0, starA, 0, 5);
Logic.foundColony(RULES, st3, 1, starB, 0, 5);
Logic.checkContactAt(RULES, st3, starA);
Logic.checkContactAt(RULES, st3, starB);
check('mere proximity does not create contact (old proximity behaviour is gone)',
!st3.empires[0].contacted[1]);
Logic.addFleet(RULES, st3, 1, starA, [{ hullId: 'scout', mark: 1, count: 1 }]);
Logic.checkContactAt(RULES, st3, starA);
check('sharing a star creates mutual contact',
st3.empires[0].contacted[1] === true && st3.empires[1].contacted[0] === true);
check('sharing a star pushes a contact event at that star',
st3.events.some((e) => e.type === 'contact' && e.starIdx === starA
&& [e.empire, e.other].includes(0) && [e.empire, e.other].includes(1)));
}
// --- proposeOrOffer / respondToOffer / expiry, using the contact just made.
const held = Diplo.proposeOrOffer(RULES, st3, 1, 0, 'peace');
check('an AI peace proposal to the human is held, not resolved', held === 'pending');
check('a held offer does not touch the treaty yet', st3.empires[0].treaties[1] !== 'peace');
check('a held offer is recorded on the human empire, keyed by proposer',
st3.empires[0].pendingOffers[1]?.kind === 'peace');
const offersBefore = st3.events.filter((e) => e.type === 'offerReceived').length;
Diplo.proposeOrOffer(RULES, st3, 1, 0, 'peace');
const offersAfter = st3.events.filter((e) => e.type === 'offerReceived').length;
check('re-proposing while one offer is outstanding does not duplicate it',
offersAfter === offersBefore);
const humanProposed = Diplo.proposeOrOffer(RULES, st3, 0, 1, 'peace');
check('a human proposal to an AI resolves immediately, never held',
typeof humanProposed === 'boolean');
check('a human proposal never creates a pendingOffers entry on the AI',
!st3.empires[1].pendingOffers[0]);
check('respondToOffer with no matching offer is a safe no-op',
Diplo.respondToOffer(RULES, st3, 0, 2, true) === false);
const accepted = Diplo.respondToOffer(RULES, st3, 0, 1, true);
check('accepting a held offer succeeds', accepted === true);
check('accepting a held peace offer applies the treaty both ways',
st3.empires[0].treaties[1] === 'peace' && st3.empires[1].treaties[0] === 'peace');
check('accepting clears the pending offer', !st3.empires[0].pendingOffers[1]);
const heldAlliance = Diplo.proposeOrOffer(RULES, st3, 1, 0, 'alliance');
check('a second kind of offer can be held once the first is resolved',
heldAlliance === 'pending');
const rejected = Diplo.respondToOffer(RULES, st3, 0, 1, false);
check('rejecting a held offer succeeds', rejected === true);
check('rejecting does not apply the treaty', st3.empires[0].treaties[1] !== 'alliance');
check('rejecting clears the pending offer', !st3.empires[0].pendingOffers[1]);
check('rejecting pushes an offerRejected event',
st3.events.some((e) => e.type === 'offerRejected' && e.empire === 0 && e.other === 1));
Diplo.proposeOrOffer(RULES, st3, 1, 0, 'alliance');
st3.turn += 10;
Diplo.runDiplomacyTurn(RULES, st3, 1);
check('a stale pending offer expires after enough turns',
st3.events.some((e) => e.type === 'offerExpired' && e.empire === 1 && e.other === 0));
}
// --- colonize() must trigger contact even when nobody's fleet just moved.
{
const stC = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'spiral', seed: 4242, difficultyId: 'normal',
speciesIds: ['human', 'kkrix'], humanIndex: 0,
});
let target = -1;
let orbit = -1;
stC.galaxy.stars.forEach((s, i) => {
if (target >= 0 || i === stC.empires[0].homeStar || i === stC.empires[1].homeStar) return;
const o = s.planets.findIndex((p, oo) => Logic.canColonize(RULES, stC, 1, i, oo));
if (o >= 0) { target = i; orbit = o; }
});
check('the colonize-contact galaxy offers somewhere to settle', target >= 0);
if (target >= 0) {
stC.empires[1].explored[target] = true;
Logic.addFleet(RULES, stC, 0, target, [{ hullId: 'scout', mark: 1, count: 1 }]);
Logic.addFleet(RULES, stC, 1, target, [{ hullId: 'colonyship', mark: 1, count: 1 }]);
const ok = Logic.colonize(RULES, stC, 1, target, orbit);
check('colonize succeeded onto the shared star', ok);
check('colonize() triggers contact without any fleet move',
ok && stC.empires[0].contacted[1] === true && stC.empires[1].contacted[0] === true);
}
}
// --- claimAudienceContacts: the routing helper deciding auto-open vs. the
// ordinary turn report.
{
const stD = Logic.createGame(RULES, {
sizeId: 'small', shapeId: 'cluster', seed: 5151, difficultyId: 'normal',
speciesIds: ['human', 'kkrix', 'lithox'], humanIndex: 0,
});
stD.rules = RULES;
stD.empires[0].contacted[1] = true; stD.empires[1].contacted[0] = true;
stD.empires[0].contacted[2] = true; stD.empires[2].contacted[0] = true;
stD.events.push({ type: 'contact', empire: 0, other: 1, starIdx: 1, turn: stD.turn });
stD.events.push({ type: 'contact', empire: 2, other: 0, starIdx: 2, turn: stD.turn });
const claimed = Diplo.claimAudienceContacts(RULES, stD, 0);
check('claimAudienceContacts claims only the diplomacy-capable contact',
claimed.length === 1 && claimed[0] === 1);
check('the claimed event is marked announced',
stD.events.find((e) => e.type === 'contact' && e.other === 1).announced === true);
check('the lithox contact event is left unannounced for the ordinary turn report',
stD.events.find((e) => e.type === 'contact' && e.empire === 2).announced !== true);
}
// --- VegaChat.js structural completeness: every diplomacy-capable species
// has every situation key the Audience screen's buttons and openers look
// up. pickLine's own empty-pool guard fails silently in production, so this
// is the only safety net for a content-authoring gap.
{
const SITUATIONS = [
'firstContact', 'proposePeace', 'proposeAlliance', 'declareWar', 'tradeTechOffer',
'replyPeaceAccept', 'replyPeaceReject', 'replyAllianceAccept', 'replyAllianceReject',
'replyWarDeclared', 'replyTechAccept', 'replyTechReject',
'offerPeaceOpener', 'offerAllianceOpener', 'acceptOffer', 'rejectOffer',
'afterAccepted', 'afterRejected',
];
const capable = RULES.speciesList.filter((s) => (s.traits.diplomacy ?? 0) > -100);
for (const s of capable) {
const chat = CHAT[s.id];
check(`VegaChat has an entry for ${s.id}`, !!chat);
if (!chat) continue;
for (const mood of ['angry', 'neutral', 'happy']) {
check(`VegaChat ${s.id}.opener.${mood} is non-empty`, chat.opener?.[mood]?.length > 0);
}
for (const key of SITUATIONS) {
check(`VegaChat ${s.id}.${key} is non-empty`, chat[key]?.length > 0);
}
}
check('lithox has a contact-only pool', CHAT.lithox?.contactOnly?.length > 0);
}
}
// ---------------------------------------------------------------------------
@ -1315,6 +1669,18 @@ section('9. Serialisation');
check('derived caches are never serialised', !json.includes('_comps') && !json.includes('_range')
&& !json.includes('_designs'));
// pendingOffers and chatLog are plain-object empire/state fields, same
// convention as contacted/treaties/attitude — no special-casing in
// serialize()/deserialize(), so a round-trip has to preserve them for free.
st.empires[0].pendingOffers[1] = { kind: 'peace', turn: st.turn };
st.chatLog = { 1: [{ who: 'o', text: 'Contact logged. State function.' }] };
const json2 = Logic.serialize(st);
const back2 = Logic.deserialize(json2);
check('pendingOffers survives a round-trip',
JSON.stringify(back2.empires[0].pendingOffers) === JSON.stringify(st.empires[0].pendingOffers));
check('chatLog survives a round-trip',
JSON.stringify(back2.chatLog) === JSON.stringify(st.chatLog));
const bad = JSON.parse(json);
bad.version = 99;
check('a save from another version is rejected', Logic.deserialize(JSON.stringify(bad)) === null);
@ -1358,6 +1724,20 @@ section('10. AI self-play soak');
if (!e.alive && Logic.empireColonies(st, e.idx).length > 0) {
return `${label}: dead empire ${e.idx} still holds colonies`;
}
// Trivially bounded (one entry per other empire at most), but the
// pending-offer engine is new — a leak here would mean an offer is
// never being cleared on accept/reject/expiry.
if (Object.keys(e.pendingOffers).length > st.empires.length) {
return `${label}: empire ${e.idx} has ${Object.keys(e.pendingOffers).length} pending offers`;
}
// Contact only ever grows and is always mutual — checkContactAt sets
// both sides in the same call, so an asymmetric or vanished entry means
// the star-scoped rewrite regressed.
for (const otherIdx of Object.keys(e.contacted)) {
if (e.contacted[otherIdx] && !st.empires[otherIdx]?.contacted[e.idx]) {
return `${label}: empire ${e.idx} contacted ${otherIdx} is not mutual`;
}
}
}
for (const c of st.colonies) {
if (!Number.isFinite(c.pop) || c.pop < 0) return `${label}: colony ${c.id} pop ${c.pop}`;