fertig-classic-games/src/games/mastervega/VegaGnn.js

414 lines
20 KiB
JavaScript

// Master of Vega — Galactic News Network: event classification, anchor
// copywriting, and ranking-page data. Headless, no Phaser imports, so it
// runs in Node (tools/verifyMasterOfVega.js) exactly like VegaTurnReport.js.
//
// GNN reads the same `state.events` bus VegaTurnReport.js's New Turn report
// does, but tracks its own consumption separately (`ev.gnnAnnounced`, not
// VegaTurnReport's `ev.announced`) so the two features never step on each
// other's flag despite watching the same array. GNN is additional to the
// New Turn report, not a replacement — Brian confirmed both stay (some
// double-announcement of war/peace/alliance/eliminated is intentional).
import { empireColonies, colonyProduction, empireFleetPower } from './VegaLogic.js';
import { pickLine } from './VegaChat.js';
import { attitudeOf, moodOf } from './VegaDiplomacy.js';
// The nine event types GNN ever turns into a story page. Council/election
// events are deliberately excluded — Brian is handling that separately.
export const GNN_STORY_TYPES = new Set([
'warDeclared', 'peace', 'alliance', 'techDone', 'lastColony', 'eliminated', 'spyCaught',
'sabotage', 'techStolen',
]);
/**
* Whether `ev` should ever become a GNN story. `techDone` is filtered three
* ways: only research-sourced completions (never espionage-stolen or traded
* techs — nobody can headline a tech nobody publicly knows was taken) count;
* only techs hand-flagged `gnnHeadline` in the rules data count as
* "breakthrough" news, the other ~75 techs stay ordinary; and it's
* contact-gated on the human (Brian's ask) — GNN can't headline a
* breakthrough by a species the player hasn't met yet, same logic as
* rankingRows/RANKING_METRICS staying off the page rotation until first
* contact. The human's own breakthroughs always qualify.
*
* `sabotage`/`techStolen` are visibility-gated on the VIEWER (the human),
* since a successful mission never reveals who did it (see
* describeGnnStory's `attributed` flag) but who gets to hear about it at all
* still depends on who's watching: the perpetrator and the victim always
* know (it happened to/because of them), but a true third party only picks
* up the rumor if they're in contact with the victim AND on decent terms
* with them (Brian's ask — hostile/cold empires don't share gossip). This is
* why isGnnStory needs `state`, unlike every other story type here.
*/
export function isGnnStory(rules, state, ev) {
if (!GNN_STORY_TYPES.has(ev.type)) return false;
if (ev.type === 'techDone') {
if (ev.source !== 'research' || !rules.techs[ev.techId]?.gnnHeadline) return false;
const me = state.humanIndex;
return ev.empire === me || !!state.empires[me]?.contacted?.[ev.empire];
}
if (ev.type === 'sabotage' || ev.type === 'techStolen') {
const me = state.humanIndex;
if (ev.empire === me || ev.target === me) return true;
if (!state.empires[me]?.contacted?.[ev.target]) return false;
const mood = moodOf(attitudeOf(state, me, ev.target));
return mood !== 'hostile' && mood !== 'cold';
}
return true;
}
/** Every GNN-worthy event not yet turned into a story, in chronological order. */
export function pendingGnnStories(rules, state) {
return state.events.filter((ev) => !ev.gnnAnnounced && isGnnStory(rules, state, ev));
}
// Replay buffer for the on-demand HUD button when there's nothing new to
// report — capped so a long game doesn't grow it without bound.
const GNN_HISTORY_CAP = 30;
/**
* Marks `events` consumed and appends them to the replay history. Deliberately
* separate bookkeeping from VegaTurnReport's `ev.announced` — both consumers
* read the same `state.events` array independently.
*/
export function consumeGnnStories(state, events) {
state.gnn ??= { history: [] };
for (const ev of events) {
ev.gnnAnnounced = true;
state.gnn.history.push(ev);
}
if (state.gnn.history.length > GNN_HISTORY_CAP) {
state.gnn.history = state.gnn.history.slice(-GNN_HISTORY_CAP);
}
}
/**
* Turns one event into third-person anchor copy — deliberately NOT
* VegaTurnReport's "we/us" phrasing, since GNN is a galaxy-wide broadcast,
* not a personal briefing. Returns
* `{type, kind, accent, headline, sub?, ...participant empire indices}`;
* `kind` picks the story renderer, `accent` picks the story-frame color,
* `type` is the raw event type — carried through so a second consumer (the
* anchor-line generator below) can dispatch on the exact event rather than
* re-deriving it from `kind`/`headline` text.
*/
export function describeGnnStory(rules, state, ev) {
const name = (i) => state.empires[i]?.name ?? 'An unknown empire';
switch (ev.type) {
case 'warDeclared': return {
type: ev.type, kind: 'diplomacy', accent: 'war', a: ev.empire, b: ev.other, verb: 'WAR DECLARED',
headline: `${name(ev.empire)} declares war on ${name(ev.other)}.`,
};
case 'peace': return {
type: ev.type, kind: 'diplomacy', accent: 'peace', a: ev.empire, b: ev.other, verb: 'PEACE',
headline: `${name(ev.empire)} and ${name(ev.other)} sign a peace treaty.`,
};
case 'alliance': return {
type: ev.type, kind: 'diplomacy', accent: 'alliance', a: ev.empire, b: ev.other, verb: 'ALLIANCE',
headline: `${name(ev.empire)} and ${name(ev.other)} form an alliance.`,
};
case 'techDone': {
const tech = rules.techs[ev.techId];
return {
type: ev.type, kind: 'tech', accent: 'tech', empire: ev.empire, techId: ev.techId,
headline: `${name(ev.empire)} unveils ${tech?.name ?? 'a new technology'}.`,
sub: tech?.desc ?? '',
};
}
case 'lastColony': return {
type: ev.type, kind: 'territory', accent: 'grim', victim: ev.empire, attacker: ev.attacker,
headline: ev.attacker >= 0
? `${name(ev.empire)} reduced to a single world by ${name(ev.attacker)}.`
: `${name(ev.empire)} has been reduced to a single world.`,
};
case 'eliminated': return {
type: ev.type, kind: 'territory', accent: 'grim', victim: ev.empire, attacker: ev.attacker,
headline: ev.attacker >= 0
? `${name(ev.empire)} has been eliminated from the galaxy — by ${name(ev.attacker)}.`
: `${name(ev.empire)} has been eliminated from the galaxy.`,
};
case 'spyCaught': return {
type: ev.type, kind: 'espionage', accent: 'espionage', perpetrator: ev.empire, victim: ev.target,
mission: ev.mission,
headline: `${name(ev.empire)} agents caught red-handed targeting ${name(ev.target)}.`,
sub: ev.mission === 'sabotage' ? 'Mission: sabotage.' : 'Mission: technology theft.',
};
// A SUCCESSFUL mission, unlike spyCaught above, never names a culprit —
// `attributed` is only true when the viewing human IS the perpetrator
// (isGnnStory's own contact/attitude gate is what decides whether a true
// third party ever sees this at all; the victim always does, same
// anonymous copy as a bystander since even the victim's own GNN never
// outs the thief — VegaTurnReport.js's ticker log is where the victim's
// *personal* briefing already names them in full).
case 'sabotage': {
const attributed = ev.empire === state.humanIndex;
return {
type: ev.type, kind: 'espionageResult', accent: 'espionage', attributed,
perpetrator: ev.empire, victim: ev.target,
headline: attributed
? `${name(ev.empire)} agents sabotage ${name(ev.target)}'s colony, destroying ${ev.factoriesLost} factories and ${ev.defenseLost} defense.`
: `${name(ev.target)} report a colony crippled by an unknown saboteur.`,
};
}
case 'techStolen': {
const attributed = ev.empire === state.humanIndex;
const techName = rules.techs[ev.techId]?.name ?? 'a technology';
return {
type: ev.type, kind: 'espionageResult', accent: 'espionage', attributed,
perpetrator: ev.empire, victim: ev.target, techId: ev.techId,
headline: attributed
? `${name(ev.empire)} agents successfully steal ${techName} from ${name(ev.target)}.`
: `${name(ev.target)} believe their technology '${techName}' has been stolen.`,
};
}
default: return { type: ev.type, kind: 'unknown', accent: 'tech', headline: ev.type };
}
}
// --------------------------------------------------------------------------
// MOO1-style rankings
export const RANKING_METRICS = [
{ id: 'population', label: 'POPULATION', valueFn: (rules, state, e) => state.empires[e].totalPop },
{
id: 'production',
label: 'GNP / PRODUCTION',
valueFn: (rules, state, e) => empireColonies(state, e)
.reduce((t, c) => t + colonyProduction(rules, state, c), 0),
},
// Total techs known, not a research rate (nothing in VegaLogic persists a
// per-turn rate) — already the same term VegaDiplomacy.js's powerOf() uses
// for its own research-strength component, and the one number a single
// bar-chart page can show without inventing a stacked per-field variant.
{ id: 'research', label: 'RESEARCH', valueFn: (rules, state, e) => state.empires[e].techsKnown },
{ id: 'military', label: 'FLEET STRENGTH', valueFn: (rules, state, e) => empireFleetPower(rules, state, e) },
{ id: 'treasury', label: 'TREASURY', valueFn: (rules, state, e) => state.empires[e].bc },
];
/**
* Ranking rows for one metric — the human plus every alive empire the human
* has met, sorted descending. Same contact-gating shape VegaScreens.js:164
* already uses for its own "empires I know about" list.
*/
export function rankingRows(rules, state, metricId) {
const me = state.humanIndex;
const metric = RANKING_METRICS.find((m) => m.id === metricId);
return state.empires
.filter((o) => o.alive && (o.idx === me || state.empires[me].contacted[o.idx]))
.map((o) => ({
idx: o.idx, name: o.name, color: o.color, speciesId: o.speciesId,
value: metric.valueFn(rules, state, o.idx),
}))
.sort((a, b) => b.value - a.value);
}
// --------------------------------------------------------------------------
// Diplomatic relations — the always-accessible page listing who's at war,
// who has a trade agreement, and who's allied with whom. Deliberately NOT
// contact-gated like rankingRows: war/peace/alliance are already broadcast
// galaxy-wide regardless of whether the human has met either party (see
// VegaTurnReport.js's PERSONAL_TYPES — diplomacy events are never personal),
// so this page is just that same "public knowledge" precedent laid out as a
// standing reference instead of one-off headlines.
export function relationsRows(rules, state) {
const name = (i) => state.empires[i]?.name ?? '?';
return state.empires.filter((e) => e.alive).map((e) => {
const atWar = [];
const allied = [];
const trade = [];
for (const o of state.empires) {
if (o.idx === e.idx || !o.alive) continue;
const treaty = e.treaties[o.idx];
if (treaty === 'war') atWar.push(name(o.idx));
else if (treaty === 'alliance') allied.push(name(o.idx));
// A trade agreement is a separate, coexisting relationship (see
// VegaDiplomacy.js's formTradeAgreement comment) — not another rung of
// the treaties[] ladder — so it's checked independently of the switch
// above rather than as another `else if`.
if (e.tradeAgreements[o.idx]) trade.push(name(o.idx));
}
return {
idx: e.idx, name: e.name, color: e.color, speciesId: e.speciesId, atWar, allied, trade,
};
});
}
// --------------------------------------------------------------------------
// Anchor-desk narration — the green-screen "news ticker" line under GNN's
// looping anchor video. Deliberately separate text from the on-screen
// headline/rankings, phrased as spoken narration rather than a printed
// headline, and deliberately varied (pickLine, same random-template-plus-
// {token}-substitution convention VegaChat.js's diplomacy lines already
// use) so it doesn't read as the same sentence with the nouns swapped every
// time, and doesn't always open the same way either.
const WAR_LINES = [
'This just in, {a} has declared war on {b}.',
'Breaking: hostilities erupt as {a} declares war on {b}.',
'Sources confirm {a} and {b} are now at war.',
'Tensions boil over tonight — {a} declares war on {b}.',
'From the frontier: {a} has opened hostilities against {b}.',
];
const PEACE_LINES = [
'This just in, {a} and {b} have signed a peace treaty.',
'Word from the negotiating table: {a} and {b} are now at peace.',
'Breaking: {a} and {b} lay down arms in a new peace accord.',
'Diplomats confirm {a} and {b} have ended hostilities.',
"Tonight's top story: peace returns between {a} and {b}.",
];
const ALLIANCE_LINES = [
'This just in, {a} and {b} have formed an alliance.',
'Breaking: {a} and {b} unite in a new alliance.',
'Word from the Core Worlds: {a} and {b} have signed an alliance pact.',
'Sources confirm a new alliance between {a} and {b}.',
"Tonight's top story: {a} and {b} now stand united.",
];
const TECH_LINES = [
'This just in, {empire} scientists unveil {tech}.',
'Breaking: {empire} announces the completion of {tech}.',
'Word from the labs: {empire} has achieved {tech}.',
'Sources confirm {empire} researchers have perfected {tech}.',
'In technological news, {empire} unveils {tech} to the galaxy.',
];
const LAST_COLONY_WITH_ATTACKER_LINES = [
'This just in, {victim} has been reduced to a single world after a crushing defeat by {attacker}.',
'Breaking: {victim} clings to survival with just one colony left, following heavy losses to {attacker}.',
'Word from the front: {attacker} forces have left {victim} with only one world standing.',
];
const LAST_COLONY_LINES = [
'This just in, {victim} has been reduced to a single world.',
'Breaking: {victim} now holds only one colony in the entire galaxy.',
'Sources confirm {victim} clings to survival with just one world remaining.',
];
const ELIMINATED_WITH_ATTACKER_LINES = [
'This just in, {victim} has been wiped from the galactic map by {attacker}.',
'Breaking: {victim} is no more, eliminated by {attacker}.',
'Sources confirm the fall of {victim} at the hands of {attacker}.',
];
const ELIMINATED_LINES = [
'This just in, {victim} has been eliminated from the galaxy.',
'Breaking: {victim} is no more.',
'Word tonight: the {victim} are no longer among the stars.',
];
const ESPIONAGE_LINES = [
'This just in, {perp} agents caught red-handed targeting {victim}.',
'Breaking: an espionage scandal rocks the galaxy as {perp} operatives are exposed against {victim}.',
'Sources confirm {perp} spies caught attempting {mission} against {victim}.',
'Word from Galactic Security: {perp} agents apprehended mid-{mission} against {victim}.',
];
const ESPIONAGE_SUCCESS_LINES = [
'This just in, {perp} agents strike a covert blow against {victim}.',
'Breaking: {victim} left reeling after a covert operation by {perp}.',
'Sources confirm {perp} intelligence services scored a hit against {victim} tonight.',
];
const ESPIONAGE_RUMOR_LINES = [
'Rumors swirl tonight of an unexplained security breach in {victim} space.',
'Word on the street: {victim} suffered a covert attack of unknown origin.',
'Sources close to {victim} report foul play, though no culprit has been named.',
];
const RANKING_LINES = {
population: [
'This just in, {leader} population reaches new galactic heights at {value}.',
'Breaking: {leader} now leads the galaxy in population, {value} strong.',
'Word from the census bureau: {leader} tops the population charts at {value}.',
'{leader} continues to grow, now boasting a population of {value} across their worlds.',
],
production: [
'This just in, {leader} tops the charts in industrial output at {value}.',
'Breaking: factories roar across {leader} space — {value} in total production.',
'Sources confirm {leader} leads the galaxy in production with {value}.',
"Tonight's economic report: {leader} industry churns out {value} in output.",
],
research: [
'This just in, {leader} leads the galaxy in technology with {value} advances mastered.',
'Breaking: {leader} scientists have unlocked {value} technologies, the most in known space.',
'Word from the laboratories: {leader} research leads the pack at {value} technologies.',
],
military: [
'This just in, {leader} fields the mightiest fleet in known space, rated at {value}.',
'Breaking: {leader} war fleets dominate the standings with a strength of {value}.',
'Sources confirm {leader} holds the strongest military in the galaxy at {value}.',
],
treasury: [
'This just in, {leader} banks the largest treasury in the galaxy — {value} BC and counting.',
'Breaking: coffers overflow in {leader} space, a treasury of {value} BC.',
'Word from the exchequer: {leader} leads the galaxy in wealth with {value} BC.',
],
};
const RELATIONS_QUIET_LINES = [
'This just in, an uneasy calm blankets the galaxy tonight — no wars, alliances, or trade pacts on record.',
'Word tonight: the galaxy remains quiet, with no formal treaties of any kind to report.',
];
const RELATIONS_LINES = [
'This just in, the galaxy currently counts {warCount} active conflicts and {allianceCount} standing alliances.',
'Diplomatic climate check: {tradeCount} trade routes keep the galactic economy humming, while {warCount} wars rage on.',
'Word from the Core Worlds: {allianceCount} alliances hold firm even as {warCount} empires remain at war.',
"Tonight's galactic report: {warCount} wars, {allianceCount} alliances, and {tradeCount} trade agreements crisscross known space.",
];
const fmtNum = (v) => Math.round(v).toLocaleString();
function anchorLineForStory(rules, state, desc) {
const empName = (i) => state.empires[i]?.name ?? 'an unknown empire';
switch (desc.kind) {
case 'diplomacy': {
const vars = { a: empName(desc.a), b: empName(desc.b) };
const pool = desc.type === 'warDeclared' ? WAR_LINES
: desc.type === 'peace' ? PEACE_LINES : ALLIANCE_LINES;
return pickLine(pool, vars);
}
case 'tech': {
const tech = rules.techs[desc.techId]?.name ?? 'a new technology';
return pickLine(TECH_LINES, { empire: empName(desc.empire), tech });
}
case 'territory': {
const victim = empName(desc.victim);
const hasAttacker = desc.attacker >= 0;
const attacker = hasAttacker ? empName(desc.attacker) : null;
if (desc.type === 'lastColony') {
return pickLine(hasAttacker ? LAST_COLONY_WITH_ATTACKER_LINES : LAST_COLONY_LINES, { victim, attacker });
}
return pickLine(hasAttacker ? ELIMINATED_WITH_ATTACKER_LINES : ELIMINATED_LINES, { victim, attacker });
}
case 'espionage': {
const mission = desc.mission === 'sabotage' ? 'sabotage' : 'technology theft';
return pickLine(ESPIONAGE_LINES, { perp: empName(desc.perpetrator), victim: empName(desc.victim), mission });
}
case 'espionageResult': {
const vars = { perp: empName(desc.perpetrator), victim: empName(desc.victim) };
return pickLine(desc.attributed ? ESPIONAGE_SUCCESS_LINES : ESPIONAGE_RUMOR_LINES, vars);
}
default: return desc.headline ?? '';
}
}
function anchorLineForRanking(rules, state, metric) {
const rows = rankingRows(rules, state, metric.id);
if (!rows.length) return 'This just in, no galactic data is currently available.';
const leader = rows[0];
const pool = RANKING_LINES[metric.id] ?? ['This just in, {leader} leads the way with {value}.'];
return pickLine(pool, { leader: leader.name, value: fmtNum(leader.value) });
}
function anchorLineForRelations(rules, state) {
const rows = relationsRows(rules, state);
const warCount = Math.round(rows.reduce((t, r) => t + r.atWar.length, 0) / 2);
const allianceCount = Math.round(rows.reduce((t, r) => t + r.allied.length, 0) / 2);
const tradeCount = Math.round(rows.reduce((t, r) => t + r.trade.length, 0) / 2);
if (warCount === 0 && allianceCount === 0 && tradeCount === 0) return pickLine(RELATIONS_QUIET_LINES, {});
return pickLine(RELATIONS_LINES, { warCount, allianceCount, tradeCount });
}
/**
* The anchor-desk narration line for whatever page GNN is currently
* showing — `page` is the exact `{kind, desc?, metric?}` shape
* VegaGnnScreen.js's own pager builds. Re-picked (and re-typed on screen)
* every time the page changes, never cached, so revisiting a page can land
* on a different variant.
*/
export function anchorLine(rules, state, page) {
if (page.kind === 'story') return anchorLineForStory(rules, state, page.desc);
if (page.kind === 'ranking') return anchorLineForRanking(rules, state, page.metric);
return anchorLineForRelations(rules, state);
}