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:
parent
c84b668553
commit
5ce7fc2367
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue