diff --git a/assets/fx/vega/vega-council-intro.mp3 b/assets/fx/vega/vega-council-intro.mp3 new file mode 100644 index 0000000..1fdf9a3 Binary files /dev/null and b/assets/fx/vega/vega-council-intro.mp3 differ diff --git a/assets/music/vega/vega-council.mp3 b/assets/music/vega/vega-council.mp3 new file mode 100644 index 0000000..31d5ecf Binary files /dev/null and b/assets/music/vega/vega-council.mp3 differ diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index ddd9bf5..caea9c5 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -168,6 +168,9 @@ export const MANIFEST = { // than a mastervega-artwork.json map entry. Eager-loaded so the first // post-turn GNN open never stalls on a JIT fetch. { 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 — // VegaColonyIntro.js ducks that and plays this over the vignette instead. // 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. { 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' }, + // 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()). { 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' }, diff --git a/src/games/mastervega/MasterOfVegaGame.js b/src/games/mastervega/MasterOfVegaGame.js index 849b577..f4c130f 100644 --- a/src/games/mastervega/MasterOfVegaGame.js +++ b/src/games/mastervega/MasterOfVegaGame.js @@ -38,6 +38,7 @@ import { openTurnReportScreen } from './VegaTurnReportScreen.js'; import { NOTABLE_TYPES, isRelevantToHuman } from './VegaTurnReport.js'; import * as Gnn from './VegaGnn.js'; import { openGnnScreen } from './VegaGnnScreen.js'; +import { openCouncilSessionScreen } from './VegaCouncilSession.js'; import { openAudienceScreen } from './VegaAudience.js'; import { playIntroVideo } from './VegaIntroVideo.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. runToHumanTurn() { 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.current === this.state.humanIndex) { Logic.beginEmpireTurn(this.rules, this.state, this.state.humanIndex); diff --git a/src/games/mastervega/VegaCouncilSession.js b/src/games/mastervega/VegaCouncilSession.js new file mode 100644 index 0000000..6be989d --- /dev/null +++ b/src/games/mastervega/VegaCouncilSession.js @@ -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 }; +} diff --git a/src/games/mastervega/VegaLogic.js b/src/games/mastervega/VegaLogic.js index 6a8475a..92bdddc 100644 --- a/src/games/mastervega/VegaLogic.js +++ b/src/games/mastervega/VegaLogic.js @@ -419,7 +419,9 @@ export function createGame(rules, opts) { nextColonyId: 0, nextFleetId: 0, events: [], - council: { nextTurn: rules.council.firstTurn, lastResult: null, history: [] }, + council: { + nextTurn: rules.council.firstTurn, lastResult: null, history: [], pendingSession: false, + }, gnn: { history: [] }, over: false, winnerIdx: -1, @@ -1772,11 +1774,16 @@ export function runCouncil(rules, state) { const votes = {}; for (const c of candidates) votes[c.idx] = 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) { const weight = voter.totalPop; 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 bestScore = -Infinity; 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); 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; + voters.push({ idx: voter.idx, weight, choice: best.idx }); } 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.history.push(result); 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 }); if (winner >= 0) { state.over = true; @@ -2562,6 +2582,10 @@ export function deserialize(json) { state.gnn = { history: [] }; 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. for (const c of state.colonies) { c.focus ??= 'manual'; diff --git a/src/games/mastervega/VegaScreens.js b/src/games/mastervega/VegaScreens.js index 14466af..9e14f30 100644 --- a/src/games/mastervega/VegaScreens.js +++ b/src/games/mastervega/VegaScreens.js @@ -23,11 +23,15 @@ export const FONT = '"Julius Sans One"'; // `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 // 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 -// vignette, which opens over the system view it was triggered from and must -// cover everything except the end-of-game overlay. +// colony screen, so it has to clear both. `council` is the Council Session +// ceremony (VegaCouncilSession.js) — it can fire mid-AI-turn-stepping, before +// 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 = { - 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, }; /** diff --git a/src/games/mastervega/VegaTurnReport.js b/src/games/mastervega/VegaTurnReport.js index 3b388c2..2b9169d 100644 --- a/src/games/mastervega/VegaTurnReport.js +++ b/src/games/mastervega/VegaTurnReport.js @@ -16,9 +16,18 @@ import { describeTechEffects } from './VegaTechEffects.js'; // 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 // 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([ 'discovered', 'techDone', 'buildingDone', 'shipDone', 'contact', 'captured', - 'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'tradeAgreementFormed', 'council', + 'colonyDestroyed', 'warDeclared', 'peace', 'alliance', 'tradeAgreementFormed', 'councilRefused', 'eliminated', 'advisorRecommendation', ]); @@ -54,7 +63,6 @@ export const TYPE_LABEL = { peace: 'Peace', alliance: 'Alliance', tradeAgreementFormed: 'Trade Agreement', - council: 'Galactic Council', councilRefused: 'Council Refused', eliminated: 'Empire Eliminated', advisorRecommendation: 'Advisor Recommendation', @@ -64,7 +72,7 @@ export const TYPE_LABEL = { // then discoveries, then research, then finished production, mirroring how // the plan orders the list. 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, discovered: 2, techDone: 3, @@ -232,20 +240,6 @@ function describeTreaty(rules, state, ev, name, verb) { 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) { const me = state.humanIndex; 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 '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 'council': out = describeCouncil(rules, state, ev, name); break; case 'councilRefused': out = describeCouncilRefused(rules, state, ev, name); break; case 'eliminated': out = describeEliminated(rules, state, ev, name); break; case 'advisorRecommendation': out = describeAdvisorRecommendation(rules, state, ev); break; diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index 274adc7..06c63e9 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -114,6 +114,8 @@ export const SFX = { VEGA_COUNTER: 'sfx-vega-counter', VEGA_GNN_STING: 'sfx-vega-gnn-sting', 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 // same way but only two ways, and further split into launch vs. impact. // See VegaCombatView.js's weaponSfxKey()/missileLaunchSfxKey()/ diff --git a/tools/verifyMasterOfVega.js b/tools/verifyMasterOfVega.js index a74dc2e..464b4c2 100644 --- a/tools/verifyMasterOfVega.js +++ b/tools/verifyMasterOfVega.js @@ -1997,6 +1997,10 @@ section('6b. Founding vignette and the turn report'); // 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. 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); for (const type of NOTABLE_TYPES) { 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; + check('a fresh game has no pending council session', st.council.pendingSession === false); + check('a species with no diplomacy cannot negotiate', (() => { st.empires[0].contacted[3] = 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', 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. const st2 = Logic.createGame(RULES, { sizeId: 'medium', shapeId: 'elliptical', seed: 78, difficultyId: 'normal',