// 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); }