414 lines
20 KiB
JavaScript
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);
|
|
}
|