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
This commit is contained in:
Brian Fertig 2026-08-14 09:52:57 -06:00
parent c84b668553
commit 5ce7fc2367
3 changed files with 68 additions and 45 deletions

View File

@ -204,15 +204,24 @@ 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) => {
return state.empires
.filter((e) => e.alive && (e.idx === me || state.empires[me].contacted[e.idx]))
.map((e) => {
const atWar = [];
const allied = [];
const trade = [];
@ -222,9 +231,9 @@ export function relationsRows(rules, state) {
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`.
// 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 {

View File

@ -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);

View File

@ -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.
}
}