feat(master-vega): add Galactic Council Session ceremony UI

Introduce a full-screen, click-advanced ceremony (VegaCouncilSession.js)
that replays runCouncil()'s result delegate-by-delegate instead of hiding
the tally behind the turn-report. Three fixed stages — Roster, Voting,
and Verdict — with a persistent 2:3 portrait anchor video, green-screen
ticker, live scoreboard with animated bars, and sting-then-loop audio.

Key changes:
- VegaLogic.js: expose per-voter `voters` breakdown and `pendingSession`
  flag so the UI can replay the session before the victory check.
- MasterOfVegaGame.js: consume `pendingSession` in `runToHumanTurn()`
  before `state.over`, ensuring a decisive council still gets its ceremony.
- VegaTurnReport.js: remove `council` from NOTABLE_TYPES (ceremony handles
  it); keep `councilRefused` as a distinct war-declaration row.
- assetManifest.js / Sounds.js: register video + sting/loop audio cues.
- VegaScreens.js: add `D.council` depth value.
- verifyMasterOfVega.js: assert voter breakdown consistency and pending
  session lifecycle.
This commit is contained in:
Brian Fertig 2026-08-13 14:37:27 -06:00
parent 0c6c4b8452
commit b145beb28d
10 changed files with 606 additions and 26 deletions

Binary file not shown.

Binary file not shown.

View File

@ -168,6 +168,9 @@ export const MANIFEST = {
// than a mastervega-artwork.json map entry. Eager-loaded so the first // than a mastervega-artwork.json map entry. Eager-loaded so the first
// post-turn GNN open never stalls on a JIT fetch. // post-turn GNN open never stalls on a JIT fetch.
{ type: 'video', key: 'vega-gnn-anchor', path: 'assets/videos/vega/gnn.mp4' }, { type: 'video', key: 'vega-gnn-anchor', path: 'assets/videos/vega/gnn.mp4' },
// The Galactic Council session ceremony's persistent anchor visual, same
// 2:3 eager-load treatment as the GNN clip above (VegaCouncilSession.js).
{ type: 'video', key: 'vega-council-anchor', path: 'assets/videos/vega/galactic-council.mp4' },
// The colony-founding cue. It is not part of the shuffled soundtrack — // The colony-founding cue. It is not part of the shuffled soundtrack —
// VegaColonyIntro.js ducks that and plays this over the vignette instead. // VegaColonyIntro.js ducks that and plays this over the vignette instead.
// Small, and it has to be ready the instant the vignette opens. // Small, and it has to be ready the instant the vignette opens.
@ -198,6 +201,10 @@ export const MANIFEST = {
// the same way VegaColonyIntro.js's founding cue already does. // the same way VegaColonyIntro.js's founding cue already does.
{ type: 'audio', key: 'sfx-vega-gnn-sting', path: 'assets/fx/vega/vega-gnn.mp3' }, { type: 'audio', key: 'sfx-vega-gnn-sting', path: 'assets/fx/vega/vega-gnn.mp3' },
{ type: 'audio', key: 'sfx-vega-gnn-loop', path: 'assets/music/vega/vega-gnn.mp3' }, { type: 'audio', key: 'sfx-vega-gnn-loop', path: 'assets/music/vega/vega-gnn.mp3' },
// Same sting-then-loop shape for the Council Session ceremony
// (VegaCouncilSession.js's openCouncilSessionScreen).
{ type: 'audio', key: 'sfx-vega-council-intro', path: 'assets/fx/vega/vega-council-intro.mp3' },
{ type: 'audio', key: 'sfx-vega-council-loop', path: 'assets/music/vega/vega-council.mp3' },
// Combat weapon cues, Mark-banded (see VegaCombat.js's weaponSfxKey()). // Combat weapon cues, Mark-banded (see VegaCombat.js's weaponSfxKey()).
{ type: 'audio', key: 'sfx-vega-weapon-123', path: 'assets/fx/vega/vega-weapon-123.mp3' }, { type: 'audio', key: 'sfx-vega-weapon-123', path: 'assets/fx/vega/vega-weapon-123.mp3' },
{ type: 'audio', key: 'sfx-vega-weapon-45', path: 'assets/fx/vega/vega-weapon-45.mp3' }, { type: 'audio', key: 'sfx-vega-weapon-45', path: 'assets/fx/vega/vega-weapon-45.mp3' },

View File

@ -38,6 +38,7 @@ import { openTurnReportScreen } from './VegaTurnReportScreen.js';
import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js'; import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js';
import * as Gnn from './VegaGnn.js'; import * as Gnn from './VegaGnn.js';
import { openGnnScreen } from './VegaGnnScreen.js'; import { openGnnScreen } from './VegaGnnScreen.js';
import { openCouncilSessionScreen } from './VegaCouncilSession.js';
import { openAudienceScreen } from './VegaAudience.js'; import { openAudienceScreen } from './VegaAudience.js';
import { playIntroVideo } from './VegaIntroVideo.js'; import { playIntroVideo } from './VegaIntroVideo.js';
import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js'; import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js';
@ -1191,6 +1192,26 @@ export default class MasterOfVegaGame extends Phaser.Scene {
// synchronously, then the interesting events are replayed for the player. // synchronously, then the interesting events are replayed for the player.
runToHumanTurn() { runToHumanTurn() {
const step = () => { const step = () => {
// The Council Session ceremony pre-empts everything, including a
// decisive-victory state.over below — it can fire mid-AI-turn-stepping
// (VegaLogic.js's runCouncil runs inside endEmpireTurn's turn-wrap, on
// whichever empire's turn happens to close out the calendar turn), and
// a council win that ends the game still deserves its ceremony instead
// of jumping straight to the victory overlay. Manages modalOpen
// directly rather than through this.openModal() — same reasoning as
// runAudienceQueue()'s own comment: a synchronous recursive call back
// into step() from inside openModal's own done() would see modalOpen
// still true (its 60ms clear hasn't fired yet) and silently no-op.
if (this.state.council.pendingSession) {
this.state.council.pendingSession = false;
this.modalOpen = true;
openCouncilSessionScreen(this, this.rules, this.state, this.art, () => {
this.modalOpen = false;
this.refreshAll();
step();
});
return;
}
if (this.state.over) { this.finishGame(); return; } if (this.state.over) { this.finishGame(); return; }
if (this.state.current === this.state.humanIndex) { if (this.state.current === this.state.humanIndex) {
Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex); Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex);

View File

@ -0,0 +1,506 @@
// Master of Vega — the Galactic Council session ceremony: a full-screen
// dramatization of runCouncil()'s already-computed result (VegaLogic.js),
// in the same full-screen-takeover family as VegaGnnScreen.js and
// VegaColonyIntro.js. Triggered from MasterOfVegaGame.js's runToHumanTurn()
// the instant state.council.pendingSession is set — BEFORE the state.over
// victory check, so a decisive session still gets its ceremony instead of
// jumping straight to the victory overlay.
//
// Three fixed stages, advanced only by explicit clicks (Brian's ask — no
// auto-advance, no ESC-to-skip):
// 1. Roster — every empire still alive, the two candidates called out.
// 2. Voting — "Begin Voting" reveals each delegate's vote one at a time
// via "Next Species Vote", SMALLEST population first — which puts the
// two candidates (who always vote for themselves, and are by
// definition the two largest empires) last, the natural climax. A live
// scoreboard fills in as each vote lands.
// 3. Verdict — winner elected / refused-to-submit / no-majority, reusing
// the exact wording VegaScreens.js's openCouncilScreen already uses
// for the on-demand HUD summary.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { Button } from './VegaButton.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { FONT, D } from './VegaScreens.js';
import { frame } from './VegaColonyView.js';
import { makeSpeciesPortrait, sourceWidth } from './VegaArt.js';
import { createAdvisorTerminal } from './VegaColoniesScreen.js';
import { pickLine } from './VegaChat.js';
const ACCENT = 0x6fc4ff;
const PANEL = 0x0b1220;
const ANCHOR_KEY = 'vega-council-anchor';
// galactic-council.mp4 is 544x800 (2:3 portrait) — the fallback sourceWidth()
// uses before the video's first frame decodes, same convention as
// VegaGnnScreen.js's GNN_ANCHOR_SRC_W.
const ANCHOR_SRC_W = 544;
const LEFT_X = 40;
const CONTENT_TOP = 130;
const LEFT_W = 460;
const LEFT_H = Math.round(LEFT_W * (800 / 544));
const RIGHT_X = LEFT_X + LEFT_W + 48;
const RIGHT_W = GAME_WIDTH - RIGHT_X - 40;
const FOOTER_Y = GAME_HEIGHT - 100;
const RIGHT_H = FOOTER_Y - 30 - CONTENT_TOP;
// Green-screen anchor-desk ticker below the video — same geometry formula
// (and the same reused Colony Advisor terminal chrome) as VegaGnnScreen.js.
const TERM_GAP = 20;
const TERM_Y = CONTENT_TOP + LEFT_H + TERM_GAP;
const TERM_H = (CONTENT_TOP + RIGHT_H) - TERM_Y;
const toInt = (c) => Phaser.Display.Color.HexStringToColor(c).color;
// ------------------------------------------------------------- narration
function ordinal(n) {
const rem100 = n % 100;
if (rem100 >= 11 && rem100 <= 13) return `${n}th`;
switch (n % 10) {
case 1: return `${n}st`;
case 2: return `${n}nd`;
case 3: return `${n}rd`;
default: return `${n}th`;
}
}
const VOTE_CAST_LINES = [
'{delegate} rise to declare {weight} votes for {candidate}!',
'A stir in the gallery — {delegate} throw their full weight behind {candidate}.',
'{delegate} cast their lot with {candidate}, {weight} votes strong.',
'The {delegate} delegation pledges {weight} votes to {candidate}.',
'{delegate} deliver a firm {weight} votes in favor of {candidate}.',
];
const SELF_VOTE_LINES = [
'{delegate} confidently cast their own {weight} votes for themselves.',
'As expected, {delegate} back their own candidacy with {weight} votes.',
'{delegate} need no convincing — {weight} votes for {candidate}.',
];
const ABSTAIN_LINES = [
'{delegate} abstain, trusting neither candidate.',
'A murmur of uncertainty — {delegate} withhold their {weight} votes entirely.',
'{delegate} decline to choose. Their {weight} votes go uncast.',
];
/** Welcome + process explainer, typed the instant the roster stage opens.
* `{X}` from Brian's ask is this game's council-session ordinal state.council
* .history already has this session pushed onto it by the time the screen
* opens (VegaLogic.js's runCouncil pushes before setting pendingSession). */
function rosterLines(state, result) {
const [a, b] = result.candidates.map((idx) => state.empires[idx]);
const sessionNum = state.council.history.length;
return [
`Welcome, delegates, to the ${ordinal(sessionNum)} vote for a Galactic Ruling Species.`,
`${a.name} and ${b.name} command population enough to stand for High Guardian. Every other `
+ "seated delegate will cast their world's full weight behind whichever candidate they trust "
+ 'most — or abstain, if they trust neither.',
"A two-thirds majority of the galaxy's population is needed to win the chamber outright. Let the roll be called.",
];
}
function votingBeginLines() {
return ['The chamber falls silent. Delegates, cast your votes.'];
}
/** One excited line for the delegate just revealed re-typed over the
* previous line on every "Next Species Vote" click, same as GNN re-typing
* its own anchor line on every page turn. */
function voteRevealLine(state, v) {
const delegate = state.empires[v.idx].name;
if (v.choice == null) return pickLine(ABSTAIN_LINES, { delegate, weight: Math.round(v.weight) });
const candidate = state.empires[v.choice].name;
const pool = v.idx === v.choice ? SELF_VOTE_LINES : VOTE_CAST_LINES;
return pickLine(pool, { delegate, weight: Math.round(v.weight), candidate });
}
/** The presumptive winner when a session ends in refusal `result.winner`
* is already nulled out by runCouncil once refusal fires (VegaLogic.js), so
* this re-derives who WOULD have won from the still-intact `votes` tally,
* the same >= winFraction test runCouncil itself used. */
function presumptiveWinner(rules, result) {
return result.candidates.find((idx) => (result.votes[idx] ?? 0) / result.totalPop >= rules.council.winFraction);
}
function verdictLines(rules, state, result) {
if (result.winner >= 0) {
return [
`By acclamation, ${state.empires[result.winner].name} is elected High Guardian of the Galaxy!`,
'The chamber erupts. A new order begins.',
];
}
if (result.refused) {
const winIdx = presumptiveWinner(rules, result);
const loseIdx = result.candidates.find((idx) => idx !== winIdx);
const winnerName = winIdx != null ? state.empires[winIdx].name : 'the leading candidate';
const loserName = loseIdx != null ? state.empires[loseIdx].name : 'the defeated candidate';
return [
`${loserName} refuse to submit to ${winnerName}'s election — the chamber dissolves into uproar.`,
'This will be settled by the sword, not the ballot.',
];
}
return [
'No candidate commands the required majority. The Council adjourns without a ruler.',
`We reconvene in ${rules.council.interval} turns.`,
];
}
// -------------------------------------------------------------- anchor panel
function buildAnchorFallback(scene, container, w, h) {
container.add(scene.add.rectangle(w / 2, h / 2, w, h, PANEL, 1).setStrokeStyle(2, ACCENT, 0.5));
container.add(scene.add.text(w / 2, h / 2, 'GALACTIC\nCOUNCIL', {
fontFamily: FONT, fontSize: '40px', color: '#6fc4ff', align: 'center',
}).setOrigin(0.5));
}
/** Persistent looping anchor visual built once, untouched by stage
* changes, same pattern as VegaGnnScreen.js's buildAnchorPanel. */
function buildAnchorPanel(scene, w, h) {
const container = scene.add.container(0, 0);
if (scene.cache.video?.exists(ANCHOR_KEY)) {
const v = scene.add.video(w / 2, h / 2, ANCHOR_KEY);
v.setMute(true);
v.setLoop(true);
const fit = () => v.setScale(w / sourceWidth(v, ANCHOR_SRC_W));
fit();
v.on('created', fit);
v.on('playing', fit);
v.play(true);
// A missing/corrupt clip never leaves a hole where the anchor should be.
v.once('error', () => {
if (!v.scene) return;
container.removeAll(true);
buildAnchorFallback(scene, container, w, h);
});
container.add(v);
} else {
buildAnchorFallback(scene, container, w, h);
}
return container;
}
// -------------------------------------------------------------- portraits
/** A framed species portrait with a name label, optionally dimmed (the
* electorate, once a winner is known) or badged (CANDIDATE / HIGH GUARDIAN
* tag above the frame). Shared by all three stages. */
function portraitChip(scene, rules, art, container, cx, cy, size, emp, opts = {}) {
const { dim = false, tag = null } = opts;
const color = toInt(emp.color);
const box = scene.add.rectangle(cx, cy, size, size, PANEL, 0.9)
.setStrokeStyle(dim ? 2 : 4, color, dim ? 0.4 : 1);
container.add(box);
const portrait = makeSpeciesPortrait(scene, rules, art, emp.speciesId, cx, cy, size - 16);
if (dim) portrait.setAlpha(0.45);
container.add(portrait);
container.add(scene.add.text(cx, cy + size / 2 + 10, emp.name, {
fontFamily: FONT, fontSize: dim ? '15px' : '20px', color: dim ? '#7f97b3' : emp.color,
}).setOrigin(0.5, 0));
if (tag) {
container.add(scene.add.text(cx, cy - size / 2 - 20, tag, {
fontFamily: FONT, fontSize: '15px', color: '#ffd88a', fontStyle: 'bold',
}).setOrigin(0.5, 1));
}
}
// ---------------------------------------------------------------- stage 1
function renderRoster(scene, rules, state, art, container, result, onBegin) {
container.add(scene.add.text(RIGHT_W / 2, 0, `SESSION OF ${2300 + result.turn}`, {
fontFamily: FONT, fontSize: '18px', color: '#7f97b3',
}).setOrigin(0.5, 0));
container.add(scene.add.text(RIGHT_W / 2, 26, 'The Galactic Council Convenes', {
fontFamily: FONT, fontSize: '32px', color: '#e8f4ff', fontStyle: 'bold',
}).setOrigin(0.5, 0));
const cands = result.candidates.map((idx) => state.empires[idx]);
const candSet = new Set(result.candidates);
const others = result.voters.map((v) => state.empires[v.idx]).filter((e) => !candSet.has(e.idx));
const daisSize = 220;
const daisY = 130 + daisSize / 2;
const leftCx = RIGHT_W / 2 - 170;
const rightCx = RIGHT_W / 2 + 170;
portraitChip(scene, rules, art, container, leftCx, daisY, daisSize, cands[0], { tag: 'CANDIDATE FOR HIGH GUARDIAN' });
portraitChip(scene, rules, art, container, rightCx, daisY, daisSize, cands[1], { tag: 'CANDIDATE FOR HIGH GUARDIAN' });
container.add(scene.add.text(RIGHT_W / 2, daisY, 'VS', {
fontFamily: FONT, fontSize: '28px', color: '#ffd88a', fontStyle: 'bold',
}).setOrigin(0.5));
if (others.length) {
const labelY = daisY + daisSize / 2 + 60;
container.add(scene.add.text(RIGHT_W / 2, labelY, 'THE ELECTORATE', {
fontFamily: FONT, fontSize: '15px', color: '#6f8aa3', fontStyle: 'bold',
}).setOrigin(0.5, 0));
const chipSize = 100;
const gap = 26;
const rowW = others.length * chipSize + (others.length - 1) * gap;
const startX = RIGHT_W / 2 - rowW / 2 + chipSize / 2;
const rowY = labelY + 46 + chipSize / 2;
others.forEach((emp, i) => {
portraitChip(scene, rules, art, container, startX + i * (chipSize + gap), rowY, chipSize, emp, { dim: true });
});
}
container.add(new Button(scene, RIGHT_W / 2, RIGHT_H - 40, 'Begin Voting', () => {
playSound(scene, SFX.VEGA_SELECT);
onBegin();
}, { width: 260, height: 56, fontSize: 22 }));
}
// ---------------------------------------------------------------- stage 2
const VOTE_REVEAL_MS = 2500;
/**
* Built ONCE on entering the voting stage unlike the roster/verdict
* stages, this one is not torn down and rebuilt on every click. It has to
* stay alive across reveals so each vote's scoreboard change can tween from
* whatever the bar is currently showing rather than snapping the same
* count-up-race read as VegaGnnScreen.js's renderRankingPage, just one
* value animating per click instead of the whole board racing at once.
* Returns nothing; all interaction is wired to the buttons/tweens directly.
*/
function buildVoting(scene, rules, state, art, container, result, onAllRevealed, terminal) {
const need = Math.ceil(result.totalPop * rules.council.winFraction);
container.add(scene.add.text(RIGHT_W / 2, 0, `${need} votes of ${Math.round(result.totalPop)} needed`, {
fontFamily: FONT, fontSize: '17px', color: '#9fb6cc',
}).setOrigin(0.5, 0));
const cands = result.candidates.map((idx) => state.empires[idx]);
const voters = [...result.voters].sort((a, b) => a.weight - b.weight);
const barMax = Math.max(1, result.totalPop);
// Persistent scoreboard visuals — value lives on the closure-captured
// `bars` entries and is redrawn in place by drawBar(), never rebuilt.
const barY = 34;
const barW = (RIGHT_W - 80) / 2;
const bars = cands.map((emp, i) => {
const x = i === 0 ? 0 : RIGHT_W - barW;
container.add(scene.add.text(i === 0 ? x : x + barW, barY, emp.name, {
fontFamily: FONT, fontSize: '22px', color: emp.color,
}).setOrigin(i === 0 ? 0 : 1, 0));
container.add(scene.add.rectangle(x, barY + 32, barW, 20, 0x1b2b42).setOrigin(0, 0));
const fillG = scene.add.graphics().setPosition(x, barY + 32);
container.add(fillG);
const valueText = scene.add.text(x + (i === 0 ? 0 : barW), barY + 58, '0', {
fontFamily: FONT, fontSize: '19px', color: '#e8f4ff',
}).setOrigin(i === 0 ? 0 : 1, 0);
container.add(valueText);
return { idx: emp.idx, fillG, valueText, colorInt: toInt(emp.color), value: 0 };
});
container.add(scene.add.text(RIGHT_W / 2, barY + 32, 'VS', {
fontFamily: FONT, fontSize: '16px', color: '#ffd88a',
}).setOrigin(0.5));
const abstainedText = scene.add.text(RIGHT_W / 2, barY + 90, 'Abstained: 0', {
fontFamily: FONT, fontSize: '15px', color: '#7f97b3',
}).setOrigin(0.5, 0);
container.add(abstainedText);
let abstainedValue = 0;
function drawBar(b) {
b.fillG.clear();
b.fillG.fillStyle(b.colorInt, 1);
b.fillG.fillRect(0, 0, Math.max(barW * Phaser.Math.Clamp(b.value / barMax, 0, 1), 0.01), 20);
b.valueText.setText(`${Math.round(b.value)}`);
}
bars.forEach(drawBar);
// Reveal log — grows downward, most recent entry called out in gold.
const logTop = barY + 140;
container.add(scene.add.rectangle(0, logTop, RIGHT_W, 1, ACCENT, 0.25).setOrigin(0, 0));
const logStartY = logTop + 16;
const logRows = [];
function settle() {
logRows.forEach((row, i) => {
const isLast = i === logRows.length - 1;
row.text.setFontSize(isLast ? 19 : 16);
row.text.setColor(isLast ? '#ffd88a' : '#c7d3e0');
});
}
let animating = false;
const btn = new Button(scene, RIGHT_W / 2, RIGHT_H - 40, 'Next Species Vote', () => {
if (animating) return;
playSound(scene, SFX.VEGA_SELECT);
if (logRows.length >= voters.length) { onAllRevealed(); return; }
revealNext();
}, { width: 280, height: 56, fontSize: 22 });
container.add(btn);
function revealNext() {
const v = voters[logRows.length];
const emp = state.empires[v.idx];
const rowY = logStartY + logRows.length * 40;
const text = v.choice == null
? `${emp.name} abstains.`
: `${emp.name} cast ${Math.round(v.weight)} votes for the ${state.empires[v.choice].name}.`;
container.add(makeSpeciesPortrait(scene, rules, art, emp.speciesId, 22, rowY + 18, 34));
const rowText = scene.add.text(56, rowY + 3, text, {
fontFamily: FONT, fontSize: '16px', color: '#c7d3e0',
}).setOrigin(0, 0);
container.add(rowText);
logRows.push({ text: rowText });
settle();
terminal.setLines([voteRevealLine(state, v)]);
const bar = v.choice != null ? bars.find((b) => b.idx === v.choice) : null;
const fromValue = bar ? bar.value : abstainedValue;
const toValue = fromValue + v.weight;
animating = true;
btn.setEnabled(false);
// vega-counter.mp3 runs ~5s, longer than a single reveal's 2.5s window —
// cut short at onComplete rather than let it bleed into the next click's
// own copy (GNN's race gets away with letting it run because there's
// only ever one count-up per screen open; this one repeats per vote).
const counterSound = scene.sound.add(SFX.VEGA_COUNTER, { volume: 0.9 });
counterSound.play();
scene.tweens.addCounter({
from: fromValue, to: toValue, duration: VOTE_REVEAL_MS, ease: 'Linear',
onUpdate: (tw) => {
const val = tw.getValue();
if (bar) { bar.value = val; drawBar(bar); } else { abstainedValue = val; abstainedText.setText(`Abstained: ${Math.round(abstainedValue)}`); }
},
onComplete: () => {
counterSound.stop();
counterSound.destroy();
animating = false;
btn.setEnabled(true);
if (logRows.length >= voters.length) btn.setLabel('See Result');
},
});
}
}
// ---------------------------------------------------------------- stage 3
function renderVerdict(scene, rules, state, art, container, result, onClose) {
const cands = result.candidates.map((idx) => state.empires[idx]);
const size = 240;
const cy = 130 + size / 2;
const leftCx = RIGHT_W / 2 - 170;
const rightCx = RIGHT_W / 2 + 170;
cands.forEach((emp, i) => {
const isWinner = emp.idx === result.winner;
portraitChip(scene, rules, art, container, i === 0 ? leftCx : rightCx, cy, size, emp, {
dim: result.winner >= 0 && !isWinner,
tag: isWinner ? 'HIGH GUARDIAN OF THE GALAXY' : null,
});
});
const verdict = result.winner >= 0
? `${state.empires[result.winner].name} is elected High Guardian of the Galaxy.`
: (result.refused
? 'The defeated candidate REFUSES TO SUBMIT. The election is void — the matter will be settled by war.'
: 'No candidate reached the required majority. The Council adjourns.');
container.add(scene.add.text(RIGHT_W / 2, cy + size / 2 + 56, verdict, {
fontFamily: FONT, fontSize: '26px', color: result.winner >= 0 ? '#ffd88a' : '#e08a8a',
align: 'center', wordWrap: { width: RIGHT_W - 120 },
}).setOrigin(0.5, 0));
container.add(new Button(scene, RIGHT_W / 2, RIGHT_H - 40, 'Close', () => {
playSound(scene, SFX.VEGA_CLOSE);
onClose();
}, { width: 220, height: 56, fontSize: 22 }));
}
// ------------------------------------------------------------------ pager
/**
* Opens the full-screen Council Session takeover for `state.council.lastResult`
* (must already be set this screen never computes anything itself, only
* replays runCouncil()'s output). `onDone` fires once, when the ceremony is
* dismissed via the verdict stage's Close button.
*/
export function openCouncilSessionScreen(scene, rules, state, art, onDone) {
const result = state.council.lastResult;
// Sting-then-loop, same shape as VegaGnnScreen.js's openGnnScreen: duck the
// regular soundtrack for as long as the ceremony is open, play the one-shot
// intro once, then hand off to the looping ambience the instant it finishes
// (not before — `closed` is declared further down but this callback only
// ever fires asynchronously, well after that declaration has run, same
// reasoning GNN's own version relies on).
scene.music?.pause();
let intro = null;
let loop = null;
function startLoop() {
if (!scene.cache.audio?.exists(SFX.VEGA_COUNCIL_LOOP)) return;
loop = scene.sound.add(SFX.VEGA_COUNCIL_LOOP, { loop: true, volume: 0.5 });
loop.play();
}
if (scene.cache.audio?.exists(SFX.VEGA_COUNCIL_INTRO)) {
intro = scene.sound.add(SFX.VEGA_COUNCIL_INTRO, { volume: 0.8 });
intro.once(Phaser.Sound.Events.COMPLETE, () => {
intro = null;
if (closed) return; // screen already closed before the intro finished
startLoop();
});
intro.play();
} else {
startLoop();
}
const root = scene.add.container(0, 0).setDepth(D.council);
root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x030509, 0.99)
.setOrigin(0, 0).setInteractive());
root.add(scene.add.text(LEFT_X, 34, 'GALACTIC COUNCIL', {
fontFamily: FONT, fontSize: '34px', color: '#e8f4ff',
}));
frame(scene, root, LEFT_X, CONTENT_TOP, LEFT_W, LEFT_H, 0);
const anchorPanel = buildAnchorPanel(scene, LEFT_W, LEFT_H);
anchorPanel.setPosition(LEFT_X, CONTENT_TOP);
root.add(anchorPanel);
// Green-screen ticker under the video, same chrome and bottom-alignment
// formula as VegaGnnScreen.js's own terminal.
const terminal = createAdvisorTerminal(scene, LEFT_X, TERM_Y, LEFT_W, TERM_H);
root.add(terminal.container);
frame(scene, root, RIGHT_X, CONTENT_TOP, RIGHT_W, RIGHT_H, 0.82);
const content = scene.add.container(RIGHT_X, CONTENT_TOP);
root.add(content);
// Roster and verdict are cheap to fully rebuild on their one-time
// transition; voting is built ONCE (see buildVoting's own comment) since
// its scoreboard has to tween continuously across many clicks. The
// terminal is re-typed once per stage entry here; buildVoting re-types it
// again itself on every individual vote reveal.
function enterRoster() {
content.removeAll(true);
renderRoster(scene, rules, state, art, content, result, enterVoting);
terminal.setLines(rosterLines(state, result));
}
function enterVoting() {
content.removeAll(true);
buildVoting(scene, rules, state, art, content, result, enterVerdict, terminal);
terminal.setLines(votingBeginLines());
}
function enterVerdict() {
content.removeAll(true);
renderVerdict(scene, rules, state, art, content, result, close);
terminal.setLines(verdictLines(rules, state, result));
}
enterRoster();
let closed = false;
function close() {
if (closed) return;
closed = true;
scene.music?.resume();
terminal.destroy();
intro?.stop();
loop?.stop();
root.destroy();
onDone?.();
}
return { root, close };
}

View File

@ -419,7 +419,9 @@ export function createGame(rules, opts) {
nextColonyId: 0, nextColonyId: 0,
nextFleetId: 0, nextFleetId: 0,
events: [], events: [],
council: { nextTurn: rules.council.firstTurn, lastResult: null, history: [] }, council: {
nextTurn: rules.council.firstTurn, lastResult: null, history: [], pendingSession: false,
},
gnn: { history: [] }, gnn: { history: [] },
over: false, over: false,
winnerIdx: -1, winnerIdx: -1,
@ -1772,11 +1774,16 @@ export function runCouncil(rules, state) {
const votes = {}; const votes = {};
for (const c of candidates) votes[c.idx] = 0; for (const c of candidates) votes[c.idx] = 0;
let abstained = 0; let abstained = 0;
// Per-voter breakdown, purely additive to the aggregate `votes` above —
// exists so VegaCouncilSession.js can replay the session one delegate at a
// time (smallest population first) instead of only ever showing the
// final tally. `choice` is a candidate idx or null (abstained).
const voters = [];
for (const voter of alive) { for (const voter of alive) {
const weight = voter.totalPop; const weight = voter.totalPop;
const own = candidates.find((c) => c.idx === voter.idx); const own = candidates.find((c) => c.idx === voter.idx);
if (own) { votes[own.idx] += weight; continue; } if (own) { votes[own.idx] += weight; voters.push({ idx: voter.idx, weight, choice: own.idx }); continue; }
let best = null; let best = null;
let bestScore = -Infinity; let bestScore = -Infinity;
for (const cand of candidates) { for (const cand of candidates) {
@ -1784,8 +1791,13 @@ export function runCouncil(rules, state) {
const score = (voter.attitude[cand.idx] ?? 0) - (atWar(state, voter.idx, cand.idx) ? 60 : 0); const score = (voter.attitude[cand.idx] ?? 0) - (atWar(state, voter.idx, cand.idx) ? 60 : 0);
if (score > bestScore) { bestScore = score; best = cand; } if (score > bestScore) { bestScore = score; best = cand; }
} }
if (!best || bestScore < (rules.council.abstainAttitude ?? -20)) { abstained += weight; continue; } if (!best || bestScore < (rules.council.abstainAttitude ?? -20)) {
abstained += weight;
voters.push({ idx: voter.idx, weight, choice: null });
continue;
}
votes[best.idx] += weight; votes[best.idx] += weight;
voters.push({ idx: voter.idx, weight, choice: best.idx });
} }
let winner = -1; let winner = -1;
@ -1819,10 +1831,18 @@ export function runCouncil(rules, state) {
} }
} }
const result = { turn: state.turn, votes, totalPop, abstained, winner, refused, candidates: candidates.map((c) => c.idx) }; const result = {
turn: state.turn, votes, totalPop, abstained, winner, refused,
candidates: candidates.map((c) => c.idx), voters,
};
state.council.lastResult = result; state.council.lastResult = result;
state.council.history.push(result); state.council.history.push(result);
state.council.nextTurn = state.turn + rules.council.interval; state.council.nextTurn = state.turn + rules.council.interval;
// Consumed by MasterOfVegaGame.js's runToHumanTurn() the moment control
// returns to the human, which opens VegaCouncilSession.js's ceremony
// BEFORE the state.over victory check — so a decisive session still gets
// its ceremony instead of jumping straight to the victory overlay.
state.council.pendingSession = true;
pushEvent(state, { type: 'council', ...result }); pushEvent(state, { type: 'council', ...result });
if (winner >= 0) { if (winner >= 0) {
state.over = true; state.over = true;
@ -2562,6 +2582,10 @@ export function deserialize(json) {
state.gnn = { history: [] }; state.gnn = { history: [] };
for (const ev of state.events) ev.gnnAnnounced = true; for (const ev of state.events) ev.gnnAnnounced = true;
} }
// Same convention for a save from before the Council Session ceremony
// existed — an old save can never be mid-session (the feature didn't
// exist to leave one pending), so false is always correct here.
state.council.pendingSession ??= false;
// Same convention for a save from before Colony Focus existed. // Same convention for a save from before Colony Focus existed.
for (const c of state.colonies) { for (const c of state.colonies) {
c.focus ??= 'manual'; c.focus ??= 'manual';

View File

@ -23,11 +23,15 @@ export const FONT = '"Julius Sans One"';
// `colony` sits above `modal` so the colony screen can stack over the system // `colony` sits above `modal` so the colony screen can stack over the system
// view that opened it without either having to be torn down. `detail` is the // view that opened it without either having to be torn down. `detail` is the
// ship pop-over, which opens from the star map's side panel AND from inside the // ship pop-over, which opens from the star map's side panel AND from inside the
// colony screen, so it has to clear both. `intro` is the colony-founding // colony screen, so it has to clear both. `council` is the Council Session
// vignette, which opens over the system view it was triggered from and must // ceremony (VegaCouncilSession.js) — it can fire mid-AI-turn-stepping, before
// cover everything except the end-of-game overlay. // any other modal is open, so its exact rank relative to gnn/detail never
// actually matters in practice; it sits next to gnn as the other full-screen
// takeover. `intro` is the colony-founding vignette, which opens over the
// system view it was triggered from and must cover everything except the
// end-of-game overlay.
export const D = { export const D = {
map: 1, hud: 30, modal: 60, colony: 70, gnn: 72, detail: 76, intro: 78, toast: 80, map: 1, hud: 30, modal: 60, colony: 70, gnn: 72, council: 73, detail: 76, intro: 78, toast: 80,
}; };
/** /**

View File

@ -16,9 +16,18 @@ import { describeTechEffects } from './VegaTechEffects.js';
// in the next turn's report on top of that would announce it twice — the // in the next turn's report on top of that would announce it twice — the
// second time with less weight than a finished refit. Do not add it back // second time with less weight than a finished refit. Do not add it back
// without taking the vignette out. // without taking the vignette out.
//
// `council` is the same story, added later: a council session now gets its
// own full-screen ceremony the instant it fires (VegaCouncilSession.js,
// triggered from MasterOfVegaGame.js's runToHumanTurn() off
// state.council.pendingSession, not off this event bus at all), so a turn-
// report row on top would repeat the exact same tally the ceremony just
// showed. `councilRefused` stays notable — it is a distinct consequence (a
// war begins) worth its own row alongside the ceremony, same as any other
// warDeclared.
export const NOTABLE_TYPES = new Set([ export const NOTABLE_TYPES = new Set([
'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', 'captured', 'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', 'captured',
'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'tradeAgreementFormed', 'council', 'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'tradeAgreementFormed',
'councilRefused', 'eliminated', 'advisorRecommendation', 'councilRefused', 'eliminated', 'advisorRecommendation',
]); ]);
@ -54,7 +63,6 @@ export const TYPE_LABEL = {
peace: 'Peace', peace: 'Peace',
alliance: 'Alliance', alliance: 'Alliance',
tradeAgreementFormed: 'Trade Agreement', tradeAgreementFormed: 'Trade Agreement',
council: 'Galactic Council',
councilRefused: 'Council Refused', councilRefused: 'Council Refused',
eliminated: 'Empire Eliminated', eliminated: 'Empire Eliminated',
advisorRecommendation: 'Advisor Recommendation', advisorRecommendation: 'Advisor Recommendation',
@ -64,7 +72,7 @@ export const TYPE_LABEL = {
// then discoveries, then research, then finished production, mirroring how // then discoveries, then research, then finished production, mirroring how
// the plan orders the list. // the plan orders the list.
const CATEGORY_ORDER = { const CATEGORY_ORDER = {
contact: 0, warDeclared: 0, peace: 0, alliance: 0, tradeAgreementFormed: 0, council: 0, councilRefused: 0, eliminated: 0, contact: 0, warDeclared: 0, peace: 0, alliance: 0, tradeAgreementFormed: 0, councilRefused: 0, eliminated: 0,
captured: 1, colonyDestroyed: 1, captured: 1, colonyDestroyed: 1,
discovered: 2, discovered: 2,
techDone: 3, techDone: 3,
@ -232,20 +240,6 @@ function describeTreaty(rules, state, ev, name, verb) {
return { headline, lines: [] }; return { headline, lines: [] };
} }
function describeCouncil(rules, state, ev, name) {
const need = Math.ceil(ev.totalPop * rules.council.winFraction);
const lines = (ev.candidates ?? []).map((idx) => line(
`${name(idx)}: ${Math.round(ev.votes?.[idx] ?? 0)} votes`, state.empires[idx]?.color,
));
lines.push(line(`Abstained: ${Math.round(ev.abstained ?? 0)} · ${need} votes of ${Math.round(ev.totalPop)} needed`, '#7f97b3'));
const headline = ev.winner >= 0
? `${name(ev.winner)} is elected High Guardian of the Galaxy.`
: (ev.refused
? 'The Council election was refused — war has begun.'
: 'The Council failed to elect a High Guardian.');
return { headline, lines };
}
function describeCouncilRefused(rules, state, ev, name) { function describeCouncilRefused(rules, state, ev, name) {
const me = state.humanIndex; const me = state.humanIndex;
let headline; let headline;
@ -279,7 +273,6 @@ export function describeEvent(rules, state, ev) {
case 'peace': out = describeTreaty(rules, state, ev, name, 'make peace with'); break; case 'peace': out = describeTreaty(rules, state, ev, name, 'make peace with'); break;
case 'alliance': out = describeTreaty(rules, state, ev, name, 'form an alliance with'); break; case 'alliance': out = describeTreaty(rules, state, ev, name, 'form an alliance with'); break;
case 'tradeAgreementFormed': out = describeTreaty(rules, state, ev, name, 'form a trade agreement with'); break; case 'tradeAgreementFormed': out = describeTreaty(rules, state, ev, name, 'form a trade agreement with'); break;
case 'council': out = describeCouncil(rules, state, ev, name); break;
case 'councilRefused': out = describeCouncilRefused(rules, state, ev, name); break; case 'councilRefused': out = describeCouncilRefused(rules, state, ev, name); break;
case 'eliminated': out = describeEliminated(rules, state, ev, name); break; case 'eliminated': out = describeEliminated(rules, state, ev, name); break;
case 'advisorRecommendation': out = describeAdvisorRecommendation(rules, state, ev); break; case 'advisorRecommendation': out = describeAdvisorRecommendation(rules, state, ev); break;

View File

@ -114,6 +114,8 @@ export const SFX = {
VEGA_COUNTER: 'sfx-vega-counter', VEGA_COUNTER: 'sfx-vega-counter',
VEGA_GNN_STING: 'sfx-vega-gnn-sting', VEGA_GNN_STING: 'sfx-vega-gnn-sting',
VEGA_GNN_LOOP: 'sfx-vega-gnn-loop', VEGA_GNN_LOOP: 'sfx-vega-gnn-loop',
VEGA_COUNCIL_INTRO: 'sfx-vega-council-intro',
VEGA_COUNCIL_LOOP: 'sfx-vega-council-loop',
// Beam cues split by the firing ship's Mark (I-VII); missile cues split the // Beam cues split by the firing ship's Mark (I-VII); missile cues split the
// same way but only two ways, and further split into launch vs. impact. // same way but only two ways, and further split into launch vs. impact.
// See VegaCombatView.js's weaponSfxKey()/missileLaunchSfxKey()/ // See VegaCombatView.js's weaponSfxKey()/missileLaunchSfxKey()/

View File

@ -1997,6 +1997,10 @@ section('6b. Founding vignette and the turn report');
// Founding a colony is announced ONCE, by VegaColonyIntro.js the moment it // Founding a colony is announced ONCE, by VegaColonyIntro.js the moment it
// happens. Putting it back in the "New Turn" popup would announce it twice. // happens. Putting it back in the "New Turn" popup would announce it twice.
check('founding a colony is not a turn-report row', !NOTABLE_TYPES.has('colonised')); check('founding a colony is not a turn-report row', !NOTABLE_TYPES.has('colonised'));
check('a council session is not a turn-report row (it has its own ceremony)',
!NOTABLE_TYPES.has('council'));
check('council refusal still is a turn-report row (a distinct war-declaration consequence)',
NOTABLE_TYPES.has('councilRefused'));
check('colonised has no turn-report label either', !TYPE_LABEL.colonised); check('colonised has no turn-report label either', !TYPE_LABEL.colonised);
for (const type of NOTABLE_TYPES) { for (const type of NOTABLE_TYPES) {
check(`notable event ${type} has a turn-report label`, !!TYPE_LABEL[type]); check(`notable event ${type} has a turn-report label`, !!TYPE_LABEL[type]);
@ -2163,6 +2167,8 @@ section('7. Diplomacy and the Galactic Council');
}); });
st.rules = RULES; st.rules = RULES;
check('a fresh game has no pending council session', st.council.pendingSession === false);
check('a species with no diplomacy cannot negotiate', (() => { check('a species with no diplomacy cannot negotiate', (() => {
st.empires[0].contacted[3] = true; st.empires[0].contacted[3] = true;
st.empires[3].contacted[0] = true; st.empires[3].contacted[0] = true;
@ -2218,6 +2224,23 @@ section('7. Diplomacy and the Galactic Council');
check('a landslide elects a High Guardian or is refused', check('a landslide elects a High Guardian or is refused',
result.winner >= 0 || result.refused); result.winner >= 0 || result.refused);
// Per-voter breakdown, added for VegaCouncilSession.js's one-at-a-time
// reveal — must stay consistent with the aggregate `votes`/`abstained`
// this session's UI checks above, since both are read from the same
// result object.
check('every alive empire appears exactly once in the voter breakdown',
result.voters.length === st.empires.filter((e) => e.alive).length);
check("voter weights sum to the session's total population", (() => {
const sum = result.voters.reduce((t, v) => t + v.weight, 0);
return Math.abs(sum - result.totalPop) < 1e-6;
})());
check('every voter choice is a candidate or an abstention (null)',
result.voters.every((v) => v.choice === null || result.candidates.includes(v.choice)));
check('a candidate always votes for itself',
result.candidates.every((idx) => result.voters.find((v) => v.idx === idx)?.choice === idx));
check('runCouncil leaves a pending session for the UI to consume',
st.council.pendingSession === true);
// Refusal: a candidate at war with the winner walks out. // Refusal: a candidate at war with the winner walks out.
const st2 = Logic.createGame(RULES, { const st2 = Logic.createGame(RULES, {
sizeId: 'medium', shapeId: 'elliptical', seed: 78, difficultyId: 'normal', sizeId: 'medium', shapeId: 'elliptical', seed: 78, difficultyId: 'normal',