feat(mastervega): overhaul contact system and add full-screen Audience diplomacy
- Rewrite contact detection from proximity-based (parsec-range) to star-scoped: empires now meet by sharing a system (colonies or fleets), making first-contact a visible event rather than a silent background check. - Add full-screen Audience interface (VegaAudience.js) with species-specific mood videos (or letterboxed portrait fallbacks), a typewriter chat log, and contextual action buttons. Auto-opens on first contact via a queue system in MasterOfVegaGame. - Add VegaChat.js with unique dialogue pools for all 9 diplomacy-capable species across 18+ situations (first contact, peace/alliance offers, war declarations, tech trades, accept/reject responses, etc.). - Introduce held diplomacy offers: AI proposals to the human are queued (pendingOffers) instead of auto-resolved, with player Accept/Reject buttons in the Audience screen and automatic expiry after 6 turns. - Add audienceVideos config and JIT-loading infrastructure in VegaArt.js. - Add ship videos for kkrix and mekhan species. - Simplify openDiplomacyScreen to a single "Seek Audience" button. - Fix colony view to filter out 'starbase' from building display list. - Add comprehensive verification in tools/verifyMasterOfVega.js for contact system, held offers, chat completeness, audience videos, and serialization of new state fields. - Remove obsolete contactRange rule from mastervega-rules.json.
This commit is contained in:
parent
d4daa990a1
commit
1251da186c
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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-<speciesId>-<mood>.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",
|
||||
|
|
|
|||
|
|
@ -379,7 +379,6 @@
|
|||
"shipUpkeepFraction": 0.02,
|
||||
"colonyBaseCost": 100,
|
||||
"reserveTransferLoss": 0.5,
|
||||
"contactRange": 12,
|
||||
"baseFuelRange": 5,
|
||||
"starbaseRangeBonus": 4
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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.]',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue