From a3af6b755da0056d1c9de59e335c2b480f23dafd Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:22:58 +1100 Subject: [PATCH] Prototype unanimous Advanced victory continuation --- components/game-table.tsx | 6 +- components/victory-continuation.tsx | 105 +++++++++++ docs/ADVANCED_PREVIEW.md | 21 ++- docs/CURRENT_STATUS.md | 8 + docs/IMPLEMENTATION_STATUS.md | 11 ++ docs/RULE_DECISIONS.md | 24 ++- game/bots.ts | 7 + game/engine.ts | 95 +++++++++- game/reference.ts | 2 +- game/victory-continuation.ts | 135 ++++++++++++++ game/victory-progress.ts | 9 +- game/victory-quote.ts | 2 +- ...vanced-victory-stronghold-tiebreak.test.ts | 7 +- tests/victory-continuation-controls.test.tsx | 67 +++++++ tests/victory-continuation-runtime.test.ts | 169 ++++++++++++++++++ tests/victory-continuation.test.ts | 92 ++++++++++ 16 files changed, 740 insertions(+), 20 deletions(-) create mode 100644 components/victory-continuation.tsx create mode 100644 game/victory-continuation.ts create mode 100644 tests/victory-continuation-controls.test.tsx create mode 100644 tests/victory-continuation-runtime.test.ts create mode 100644 tests/victory-continuation.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 89ecf17a..a7aff641 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -152,6 +152,7 @@ import { RoomControlNotice } from './room-control-notice'; import { TableSounds } from './table-sounds'; import { EcazSpice } from './ecaz-spice'; import { VictoryProgress } from './victory-progress'; +import { VictoryContinuation } from './victory-continuation'; import { tableActionOwner } from '@/game/table-turn'; import { DukeVidal } from './duke-vidal'; import { CardInspector, CardRules } from './card-inspector'; @@ -308,7 +309,7 @@ export function GameTable({ g.combatLocations?.find((place) => place.id === id)?.name ?? gameTerritories(g).find((place) => place.id === id)?.name ?? id; - const turnOwner = tableActionOwner(g); + const turnOwner = g.victoryContinuation?.actor ?? tableActionOwner(g); const turnOwnerName = g.players.find((p) => p.id === turnOwner)?.name; const setupStage = g.setupStage; const setupFremen = g.players.find((p) => p.faction === 'fremen'); @@ -373,7 +374,7 @@ export function GameTable({ const harkonnenBetrayalOwnsControls = !!g.nexusHarkonnenBetrayalReaction && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening && !g.automaticContinuationPending && !g.nexusCards?.waiting.length && !g.nexusTraitors?.pending; - const busy = reactionBusy || !!g.nexusFremenRideBetrayal || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; + const busy = reactionBusy || !!g.victoryContinuation || !!g.nexusFremenRideBetrayal || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; const reactionOwnsControls = !!g.nexusFremenRideBetrayal || !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement || ixianBetrayalOwnsControls || harkonnenBetrayalOwnsControls; const canSpendKarama = (card: Card, purpose: KullPurpose) => !kullPurposeBlock(g, purpose) && canUseAsKaramaRole(g, me, card); @@ -6610,6 +6611,7 @@ export function GameTable({ )} + void; + children: string; +}) { + return ( + + ); +} + +/** All viewers see the same offer. Only its current participant may respond. */ +export function VictoryContinuation({ + game, act, busy, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const vote = game.victoryContinuation; + const id = useId(); + const [extraText, setExtraText] = useState('0'); + if (!vote) return null; + + const name = (id: string) => game.players.find(player => player.id === id)?.name ?? id; + const sides = vote.sides.map(side => side.map(name).join(' and ')).join('; '); + const actor = name(vote.actor); + const extra = Number(extraText); + const validExtra = /^\d+$/.test(extraText) && Number.isSafeInteger(extra) && extra <= vote.maxExtra; + const ownTurn = vote.actor === game.me; + const proposal = vote.extra === null + ? null + : vote.extra === 0 + ? 'Continue with the current stronghold targets.' + : `Continue and increase the stronghold targets by ${vote.extra}.`; + + return ( +
+

Simultaneous victory

+

The qualifying sides are {sides}. They can finish the game, or unanimously agree to play another turn.

+ {proposal &&

Proposal: {proposal}

} + {!ownTurn ? ( +

Waiting for {actor} to decide.

+ ) : ( + <> + {vote.extra === null && ( + + )} +
+ {vote.extra === null ? ( + + Propose another turn + + ) : ( + + Agree to continue + + )} + + Finish the game + +
+ + )} +
+ ); +} diff --git a/docs/ADVANCED_PREVIEW.md b/docs/ADVANCED_PREVIEW.md index 5e11ea9f..946a7ca9 100644 --- a/docs/ADVANCED_PREVIEW.md +++ b/docs/ADVANCED_PREVIEW.md @@ -38,10 +38,23 @@ all three Tech Tokens counts as one additional stronghold in this comparison. Qualifying alliances and the separate Ecaz Homeworld victory retain their existing shared-winner treatment. Basic retains its existing behavior. -The authorized revised rules also permit all qualifying players or alliances to -agree to play another turn and optionally raise the stronghold target. That -choice is not yet implemented in the preview, which currently ends in a shared -win when applicable. [Source and interpretation](RULE_DECISIONS.md#advanced-simultaneous-solo-stronghold-victories--27-october-2026). +The authorized revised rules also permit qualifying players or alliances to +agree to play another turn and optionally raise the stronghold target. The +provisional preview offers this on turns 1–9 when at least two distinct sides +qualify and one is an alliance. Every member of those sides responds in storm +order. The first may finish or propose another turn with a whole-number target +increase from zero through 20; each later member accepts that exact proposal or +finishes. These ordered controls and the input cap are online conventions. +Finishing resolves the original result and any correct private prediction; +unanimous acceptance continues without revealing that prediction. + +The increase adds to the ordinary solo and alliance stronghold targets, while +independent Ecaz joint-occupation and Homeworld special-victory conditions stay +separate under the provisional interpretation. The original Mentat phase +continues unless CHOAM's completed end-of-Mentat opportunity was the source, +in which case the next turn begins. Turn-ten extension, equal-solo-only +agreements and broader special-victory cases remain unfinished. +[Source and interpretation](RULE_DECISIONS.md#advanced-simultaneous-solo-stronghold-victories--27-october-2026). ## Admission and saved behavior diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 996c5eeb..8168fda9 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,14 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Advanced simultaneous-victory continuation — 27 October. Prototyped / +Partial.** Qualifying sides including an alliance may unanimously agree to +another turn and optionally increase ordinary stronghold targets, preserving +the original Mentat or CHOAM continuation and hidden prediction. Controls and +minimal bots are connected. The eight-file affected union passes 104 cases and +the production build passes. Turn-10 extension, equal-solo-only and exceptional +victory compositions remain open. [Scope](ADVANCED_PREVIEW.md#simultaneous-victories). + **Advanced Revival order and competing winners — 27 October. Prototyped / Partial.** Sapho can provisionally pause a clean started Revival turn and resume it last with spent allowances and legacy order records preserved. Simultaneous diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 4f27ca8a..88e91a3f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,16 @@ # Dune implementation status +## 27 October 2026 — Advanced simultaneous-victory continuation + +Before turn ten, multiple qualifying sides including an alliance may +provisionally agree to play another turn and optionally increase the ordinary +stronghold targets. Each qualifying participant accepts the same proposal; +finishing instead resolves the original victory and prediction. Original +Mentat and CHOAM continuation are retained. The affected eight-file union +passes 104 cases and the production build passes. Turn-ten extension, +equal-solo-only choices and wider combinations remain open. +[Scope](ADVANCED_PREVIEW.md#simultaneous-victories). + ## 27 October 2026 — Advanced Revival and simultaneous victory A clean already-started Advanced Revival can provisionally pause through diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 504bd9fb..b776a1a8 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -12,11 +12,25 @@ a qualifying alliance or the separate Ecaz Homeworld victory retains the existing shared-winner treatment. Basic and recorded winners retain their existing behavior. -The same revision permits all qualifying players or alliances to agree to play -another turn and optionally raise the required stronghold count. This choice is -not yet implemented; automatic shared victory remains the current preview -behavior. Implementing the solo comparison does not complete Advanced victory -or release acceptance. [Preview](ADVANCED_PREVIEW.md#simultaneous-victories). +The same revision permits qualifying players or alliances to agree to play +another turn and optionally raise the required stronghold count. In the +provisional Advanced preview, a public agreement opens on turns 1–9 when at +least two distinct sides qualify, including an alliance. Qualification is +determined before a private Bene Gesserit prediction replaces an actual win. +Every member of the qualifying sides votes in public storm order: the first +may finish or propose continuation with an integer increase from zero through +20, and each remaining member accepts that exact proposal or finishes. The +20-point input cap and sequential proposal procedure are software conventions, +not printed limits. A finish decision settles the original result, including +prediction; unanimous acceptance retains play without applying that result. + +An agreed increase adds to the ordinary solo and alliance stronghold targets. +It does not alter independent Ecaz joint-occupation or Homeworld special-victory +conditions under this provisional composition. The original Mentat opportunity +resumes, or the completed CHOAM end-of-Mentat continuation advances once. +Turn-ten extension, equal-solo-only agreements and broader special-victory +composition remain unfinished. This does not certify Advanced victory or +release acceptance. [Preview](ADVANCED_PREVIEW.md#simultaneous-victories). ## Juice of Sapho and an already-started Advanced movement turn — 27 October 2026 diff --git a/game/bots.ts b/game/bots.ts index a5c083ae..6d9eaedc 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1236,6 +1236,10 @@ function policyActions(g: GameView): Action[] { const level = rank(g); if (!(me.bot ?? me.autopilot) || g.status === 'finished') return []; if (g.status === 'lobby') return me.ready ? [] : [{ type: 'ready' }]; + if (g.victoryContinuation) + return g.victoryContinuation.actor === me.id + ? [{ type: 'victoryContinuation', event: g.victoryContinuation.event, mode: 'finish' }] + : []; if (g.homeworldTupileBlocked) return []; const dukeGrant = g.homeworldEcazDuke; if (dukeGrant && dukeGrant.occupier === me.id && !dukeGrant.blocked && dukeGrant.event && dukeGrant.recipients.length) @@ -4284,6 +4288,9 @@ function botAdvisorMatchingAllowed(g:GameView,action:Action) { } catch(error) { if(error instanceof AdvisorMatchingError) return false; throw error; } } export function botActions(g: GameView): Action[] { + if (g.victoryContinuation) + return g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending + ? policyActions(g) : []; if (g.homeworldTupileBlocked) return []; if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup) return tupileCleanupActions(g); diff --git a/game/engine.ts b/game/engine.ts index d058cd86..a89ed748 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,4 +1,7 @@ import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises'; +import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation, + VictoryContinuationError, MAX_VICTORY_EXTRA_STRONGHOLDS, type VictoryContinuation, + type VictoryContinuationResume } from './victory-continuation'; import { SabotageGiftError, quoteSabotageGift, quoteSabotageGiftDiscard } from './sabotage-gift'; import { kullDefaultEligible } from './kull-default-profile'; import { advancedKullLobbyEligible } from './kull-advanced-profile'; @@ -1402,6 +1405,9 @@ type RicheseAllyOffer = { payer: string; }; export type Game = { + /** Agreed addition to the ordinary stronghold win targets. Older games omit it. */ + victoryExtraStrongholds?: number; + victoryContinuation?: VictoryContinuation | null; charityPromises?: CharityPromise[]; revivalOrder?: RevivalOrder; charityOrder?: CharityOrder; @@ -29126,12 +29132,51 @@ function decideSharedSpice(g: Game, id: string, action: Action) { } } -function victory(g: Game, quote?: VictoryQuote) { +function victoryAgreementRule(calculate: () => T): T { + try { return calculate(); } + catch (error) { + if (error instanceof VictoryContinuationError) throw new RuleError(error.message); + throw error; + } +} +function victoryAgreementSides(g: Game, quote: VictoryQuote): string[][] { + const sides = strongholdProgress(g).progress.filter(row => row.qualifies).map(row => row.members); + if (quote.ecazHomeworld) sides.push(quote.ecazHomeworld.members); + return sides; +} +function victoryContinuationIntegrity(g: Game) { + const pending = g.victoryContinuation; + if (!pending) return; + victoryAgreementRule(() => validateVictoryContinuation(pending)); + requireRule(g.status === 'playing' && g.advanced && g.phase === 8 && g.turn === pending.turn && + !g.winner.length && !g.decision && !g.response, + 'The shared victory agreement must retain its original Mentat opportunity.'); + const expected = victoryAgreementRule(() => createVictoryContinuation({ + event: pending.event, turn: g.turn, resume: pending.resume, order: g.order, + sides: victoryAgreementSides(g, currentVictoryQuote(g)), + })); + requireRule(JSON.stringify(expected.sides) === JSON.stringify(pending.sides) && + JSON.stringify(expected.voters) === JSON.stringify(pending.voters), + 'The qualifying sides changed during their shared victory agreement.'); +} +function victory(g: Game, quote?: VictoryQuote, resume: VictoryContinuationResume = 'mentat', skipOffer = false) { requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport && !g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport && !g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingAdvisorTransportMatching && !g.pendingAdvisorGatherMatching && !g.pendingSpiritualAdvisor, 'Resolve mandatory advisor conversions before checking victory.'); quote ??= currentVictoryQuote(g); + if (!skipOffer && g.advanced && g.turn < 10 && g.status === 'playing' && !g.winner.length) { + const sides = victoryAgreementSides(g, quote); + const distinct = new Set(sides.map(side => [...side].sort().join('\u0000'))); + if (distinct.size >= 2 && sides.some(side => side.length === 2)) { + applyAdvisorReleases(g, quote.released); + g.victoryContinuation = victoryAgreementRule(() => createVictoryContinuation({ + event: crypto.randomUUID(), turn: g.turn, resume, order: g.order, sides, + })); + log(g, 'Multiple sides qualify for victory. Their participants may unanimously agree to another turn and a changed stronghold target.'); + return; + } + } applyAdvisorReleases(g, quote.released); g.winner = quote.winner; g.status = quote.status; @@ -39811,6 +39856,7 @@ function normalizeCardNames(g: Game) { ]); } export function applyAction(state: Game, id: string, action: Action): Game { + victoryContinuationIntegrity(state); bidPromiseIntegrity(state); phaseBidIntegrity(state); richeseBidIntegrity(state); @@ -39906,6 +39952,35 @@ export function applyAction(state: Game, id: string, action: Action): Game { } return g; } + if (state.victoryContinuation) { + const frame = state.victoryContinuation; + if (action.type === 'advanceBots') return state; + requireRule(action.type === 'victoryContinuation' && + Object.keys(action).every(key => ['type','event','mode','extra'].includes(key)) && + (action.mode === 'finish' || action.mode === 'continue' || action.mode === 'accept') && + (frame.extraStrongholds === null ? action.mode !== 'accept' : action.mode !== 'continue'), + 'Finish the current shared victory agreement before another action.'); + const result = victoryAgreementRule(() => voteVictoryContinuation(frame, { + event: action.event as string, player: id, + choice: action.mode === 'finish' ? 'finish' : 'continue', + ...(action.extra === undefined ? {} : {extraStrongholds: action.extra as number}), + })); + const g = structuredClone(state); + if (result.outcome === 'pending') g.victoryContinuation = result.frame; + else { + g.victoryContinuation = null; + if (result.outcome === 'finish') victory(g, undefined, frame.resume, true); + else { + const extra = (g.victoryExtraStrongholds ?? 0) + result.extraStrongholds; + requireRule(Number.isSafeInteger(extra) && extra <= 200, 'The agreed stronghold target is too large.'); + g.victoryExtraStrongholds = extra; + log(g, `The qualifying participants agreed to continue for another turn${result.extraStrongholds ? ` and increase ordinary stronghold targets by ${result.extraStrongholds}` : ' with unchanged stronghold targets'}.`); + if (frame.resume === 'advance') completePhase(g); + return normalizeAutomaticGame(g); + } + } + return g; + } if (state.pendingTreacheryDiscard) { getPlayer(state, id); if (state.pendingTreacheryDiscard.reaction && @@ -40177,6 +40252,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { return g; } function finishActionContinuations(g: Game) { + if (g.victoryContinuation) return; if (g.pendingNexusHarkonnenBetrayal) return; if (g.pendingNexusIxianBetrayal) return; if (g.pendingGuildBetrayal) return; @@ -40248,6 +40324,7 @@ function finishActionContinuations(g: Game) { finishMoritaniPlacement(g); } function settleAutomaticContinuations(g: Game) { + if (g.victoryContinuation) return; if (g.pendingNexusHarkonnenBetrayal) return; if (g.pendingNexusIxianBetrayal) return; if (g.pendingNexusIxianReplacement) return; @@ -40259,6 +40336,7 @@ function settleAutomaticContinuations(g: Game) { if (pendingNexusTraitors(g)) return; if (g.pendingNullentropy) return; for (let iteration = 0; iteration < 128; iteration++) { + if (g.victoryContinuation) return; if (g.pendingNexusHarkonnenBetrayal) return; if (g.pendingNexusIxianBetrayal) return; if (g.pendingGuildBetrayal) return; @@ -40388,6 +40466,8 @@ function settleAutomaticContinuations(g: Game) { } /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { + victoryContinuationIntegrity(state); + if (state.victoryContinuation) return state; bidPromiseIntegrity(state); phaseBidIntegrity(state); richeseBidIntegrity(state); @@ -41642,8 +41722,8 @@ function applyActionInner( 'Finish CHOAM’s Mentat opportunity before checking victory.', ); g.choamMentatPending = false; - victory(g); - if (g.status === 'playing') completePhase(g); + victory(g, undefined, 'advance'); + if (g.status === 'playing' && !g.victoryContinuation) completePhase(g); return g; } if (decision.kind === 'choamFreeRevival') { @@ -44594,6 +44674,7 @@ function applyActionInner( throw new RuleError('That action is not available.'); } export function viewGame(state: Game, id: string, now?: number) { + victoryContinuationIntegrity(state); revivalOrderIntegrity(state); charityOrderIntegrity(state); state = migrateAtomicsTargets(state); @@ -45530,6 +45611,14 @@ export function viewGame(state: Game, id: string, now?: number) { g.moritaniRetention.keep, ) ?? null) : null, + victoryContinuation: g.victoryContinuation ? { + event: g.victoryContinuation.event, + actor: g.victoryContinuation.voters[g.victoryContinuation.accepted.length], + voters: [...g.victoryContinuation.voters], + sides: g.victoryContinuation.sides.map(side => [...side]), + extra: g.victoryContinuation.extraStrongholds, + maxExtra: MAX_VICTORY_EXTRA_STRONGHOLDS, + } : null, decision: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.decision?.kind === 'capturedLeader' && !g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract).leader) && diff --git a/game/reference.ts b/game/reference.ts index 53e1af53..4b6043ee 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4445,7 +4445,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A correct Bene Gesserit faction-and-turn prediction replaces a normal win, including an allied win, with a Bene Gesserit solo win. Guild and Fremen special wins cannot be predicted.', 'On turn ten, a normal stronghold victory comes first. Otherwise, check Fremen’s special condition, then award the game to Guild and its ally if present. If Guild is absent, Fremen and its ally win even when the usual Fremen special condition fails.', 'When neither Guild nor Fremen is playing and nobody has reached the normal target, compare individual qualifying stronghold counts. Every player tied for the highest count wins. A correct Bene Gesserit prediction can replace this stronghold win.', - 'With tech tokens enabled, owning all three adds one stronghold. One player must hold all three, even in an alliance. In Advanced, when multiple unallied players qualify, the highest stronghold count wins; equal counts provisionally share. A qualifying alliance and other qualifying sides share before a correct prediction replaces the result. The optional unanimous decision to play another turn remains unfinished.', + 'With tech tokens enabled, owning all three adds one stronghold. One player must hold all three, even in an alliance. In Advanced, when multiple unallied players qualify, the highest stronghold count wins; equal counts provisionally share. When several sides including an alliance qualify before turn ten, every qualifying participant may instead agree to continue another turn and optionally increase ordinary stronghold targets. A finish vote settles the original shared result before any correct prediction replaces it. The turn-ten continuation and other exceptional compositions remain unfinished.', 'Stronghold victory progress shows current territories, a complete Tech Token set, Ecaz joint occupation and Fremen final-turn conditions. Basic permits reciprocal Fremen/Ecaz cooccupation at Tabr; supplied Advanced p32 permits both protected sietches. Solitary/nonallied Ecaz still blocks. Public counts do not award victory before the proper checkpoint; Tech Tokens cannot supply joint occupation.', 'Other expansion additions to victory remain in development. Advanced preview supports selected factions and combinations; complete rules acceptance remains open.', ], diff --git a/game/victory-continuation.ts b/game/victory-continuation.ts new file mode 100644 index 00000000..587143d5 --- /dev/null +++ b/game/victory-continuation.ts @@ -0,0 +1,135 @@ +/** Provisional online agreement for the supplied Advanced rule allowing + * qualifying players and alliances to continue rather than share victory. + * Only public qualifiers belong here; a Bene Gesserit prediction is private. */ +export const MAX_VICTORY_EXTRA_STRONGHOLDS = 20; + +export type VictoryContinuationResume = 'mentat' | 'advance'; +export type VictoryContinuation = { + version: 1; + event: string; + turn: number; + resume: VictoryContinuationResume; + order: string[]; + sides: string[][]; + voters: string[]; + accepted: string[]; + extraStrongholds: number | null; +}; +export type VictoryContinuationChoice = { + event: string; + player: string; + choice: 'continue' | 'finish'; + /** Only the first voter chooses the shared increment. */ + extraStrongholds?: number; +}; +export type VictoryContinuationVote = + | { outcome: 'pending'; frame: VictoryContinuation } + | { outcome: 'finish' } + | { outcome: 'continue'; extraStrongholds: number }; + +export class VictoryContinuationError extends Error { + constructor(message: string) { + super(message); + this.name = 'VictoryContinuationError'; + } +} +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +const id = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value.trim() === value; +const ids = (value: unknown): value is string[] => Array.isArray(value) && value.every(id); +const same = (a: readonly string[], b: readonly string[]) => + a.length === b.length && a.every((value, index) => value === b[index]); +function requireContinuation(condition: unknown, message: string): asserts condition { + if (!condition) throw new VictoryContinuationError(message); +} +function validExtra(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && + value >= 0 && value <= MAX_VICTORY_EXTRA_STRONGHOLDS; +} + +/** Normalize duplicates from the two rows representing one reciprocal alliance. + * Distinct sides must be disjoint and contain only actual seated players. */ +function canonicalSides(order: readonly string[], sides: readonly (readonly string[])[]): string[][] { + requireContinuation(Array.isArray(sides), 'Retain the qualifying victory sides.'); + const unique: string[][] = []; + for (const side of sides) { + requireContinuation(ids(side) && side.length >= 1 && side.length <= 2 && + new Set(side).size === side.length && side.every(player => order.includes(player)), + 'Every qualifying side must contain one or two distinct seated players.'); + const members = order.filter(player => side.includes(player)); + if (!unique.some(existing => same(existing, members))) unique.push(members); + } + unique.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0])); + requireContinuation(unique.length >= 2 && unique.some(side => side.length === 2), + 'The provisional continuation needs multiple qualifying sides including an alliance.'); + const members = unique.flat(); + requireContinuation(new Set(members).size === members.length, + 'Qualifying victory sides cannot overlap.'); + return unique; +} + +export function createVictoryContinuation(input: { + event: string; + turn: number; + resume: VictoryContinuationResume; + order: readonly string[]; + sides: readonly (readonly string[])[]; +}): VictoryContinuation { + requireContinuation(record(input) && id(input.event) && Number.isSafeInteger(input.turn) && + input.turn >= 1 && input.turn <= 9 && (input.resume === 'mentat' || input.resume === 'advance') && + ids(input.order) && input.order.length >= 2 && input.order.length <= 6 && + new Set(input.order).size === input.order.length, + 'The shared victory agreement needs a current turn before turn ten and distinct seated players.'); + const order = [...input.order], sides = canonicalSides(order, input.sides); + const voters = order.filter(player => sides.some(side => side.includes(player))); + return { version: 1, event: input.event, turn: input.turn, resume: input.resume, + order, sides, voters, accepted: [], extraStrongholds: null }; +} + +/** Check the full public receipt before projecting or accepting each vote. */ +export function validateVictoryContinuation(frame: VictoryContinuation): void { + requireContinuation(record(frame) && + Object.keys(frame).sort().join(',') === 'accepted,event,extraStrongholds,order,resume,sides,turn,version,voters' && + frame.version === 1 && id(frame.event) && Number.isSafeInteger(frame.turn) && frame.turn >= 1 && frame.turn <= 9 && + (frame.resume === 'mentat' || frame.resume === 'advance') && + ids(frame.order) && frame.order.length >= 2 && frame.order.length <= 6 && + new Set(frame.order).size === frame.order.length && ids(frame.voters) && ids(frame.accepted), + 'The saved shared victory agreement is malformed.'); + const canonical = canonicalSides(frame.order, frame.sides); + requireContinuation(frame.sides.length === canonical.length && + frame.sides.every((side, index) => same(side, canonical[index])), + 'The saved qualifying sides no longer match their public order.'); + const voters = frame.order.filter(player => canonical.some(side => side.includes(player))); + requireContinuation(same(frame.voters, voters) && frame.accepted.length < voters.length && + same(frame.accepted, voters.slice(0, frame.accepted.length)) && + (frame.accepted.length === 0 ? frame.extraStrongholds === null : validExtra(frame.extraStrongholds)), + 'The saved shared victory agreement lost its original ordered votes or proposal.'); +} + +/** Continue only on unanimous acceptance of one exact public proposal. A vote + * to finish awards the original result through the caller's native settlement. */ +export function voteVictoryContinuation(frame: VictoryContinuation, + choice: VictoryContinuationChoice): VictoryContinuationVote { + validateVictoryContinuation(frame); + requireContinuation(record(choice) && + Object.keys(choice).every(key => ['event', 'player', 'choice', 'extraStrongholds'].includes(key)) && + choice.event === frame.event && choice.player === frame.voters[frame.accepted.length] && + (choice.choice === 'continue' || choice.choice === 'finish'), + 'Choose at your current shared victory agreement opportunity.'); + if (choice.choice === 'finish') { + requireContinuation(choice.extraStrongholds === undefined, 'Finishing cannot alter the victory target.'); + return { outcome: 'finish' }; + } + const first = frame.accepted.length === 0; + requireContinuation(first ? validExtra(choice.extraStrongholds) : choice.extraStrongholds === undefined, + first ? `Choose an extra stronghold count from 0 to ${MAX_VICTORY_EXTRA_STRONGHOLDS}.` + : 'Accept the original proposal without changing its target.'); + const extra = first ? choice.extraStrongholds! : frame.extraStrongholds!; + const accepted = [...frame.accepted, choice.player]; + if (accepted.length === frame.voters.length) return { outcome: 'continue', extraStrongholds: extra }; + const next: VictoryContinuation = { ...frame, order: [...frame.order], sides: frame.sides.map(side => [...side]), + voters: [...frame.voters], accepted, extraStrongholds: extra }; + validateVictoryContinuation(next); + return { outcome: 'pending', frame: next }; +} diff --git a/game/victory-progress.ts b/game/victory-progress.ts index 6fb38af2..c55e6207 100644 --- a/game/victory-progress.ts +++ b/game/victory-progress.ts @@ -15,7 +15,7 @@ export type StrongholdProgress = { members: string[]; strongholds: string[]; techStronghold: boolean; - target: 3 | 4; + target: number; jointlyOccupied: string[]; occupyTarget: 3 | null; qualifies: boolean; @@ -45,7 +45,7 @@ export function countsForStrongholdVictory(id: string) { * victory, combat resolution, resource mutation or Stronghold Card ownership is * considered. Mandatory advisor releases are returned for the caller to commit. */ export function strongholdProgress( - g: BoardContext & Pick, + g: BoardContext & Pick & { victoryExtraStrongholds?: number }, ): { released: AdvisorRelease[]; progress: StrongholdProgress[] } { try { requireProgress( @@ -56,6 +56,9 @@ export function strongholdProgress( g.players.every((p) => record(p) && record(p.forces)), 'Victory progress requires a valid public board and seated players.', ); + const extra = g.victoryExtraStrongholds ?? 0; + requireProgress(Number.isSafeInteger(extra) && extra >= 0 && extra <= 200, + 'The agreed extra stronghold target must be a safe nonnegative count.'); // This validates every ID, faction and reciprocal alliance, even where no // stronghold has a force. It does not merge ordinary allies for occupancy. ecazOccupancyIdentity(g.players, g.players[0].id, { @@ -126,7 +129,7 @@ export function strongholdProgress( members.some((member) => TECH_TOKENS.every(({ id }) => tech[id].owner === member), ); - const target: 3 | 4 = partner || players.length === 2 ? 4 : 3; + const target = (partner || players.length === 2 ? 4 : 3) + extra; return { player: id, members, diff --git a/game/victory-quote.ts b/game/victory-quote.ts index e229d088..643f58b3 100644 --- a/game/victory-quote.ts +++ b/game/victory-quote.ts @@ -37,7 +37,7 @@ const record = (value: unknown): value is Record => export type VictoryContext = Omit & Pick< Game, - 'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' + 'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds' > & { players: readonly (BoardSeat & Pick)[]; }; diff --git a/tests/advanced-victory-stronghold-tiebreak.test.ts b/tests/advanced-victory-stronghold-tiebreak.test.ts index 3cb37516..91e327a7 100644 --- a/tests/advanced-victory-stronghold-tiebreak.test.ts +++ b/tests/advanced-victory-stronghold-tiebreak.test.ts @@ -87,7 +87,12 @@ void test('a qualifying alliance and solo still share an Advanced win regardless holds(g, 'ix', 'tueks_sietch', 'habbanya_ridge_sietch'); g.players[0].forces['hidden_mobile_stronghold:0'] = 1; assert.deepEqual(quoteVictory(g).winner, ['ix', 'at', 'em']); - assert.deepEqual(actual(g).winner, ['ix', 'at', 'em']); + const offered = actual(g); + assert.ok(offered.victoryContinuation); + const settled = applyAction(offered, offered.victoryContinuation.voters[0], { + type: 'victoryContinuation', event: offered.victoryContinuation.event, mode: 'finish', + }); + assert.deepEqual(settled.winner, ['ix', 'at', 'em']); }); void test('Bene Gesserit prediction uses the actual higher solo winner, not the lower qualifier', () => { diff --git a/tests/victory-continuation-controls.test.tsx b/tests/victory-continuation-controls.test.tsx new file mode 100644 index 00000000..8d938b36 --- /dev/null +++ b/tests/victory-continuation-controls.test.tsx @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { VictoryContinuation, VictoryContinuationButton } from '../components/victory-continuation'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; + +/** Controlled public projections exercise the actual panel and callbacks; + * they do not assert an authoritative voting or victory transition. */ +function projected(viewer: 'a' | 'b' | 'c', extra: number | null, actor: 'a' | 'b' = 'a'): GameView { + const source = createGame('VOTEUI', newPlayer('a', 'Atreides', 'atreides'), true); + joinGame(source, newPlayer('b', 'Emperor', 'emperor')); + joinGame(source, newPlayer('c', 'Guild', 'guild')); + const view = viewGame(source, viewer); + view.victoryContinuation = { + event: 'public-vote-event', actor, voters: ['a', 'b'], sides: [['a'], ['b', 'c']], extra, maxExtra: 20, + }; + return view; +} +function html(game: GameView, busy = false): string { + return renderToStaticMarkup(createElement(VictoryContinuation, { game, busy, act() {} })); +} +function click(mode: 'finish' | 'continue' | 'accept', busy = false, extra?: number): Action[] { + const actions: Action[] = []; + VictoryContinuationButton({ event: 'public-vote-event', mode, extra, busy, + act(action) { actions.push(action); }, children: 'Choose' }).props.onClick(); + return actions; +} + +void test('proposal controls display the public sides and a selectable increase', () => { + const view = projected('a', null), before = structuredClone(view), output = html(view); + assert.match(output, /aria-label="Simultaneous victory decision"/); + assert.match(output, /Atreides; Emperor and Guild/); + assert.match(output, /unanimously agree to play another turn/); + assert.match(output, /Increase the stronghold targets by/); + assert.match(output, /type="number" min="0" max="20"/); + assert.match(output, /Choose zero to keep the existing targets/); + assert.match(output, /Propose another turn/); + assert.match(output, /Finish the game/); + assert.deepEqual(view, before); +}); + +void test('proposal acceptance explains exact increase and non-actors wait', () => { + const responder = html(projected('b', 2, 'b')); + assert.match(responder, /Proposal: Continue and increase the stronghold targets by 2/); + assert.match(responder, /Agree to continue/); + assert.match(responder, /Finish the game/); + assert.doesNotMatch(responder, /Propose another turn|type="number"/); + const waiting = html(projected('a', 0, 'b')); + assert.match(waiting, /Continue with the current stronghold targets/); + assert.match(waiting, /Waiting for Emperor to decide/); + assert.doesNotMatch(waiting, /