diff --git a/assets/videos/vega/ship-kkrix-battleship.mp4 b/assets/videos/vega/ship-kkrix-battleship.mp4 new file mode 100644 index 0000000..8013295 Binary files /dev/null and b/assets/videos/vega/ship-kkrix-battleship.mp4 differ diff --git a/assets/videos/vega/ship-kkrix-colony.mp4 b/assets/videos/vega/ship-kkrix-colony.mp4 new file mode 100644 index 0000000..035c479 Binary files /dev/null and b/assets/videos/vega/ship-kkrix-colony.mp4 differ diff --git a/assets/videos/vega/ship-kkrix-cruiser.mp4 b/assets/videos/vega/ship-kkrix-cruiser.mp4 new file mode 100644 index 0000000..8d06913 Binary files /dev/null and b/assets/videos/vega/ship-kkrix-cruiser.mp4 differ diff --git a/assets/videos/vega/ship-kkrix-destroyer.mp4 b/assets/videos/vega/ship-kkrix-destroyer.mp4 new file mode 100644 index 0000000..20c2f7a Binary files /dev/null and b/assets/videos/vega/ship-kkrix-destroyer.mp4 differ diff --git a/assets/videos/vega/ship-kkrix-frigate.mp4 b/assets/videos/vega/ship-kkrix-frigate.mp4 new file mode 100644 index 0000000..f061a06 Binary files /dev/null and b/assets/videos/vega/ship-kkrix-frigate.mp4 differ diff --git a/assets/videos/vega/ship-kkrix-scout.mp4 b/assets/videos/vega/ship-kkrix-scout.mp4 new file mode 100644 index 0000000..ea33c2c Binary files /dev/null and b/assets/videos/vega/ship-kkrix-scout.mp4 differ diff --git a/assets/videos/vega/ship-mekhan-battleship.mp4 b/assets/videos/vega/ship-mekhan-battleship.mp4 new file mode 100644 index 0000000..b63f789 Binary files /dev/null and b/assets/videos/vega/ship-mekhan-battleship.mp4 differ diff --git a/assets/videos/vega/ship-mekhan-colony.mp4 b/assets/videos/vega/ship-mekhan-colony.mp4 new file mode 100644 index 0000000..a03ce4a Binary files /dev/null and b/assets/videos/vega/ship-mekhan-colony.mp4 differ diff --git a/assets/videos/vega/ship-mekhan-cruiser.mp4 b/assets/videos/vega/ship-mekhan-cruiser.mp4 new file mode 100644 index 0000000..673f2d9 Binary files /dev/null and b/assets/videos/vega/ship-mekhan-cruiser.mp4 differ diff --git a/assets/videos/vega/ship-mekhan-destroyer.mp4 b/assets/videos/vega/ship-mekhan-destroyer.mp4 new file mode 100644 index 0000000..941a18f Binary files /dev/null and b/assets/videos/vega/ship-mekhan-destroyer.mp4 differ diff --git a/assets/videos/vega/ship-mekhan-frigate.mp4 b/assets/videos/vega/ship-mekhan-frigate.mp4 new file mode 100644 index 0000000..ecc89cb Binary files /dev/null and b/assets/videos/vega/ship-mekhan-frigate.mp4 differ diff --git a/assets/videos/vega/ship-mekhan-scout.mp4 b/assets/videos/vega/ship-mekhan-scout.mp4 new file mode 100644 index 0000000..0eabbec Binary files /dev/null and b/assets/videos/vega/ship-mekhan-scout.mp4 differ diff --git a/assets/videos/vega/ship-mekhan-transport.mp4 b/assets/videos/vega/ship-mekhan-transport.mp4 new file mode 100644 index 0000000..bd67f49 Binary files /dev/null and b/assets/videos/vega/ship-mekhan-transport.mp4 differ diff --git a/data/mastervega-artwork.json b/data/mastervega-artwork.json index 311ff48..6748c58 100644 --- a/data/mastervega-artwork.json +++ b/data/mastervega-artwork.json @@ -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,19 @@ "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}, 3:2 (e.g. 1200x800 or 1080x720), muted, looping. 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--.mp4.", + "audienceVideos": { + "human": null, + "kestrelli": null, + "ursaal": null, + "umbrix": null, + "kkrix": null, + "mekhan": null, + "rrashaa": null, + "cerebrai": null, + "ssakar": null + }, + "sheets": { "ships": { "key": "vega-ships", diff --git a/data/mastervega-rules.json b/data/mastervega-rules.json index 43507a9..709599d 100644 --- a/data/mastervega-rules.json +++ b/data/mastervega-rules.json @@ -379,7 +379,6 @@ "shipUpkeepFraction": 0.02, "colonyBaseCost": 100, "reserveTransferLoss": 0.5, - "contactRange": 12, "baseFuelRange": 5, "starbaseRangeBonus": 4 }, diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 7159acf..4430b98 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -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,10 @@ 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.`); } } return notable; diff --git a/src/games/mastervega/VegaArt.js b/src/games/mastervega/VegaArt.js index 330aa80..cc1120f 100644 --- a/src/games/mastervega/VegaArt.js +++ b/src/games/mastervega/VegaArt.js @@ -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]; diff --git a/src/games/mastervega/VegaAudience.js b/src/games/mastervega/VegaAudience.js new file mode 100644 index 0000000..1e9b08d --- /dev/null +++ b/src/games/mastervega/VegaAudience.js @@ -0,0 +1,566 @@ +// 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; +} + +function buildMoodVisual(scene, rules, art, speciesId, mood, x, y, w, h, parent) { + let obj; + if (hasAudienceVideo(scene, speciesId, mood)) { + obj = scene.add.video(x, y, audienceVideoKey(speciesId, mood)); + obj.setMute(true); + obj.setLoop(true); + const fit = () => obj.setScale(w / sourceWidth(obj, w)); + fit(); + obj.on('created', fit); + obj.on('playing', fit); + obj.play(true); + } else { + obj = buildLetterboxedFallback(scene, rules, art, speciesId, x, y, w, h); + } + parent.add(obj); + return obj; +} + +// `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"). +function createMoodVisual(scene, rules, art, speciesId, x, y, w, h, parent) { + 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. + 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; } }; + } + const built = {}; + let current = null; + return { + show(mood) { + if (current === mood) return; + if (built[current]) { built[current].setVisible(false); built[current].pause?.(); } + if (!built[mood]) built[mood] = buildMoodVisual(scene, rules, art, speciesId, mood, x, y, w, h, parent); + built[mood].setVisible(true); + built[mood].play?.(true); + current = mood; + }, + get current() { return current; }, + }; +} + +/** 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 + +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)); + const moodVisual = createMoodVisual(scene, rules, art, speciesId, videoCx, videoCy, VIDEO_W, VIDEO_H, frame); + 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 }; +} diff --git a/src/games/mastervega/VegaChat.js b/src/games/mastervega/VegaChat.js new file mode 100644 index 0000000..9f8b401 --- /dev/null +++ b/src/games/mastervega/VegaChat.js @@ -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.]', + ], + }, +}; diff --git a/src/games/mastervega/VegaColonyView.js b/src/games/mastervega/VegaColonyView.js index c8458ea..e8f2908 100644 --- a/src/games/mastervega/VegaColonyView.js +++ b/src/games/mastervega/VegaColonyView.js @@ -400,9 +400,17 @@ 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); } diff --git a/src/games/mastervega/VegaDiplomacy.js b/src/games/mastervega/VegaDiplomacy.js index 8d518e3..ecbc329 100644 --- a/src/games/mastervega/VegaDiplomacy.js +++ b/src/games/mastervega/VegaDiplomacy.js @@ -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'); } } } diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index aea69f1..aa4ce33 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -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,35 @@ 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); } } } -// 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,6 +1247,7 @@ 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; diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 550fd17..d895284 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -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; } diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index b5f3471..e0dbca5 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -38,8 +38,9 @@ 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'; @@ -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,42 @@ 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)'); + // 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 +428,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', @@ -1241,6 +1294,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 +1516,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 +1571,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}`;