From 5ce7fc23673c8df0dc0abfc249f142fb4889b937 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 14 Aug 2026 09:52:57 -0600 Subject: [PATCH] feat(mastervega): contact-gate relationsRows and expand alert suppression to tech - relationsRows now filters out empires the human hasn't contacted, matching the existing contact-gating behavior of rankingRows (Brian, 2026-08-14) - Expand GNN alert suppression from espionage-only to include tech breakthroughs via new ALERT_STORY_KINDS set, skipping ranking/relations pages for these one-off notifications - Update verifyMasterOfVega tests to assert uncontacted empires are excluded from relationsRows while confirming names can still appear in contacted rows' war/trade/ally lists --- src/games/mastervega/VegaGnn.js | 59 +++++++++++++++------------ src/games/mastervega/VegaGnnScreen.js | 31 ++++++++------ tools/verifyMasterOfVega.js | 23 +++++++---- 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/src/games/mastervega/VegaGnn.js b/src/games/mastervega/VegaGnn.js index ffef1c5..d886927 100644 --- a/src/games/mastervega/VegaGnn.js +++ b/src/games/mastervega/VegaGnn.js @@ -204,33 +204,42 @@ export function rankingRows(rules, state, metricId) { // -------------------------------------------------------------------------- // 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. +// who has a trade agreement, and who's allied with whom. Contact-gated same +// as rankingRows (Brian's ask, 2026-08-14): a species the human hasn't met +// yet gets a full name/portrait row on a page that otherwise reads as "here +// is everyone" before the human has actually discovered them, which spoils +// the reveal that first contact is supposed to be (checkContactAt's own +// comment in VegaLogic.js). War/peace/alliance news for two OTHER empires +// still broadcasts galaxy-wide as its own headline regardless of contact +// (VegaTurnReport.js's PERSONAL_TYPES) — that is unaffected, since this only +// gates which rows appear on the standing reference table, not whether the +// one-off headline fires. An uncontacted empire's NAME can still show up +// inside a contacted row's war/trade/ally list; only the row itself is +// gated. export function relationsRows(rules, state) { + const me = state.humanIndex; 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, - }; - }); + return state.empires + .filter((e) => e.alive && (e.idx === me || state.empires[me].contacted[e.idx])) + .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, + }; + }); } // -------------------------------------------------------------------------- diff --git a/src/games/mastervega/VegaGnnScreen.js b/src/games/mastervega/VegaGnnScreen.js index fe13f5f..e346d52 100644 --- a/src/games/mastervega/VegaGnnScreen.js +++ b/src/games/mastervega/VegaGnnScreen.js @@ -71,6 +71,12 @@ const STORY_COLORS = { const hex = (c) => `#${c.toString(16).padStart(6, '0')}`; const fmtValue = (v) => Math.round(v).toLocaleString(); +// Story kinds (Gnn.describeGnnStory's `kind` field) that read as a targeted, +// one-off alert rather than a newscast — openGnnScreen skips the ranking and +// relations pages for these so a fresh notification doesn't get buried under +// unrelated charts. See openGnnScreen's isAlertPage for the full reasoning. +const ALERT_STORY_KINDS = new Set(['espionage', 'espionageResult', 'tech']); + // -------------------------------------------------------------- anchor panel function buildAnchorFallback(scene, container, w, h) { @@ -583,27 +589,28 @@ export function openGnnScreen(scene, rules, state, art, opts = {}) { const storyEvents = usingHistory ? (state.gnn?.history ?? []) : pending; const storyPages = storyEvents.map((ev) => ({ kind: 'story', desc: Gnn.describeGnnStory(rules, state, ev) })); - // A spying/sabotage notification is a targeted alert, not a newscast — - // skip the ranking/relations pages so it reads as "here's what just - // happened," not "here's what just happened, now flip through five - // unrelated charts" (Brian's ask). Only suppresses on a FRESH pending - // notification; reopening GNN on demand with nothing pending - // (usingHistory) always gets the full experience back — that reopen IS - // the escape hatch for a player who does want the charts. - const isEspionageAlert = !usingHistory - && storyPages.some((p) => p.desc.kind === 'espionage' || p.desc.kind === 'espionageResult'); + // A spying/sabotage notification — or a tech breakthrough — is a targeted, + // one-off alert, not a newscast — skip the ranking/relations pages so it + // reads as "here's what just happened," not "here's what just happened, + // now flip through five unrelated charts" (Brian's ask; tech added to the + // same set on the same ask, 2026-08-14). ALERT_STORY_KINDS is the place to + // add another kind later. Only suppresses on a FRESH pending notification; + // reopening GNN on demand with nothing pending (usingHistory) always gets + // the full experience back — that reopen IS the escape hatch for a player + // who does want the charts. + const isAlertPage = !usingHistory && storyPages.some((p) => ALERT_STORY_KINDS.has(p.desc.kind)); // The charts rank the human against every empire it has met (rankingRows // is contact-gated) — with nobody met yet that's just a one-bar "race" // against yourself, which reads as broken rather than informative. So the // whole ranking rotation stays off the page list until first contact - // (Brian's ask), same suppression flag as the espionage-alert case above. + // (Brian's ask), same suppression flag as the alert-page case above. const me = state.humanIndex; const hasMetAnyone = state.empires.some((o) => o.alive && o.idx !== me && state.empires[me].contacted[o.idx]); - const rankingPages = (isEspionageAlert || !hasMetAnyone) + const rankingPages = (isAlertPage || !hasMetAnyone) ? [] : Gnn.RANKING_METRICS.map((metric) => ({ kind: 'ranking', metric })); // Always-accessible otherwise: present every time GNN opens, independent // of whether there's a pending story — same footing as the ranking pages. - const relationsPage = isEspionageAlert ? null : { kind: 'relations' }; + const relationsPage = isAlertPage ? null : { kind: 'relations' }; const pages = [...storyPages, ...rankingPages, ...(relationsPage ? [relationsPage] : [])]; const root = scene.add.container(0, 0).setDepth(D.gnn); diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index 8bcc160..46db882 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -3231,9 +3231,12 @@ section('7b. Galactic News Network'); // --- relationsRows: war/trade/alliance are three independent columns // (formTradeAgreement's own comment: trade coexists with any treaty rung - // rather than being another value on it), and — unlike rankingRows — - // NOT contact-gated, since diplomacy events already broadcast regardless - // of contact (VegaTurnReport.js's PERSONAL_TYPES). + // rather than being another value on it). Contact-gated same as + // rankingRows (Brian's ask, 2026-08-14): an uncontacted empire gets no row + // of its own, even though a NAME it's involved in can still surface inside + // a contacted empire's own war/trade/ally lists (those are public + // broadcast data — VegaTurnReport.js's PERSONAL_TYPES — untouched by this + // gate). { const st6 = Logic.createGame(RULES, { sizeId: 'medium', shapeId: 'elliptical', seed: 1010, difficultyId: 'normal', @@ -3245,10 +3248,12 @@ section('7b. Galactic News Network'); Diplo.declareWar(RULES, st6, 0, 1); Diplo.formAlliance(RULES, st6, 1, 2); Diplo.formTradeAgreement(RULES, st6, 0, 2); + st6.empires[0].contacted[1] = true; st6.empires[1].contacted[0] = true; + st6.empires[0].contacted[2] = true; st6.empires[2].contacted[0] = true; const relRows = Gnn.relationsRows(RULES, st6); - check('relationsRows returns one row per alive empire, contact or not', - relRows.length === 4); + check('relationsRows excludes an empire the human has never contacted', + relRows.length === 3 && !relRows.some((r) => r.idx === 3)); const byIdx = Object.fromEntries(relRows.map((r) => [r.idx, r])); check('war is reciprocal', byIdx[0].atWar.includes(st6.empires[1].name) && byIdx[1].atWar.includes(st6.empires[0].name)); @@ -3257,10 +3262,12 @@ section('7b. Galactic News Network'); && byIdx[1].atWar.length === 1 && !byIdx[1].atWar.includes(st6.empires[2].name)); check('trade agreement is reciprocal and independent of treaty state', byIdx[0].trade.includes(st6.empires[2].name) && byIdx[2].trade.includes(st6.empires[0].name)); - check('an empire with no relations at all has three empty lists', - byIdx[3].atWar.length === 0 && byIdx[3].trade.length === 0 && byIdx[3].allied.length === 0); check('an empire not at war with everyone does not falsely list the uninvolved', - !byIdx[0].atWar.includes(st6.empires[2].name) && !byIdx[0].atWar.includes(st6.empires[3].name)); + !byIdx[0].atWar.includes(st6.empires[2].name)); + // 3 is uncontacted, so it has no row — but it can still surface as a + // NAME inside a contacted empire's own lists, unaffected by the gate. + // No such reference exists in this fixture (3 has no relations at all), + // so there is nothing further to assert here beyond its row being gone. } }