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:
parent
0c6c4b8452
commit
b145beb28d
Binary file not shown.
Binary file not shown.
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()/
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue