Support agreed extended turns in Basic and Advanced

This commit is contained in:
Quintine committed 2026-10-27 23:29:04 +11:00
1 parent a3af6b755d
commit fcd93ca252
18 files changed
+506 -66

No files matched your search

+1 -1
View File
@@ -1149,7 +1149,7 @@ export function GameTable({
automaticSequence: automaticEvents.reduce((last, entry) => Math.max(last, entry.seq), -1),
}} />
<span className="status-dot" />
{g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / 10`}
{g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / ${g.victoryFinalTurn}`}
<span className="build-revision" title={buildRevision}>
Git revision: <code>{buildRevisionLabel}</code>
</span>
+1 -1
View File
@@ -148,7 +148,7 @@ export function VictoryProgress({
{fremen && (
<section className="mt-4 space-y-2 rounded-lg border border-[#a88b60]/40 p-3" aria-label="Fremen final-turn victory">
<h3>Fremen final-turn victory</h3>
<p className="fine">At the end of turn ten, if nobody has a stronghold victory, these conditions let Fremen and its ally win before Guild. A Bene Gesserit prediction cannot replace this special victory.</p>
<p className="fine">At the end of the final turn, if nobody has a stronghold victory, these conditions let Fremen and its ally win before Guild. A Bene Gesserit prediction cannot replace this special victory. An agreed extra turn provisionally moves this check to the new final turn.</p>
<ul className="space-y-2">
{fremen.sietches.map((site) => (
<li key={site.territory}>
+18 -12
View File
@@ -36,24 +36,30 @@ their stronghold counts and awards the higher count before checking a Bene
Gesserit prediction. Equal highest counts provisionally share the win; a set of
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.
existing shared-winner treatment. Basic keeps its shared-win ranking.
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 publisher FAQ for Basic and the authorized revised rules for Advanced also
permit qualifying players or alliances to agree to play another full turn and
optionally raise the stronghold target. The provisional agreement is offered
when at least two distinct sides qualify in Basic, or when equal highest solo
qualifiers or other distinct sides qualify in Advanced. 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. Voter scope, 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.
in which case the next turn begins. The final turn defaults to ten; an agreement
at the current final turn provisionally extends it by one, including a turn-ten
agreement. The Fremen and Guild final-victory checks move to the agreed final
turn under this interpretation, even though the supplied Fremen sheet names the
end of turn ten. Repeated extensions can move the final turn further. Broader
special-victory cases and full natural extended-turn verification remain open.
[Source and interpretation](RULE_DECISIONS.md#advanced-simultaneous-solo-stronghold-victories--27-october-2026).
## Admission and saved behavior
+12 -3
View File
@@ -20,13 +20,22 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Agreed extra turns and Basic simultaneous victory — 27 October. Prototyped /
Partial.** The public continuation now covers Basic qualifiers and tied
Advanced solos, with a saved final-turn marker for agreements at turn ten and
beyond. The affected 11-file union passes 121 cases and the production build
passes. A controlled turn-ten position continued through native turn-eleven
actions to Mentat without a phase jump; it did not exercise a battle. Final-turn
Fremen timing is provisional, and broader combinations remain open.
**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).
minimal bots are connected. The earlier eight-file affected union passed 104
cases and the production build passed. The follow-up above extends turn ten and
equal-solo choices; 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
+11
View File
@@ -1,5 +1,16 @@
# Dune implementation status
## 27 October 2026 — Basic agreements and extended victory calendar
The publisher FAQ agreement now applies to multiple qualifying Basic sides,
and equal highest Advanced solos can also agree. A saved final-turn marker
permits an agreed turn eleven and subsequent extensions; the shifted Fremen
endgame check is provisional. The affected 11-file union passes 121 cases and
the production build passes. A controlled turn-ten source continued through
native turn-eleven actions to Mentat; no battle occurred in that smoke. Broader
combinations and deployed acceptance remain open.
[Scope](ADVANCED_PREVIEW.md#simultaneous-victories).
## 27 October 2026 — Advanced simultaneous-victory continuation
Before turn ten, multiple qualifying sides including an alliance may
+30 -17
View File
@@ -9,28 +9,41 @@ counts before applying Bene Gesserit prediction. Equal counts provisionally
share the win. The existing three-Tech-token stronghold equivalent is counted
as one in this comparison; this composition is provisional. Competition with
a qualifying alliance or the separate Ecaz Homeworld victory retains the
existing shared-winner treatment. Basic and recorded winners retain their
existing behavior.
existing shared-winner treatment. Basic keeps its shared-win ranking, and
recorded winners retain their existing result.
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.
The [GF9 November 2020 FAQ, printed p. 1](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=1)
permits qualifying players or alliances to agree to play another full turn and
optionally raise the required stronghold count. The supplied Advanced revision,
physical p. 14 (printed p. 12), contains the same option. In the provisional
online implementation, Basic opens a public agreement when at least two
distinct sides qualify; Advanced first ranks unequal unallied qualifiers as
above, and offers an agreement to equal highest solos or other qualifying
distinct sides. 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 voter scope, 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).
resumes, or the completed CHOAM end-of-Mentat continuation advances once. Older
games without a saved `victoryFinalTurn` retain turn ten as their final turn. A
unanimous agreement on that final turn provisionally moves it one turn later; a
further agreement may move it again. An earlier agreement leaves the existing
final turn unchanged. For this online composition, the Fremen and Guild final
victory checks move to the agreed final turn. That is provisional: the supplied
Fremen sheet, physical p. 32 (printed p. 29), names the end of turn ten, while
the Guild sheet, physical p. 37 (printed p. 34), refers to the end of play.
Neither source supplies a worked example reconciling that wording with an
agreed extra turn. Broader special-victory composition and full natural
extended-turn verification remain unfinished. This does not certify complete
victory rules or release acceptance.
[Preview](ADVANCED_PREVIEW.md#simultaneous-victories).
## Juice of Sapho and an already-started Advanced movement turn — 27 October 2026
+5 -1
View File
@@ -191,7 +191,11 @@ const responses = {
/** Bounded core/continuation shape check, not a complete save or rules certification.
* Optional modules retain their own engine validators; do not assume fixed inventories. */
export function adminSeatAiCoreShape(value: unknown): boolean {
if (!record(value) || value.schema !== 1 || !count(value.turn) || value.turn < 1 || value.turn > 10 ||
if (!record(value) || value.schema !== 1 || !count(value.turn) || value.turn < 1 ||
(value.victoryFinalTurn !== undefined &&
(!(typeof value.victoryFinalTurn === 'number' && Number.isSafeInteger(value.victoryFinalTurn)) || value.victoryFinalTurn < 10 ||
value.victoryFinalTurn < value.turn)) ||
(value.turn > 10 && value.victoryFinalTurn === undefined) ||
!count(value.phase) || value.phase >= PHASES.length || !sector(value.storm) ||
!Array.isArray(value.players) || value.players.length < 1 || value.players.length > 6 ||
(['setup', 'playing'].includes(String(value.status)) && value.players.length < 2)) return false;
+24 -8
View File
@@ -1,4 +1,5 @@
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
VictoryContinuationError, MAX_VICTORY_EXTRA_STRONGHOLDS, type VictoryContinuation,
type VictoryContinuationResume } from './victory-continuation';
@@ -1405,6 +1406,8 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
/** Explicit final turn after an agreed extension; absent means turn ten. */
victoryFinalTurn?: number;
/** Agreed addition to the ordinary stronghold win targets. Older games omit it. */
victoryExtraStrongholds?: number;
victoryContinuation?: VictoryContinuation | null;
@@ -29135,12 +29138,19 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
function victoryAgreementRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) {
if (error instanceof VictoryContinuationError) throw new RuleError(error.message);
if (error instanceof VictoryContinuationError || error instanceof VictoryCalendarError) 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);
const rows = strongholdProgress(g).progress.filter(row => row.qualifies);
// The printed Advanced solo comparison eliminates lower-scoring qualifiers
// before offering the provisional choice to equally ranked solo winners.
if (g.advanced && !quote.ecazHomeworld && rows.length > 1 && rows.every(row => row.members.length === 1)) {
const most = Math.max(...rows.map(row => row.strongholds.length + Number(row.techStronghold)));
return rows.filter(row => row.strongholds.length + Number(row.techStronghold) === most).map(row => row.members);
}
const sides = rows.map(row => row.members);
if (quote.ecazHomeworld) sides.push(quote.ecazHomeworld.members);
return sides;
}
@@ -29148,7 +29158,7 @@ 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 &&
requireRule(g.status === 'playing' && 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({
@@ -29165,10 +29175,10 @@ function victory(g: Game, quote?: VictoryQuote, resume: VictoryContinuationResum
!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) {
if (!skipOffer && 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)) {
if (distinct.size >= 2) {
applyAdvisorReleases(g, quote.released);
g.victoryContinuation = victoryAgreementRule(() => createVictoryContinuation({
event: crypto.randomUUID(), turn: g.turn, resume, order: g.order, sides,
@@ -29193,7 +29203,7 @@ function victory(g: Game, quote?: VictoryQuote, resume: VictoryContinuationResum
const homeworld = quote.ecazHomeworld &&
quote.ecazHomeworld.members.length === g.winner.length &&
quote.ecazHomeworld.members.every(id => g.winner.includes(id)) ? quote.ecazHomeworld : null;
const fremen = !quote.ecazHomeworld && g.turn === 10 && !progress.some((row) => row.qualifies)
const fremen = !quote.ecazHomeworld && g.turn === victoryAgreementRule(() => victoryFinalTurn(g)) && !progress.some((row) => row.qualifies)
? fremenSpecialVictory(g) : null;
const fremenWon = fremen?.qualifies && fremen.members.length === g.winner.length &&
fremen.members.every((id) => g.winner.includes(id));
@@ -29205,7 +29215,7 @@ function victory(g: Game, quote?: VictoryQuote, resume: VictoryContinuationResum
`${g.winner.map((id) => faction(getPlayer(g, id).faction).name).join(' and ')} won the game.${occupy ? ` Ecaz Occupy: both allies occupy ${occupy.jointlyOccupied.map((id) => territory(id).name).join(', ')} without opposing fighters, meeting the three-stronghold alliance target.` : ''}${homeworld ? ' High Ecaz Homeworld victory: the alliance jointly holds a stronghold and occupies Homeworlds of two other native factions.' : ''}${specialReason}`,
);
} else if (g.status === 'finished')
log(g, 'The tenth turn ended without a winner.');
log(g, 'The final turn ended without a winner.');
if (quote.strongholds) settleStrongholdOwnership(g, quote.strongholds);
}
function cardOf(p: Player, id: string | null) {
@@ -39960,6 +39970,9 @@ export function applyAction(state: Game, id: string, action: Action): Game {
(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.');
if (action.mode === 'continue')
requireRule(typeof action.extra === 'number' && action.extra <= Math.max(0, 200 - (state.victoryExtraStrongholds ?? 0)),
'Choose an increase within the remaining stronghold target limit.');
const result = victoryAgreementRule(() => voteVictoryContinuation(frame, {
event: action.event as string, player: id,
choice: action.mode === 'finish' ? 'finish' : 'continue',
@@ -39973,6 +39986,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
else {
const extra = (g.victoryExtraStrongholds ?? 0) + result.extraStrongholds;
requireRule(Number.isSafeInteger(extra) && extra <= 200, 'The agreed stronghold target is too large.');
const finalTurn = victoryAgreementRule(() => continueVictoryCalendar(g));
if (finalTurn !== 10 || g.victoryFinalTurn !== undefined) g.victoryFinalTurn = finalTurn;
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);
@@ -45356,6 +45371,7 @@ export function viewGame(state: Game, id: string, now?: number) {
ecazTreachery: !!g.ecazTreachery,
discoveryEnabled: g.discoveryEnabled === true,
turn: g.turn,
victoryFinalTurn: victoryAgreementRule(() => victoryFinalTurn(g)),
phase: g.phase,
phaseOpening: g.phaseOpening ? { passed: g.phaseOpening.passed } : null,
storm: g.status === 'lobby' ? STORM_START_SECTOR : g.storm,
@@ -45617,7 +45633,7 @@ export function viewGame(state: Game, id: string, now?: number) {
voters: [...g.victoryContinuation.voters],
sides: g.victoryContinuation.sides.map(side => [...side]),
extra: g.victoryContinuation.extraStrongholds,
maxExtra: MAX_VICTORY_EXTRA_STRONGHOLDS,
maxExtra: Math.min(MAX_VICTORY_EXTRA_STRONGHOLDS, Math.max(0, 200 - (g.victoryExtraStrongholds ?? 0))),
} : null,
decision: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null :
g.decision?.kind === 'capturedLeader' &&
+2 -2
View File
@@ -4443,9 +4443,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'The separately marked fresh native Ecaz Homeworld preview has another printed route: native Ecaz must currently have at least seven reserves, the reciprocal alliance must jointly hold one actual uncontested stronghold, and it must occupy worlds of two other native factions. Two Emperor worlds count as one faction. This joins normal winners before a correct BG prediction or final-turn fallback; it adds no ordinary stronghold points.',
'Opposing forces may survive together in a stronghold covered by storm. That contested stronghold does not count for either side. A sole occupant still counts it even under storm.',
'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.',
'On the final turn, normally 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. An agreed extra turn provisionally moves these final-turn checks to the new final turn.',
'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. 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.',
'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. Qualifying sides sharing a win may instead unanimously 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. Final-turn extension and exceptional compositions use provisional timing.',
'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.',
],
+35
View File
@@ -0,0 +1,35 @@
/** Provisional calendar for an agreement to play another full turn.
* Older games omit the marker and retain the printed ten-turn calendar. */
export type VictoryCalendar = {
turn: number;
victoryFinalTurn?: number;
};
export class VictoryCalendarError extends Error {
constructor(message: string) {
super(message);
this.name = 'VictoryCalendarError';
}
}
/** Return the agreed final turn while rejecting a game that has already
* advanced beyond its calendar. Does not mutate or migrate old games. */
export function victoryFinalTurn(calendar: VictoryCalendar): number {
const turn = calendar?.turn;
const finalTurn = calendar?.victoryFinalTurn === undefined ? 10 : calendar.victoryFinalTurn;
if (!Number.isSafeInteger(turn) || turn < 1 ||
!Number.isSafeInteger(finalTurn) || finalTurn < 10 || turn > finalTurn)
throw new VictoryCalendarError('The current turn must belong to the agreed victory calendar.');
return finalTurn;
}
/** Call after unanimous acceptance, before the turn advances. An agreement
* earlier than the current final turn keeps that final turn unchanged. At the
* final turn it moves the fallback exactly once to the next full turn. */
export function continueVictoryCalendar(calendar: VictoryCalendar): number {
const finalTurn = victoryFinalTurn(calendar);
if (calendar.turn !== finalTurn) return finalTurn;
if (!Number.isSafeInteger(finalTurn + 1))
throw new VictoryCalendarError('The agreed final turn cannot be extended safely.');
return finalTurn + 1;
}
+6 -6
View File
@@ -1,4 +1,4 @@
/** Provisional online agreement for the supplied Advanced rule allowing
/** Provisional online agreement for the Basic FAQ and 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;
@@ -61,8 +61,8 @@ function canonicalSides(order: readonly string[], sides: readonly (readonly stri
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.');
requireContinuation(unique.length >= 2,
'The continuation needs multiple distinct qualifying sides.');
const members = unique.flat();
requireContinuation(new Set(members).size === members.length,
'Qualifying victory sides cannot overlap.');
@@ -77,10 +77,10 @@ export function createVictoryContinuation(input: {
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') &&
input.turn >= 1 && (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.');
'The shared victory agreement needs a positive safe turn 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,
@@ -91,7 +91,7 @@ export function createVictoryContinuation(input: {
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.version === 1 && id(frame.event) && Number.isSafeInteger(frame.turn) && frame.turn >= 1 &&
(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),
+9 -3
View File
@@ -1,4 +1,5 @@
import type { Game, Player } from './engine';
import { victoryFinalTurn, VictoryCalendarError } from './victory-calendar';
import { FACTIONS } from './catalog';
import { gameTerritories, validLocation } from './board';
import { fighterCount } from './advisors';
@@ -37,7 +38,7 @@ const record = (value: unknown): value is Record<string, unknown> =>
export type VictoryContext = Omit<BoardContext, 'players'> &
Pick<
Game,
'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds'
'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds' | 'victoryFinalTurn'
> & {
players: readonly (BoardSeat & Pick<Player, 'prediction'>)[];
};
@@ -55,11 +56,16 @@ export type VictoryQuote = {
* This preserves the existing victory order, including the distinct final-turn
* Fremen/Guild fallbacks. It does not advance a turn or settle other phase work. */
export function quoteVictory(g: VictoryContext, ecazContext?: EcazHomeworldVictoryContext): VictoryQuote {
let finalTurn: number;
try { finalTurn = victoryFinalTurn(g); }
catch (error) {
if (error instanceof VictoryCalendarError) throw new VictoryQuoteError(error.message);
throw error;
}
requireVictory(
(g.status === 'playing' || g.status === 'finished') &&
whole(g.turn) &&
g.turn >= 1 &&
g.turn <= 10 &&
whole(g.phase) &&
g.phase <= 8 &&
typeof g.advanced === 'boolean' &&
@@ -197,7 +203,7 @@ export function quoteVictory(g: VictoryContext, ecazContext?: EcazHomeworldVicto
if (ecazHomeworld) winner = [...new Set([...winner, ...ecazHomeworld.members])];
if (predicted()) winner = [bg!.id];
let status = g.status;
if (!winner.length && g.turn === 10) {
if (!winner.length && g.turn === finalTurn) {
const fremen = players.find((p) => p.faction === 'fremen');
const guild = players.find((p) => p.faction === 'guild');
if (fremen && fremenSpecialVictory(g)?.qualifies)
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { adminSeatAiCoreShape } from '../game/admin-seat-ai-shape';
import { applyAction, createGame, joinGame, newPlayer, type Game } from '../game/engine';
/** Genuine public Advanced setup; only the calendar fields below are staged. */
function source(advanced = true): Game {
let game = createGame('SEATEXTENDED', newPlayer('a', 'Atreides', 'atreides'), advanced);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
return applyAction(game, game.host, { type: 'start', ...(advanced ? { advancedPreview: true } : {}) });
}
void test('seat AI shape accepts existing turns and explicitly bounded extended Basic and Advanced turns', () => {
const original = source();
for (const turn of [1, 2, 10]) {
const game = structuredClone(original);
game.turn = turn;
assert.equal(adminSeatAiCoreShape(game), true, `existing turn ${turn}`);
}
for (const [turn, finalTurn] of [[10, 11], [11, 11], [11, 12], [12, 12], [11, Number.MAX_SAFE_INTEGER]]) {
const game = structuredClone(original) as Game & { victoryFinalTurn: number };
game.turn = turn;
game.victoryFinalTurn = finalTurn;
const before = structuredClone(game);
assert.equal(adminSeatAiCoreShape(game), true, `turn ${turn} / final ${finalTurn}`);
assert.deepEqual(game, before);
}
const basic = source(false) as Game & { victoryFinalTurn: number };
basic.turn = 11;
basic.victoryFinalTurn = 11;
assert.equal(adminSeatAiCoreShape(basic), true);
});
void test('seat AI shape rejects absent or malformed extended-turn bounds without repair', () => {
const original = source();
for (const finalTurn of [undefined, null, 9, 10, -1, 11.5, '11', NaN, Infinity]) {
const game = structuredClone(original) as unknown as Record<string, unknown>;
game.turn = 11;
if (finalTurn !== undefined) game.victoryFinalTurn = finalTurn;
const before = structuredClone(game);
assert.equal(adminSeatAiCoreShape(game), false, `turn 11 / bound ${String(finalTurn)}`);
assert.deepEqual(game, before);
}
const invalidEarly = structuredClone(original) as unknown as Record<string, unknown>;
invalidEarly.victoryFinalTurn = 9;
assert.equal(adminSeatAiCoreShape(invalidEarly), false);
});
@@ -55,7 +55,27 @@ void test('equal qualified Advanced solo stronghold counts still share the win',
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
holds(g, 'at', 'sietch_tabr', 'tueks_sietch', 'habbanya_ridge_sietch');
assert.deepEqual(quoteVictory(g).winner, ['ix', 'at']);
assert.deepEqual(actual(g).winner, ['ix', 'at']);
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']);
});
void test('equally ranked Advanced solos may unanimously continue instead of sharing victory', () => {
const g = position();
holds(g, 'ix', 'arrakeen', 'carthag');
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
holds(g, 'at', 'sietch_tabr', 'tueks_sietch', 'habbanya_ridge_sietch');
let offered = actual(g);
assert.deepEqual(offered.victoryContinuation?.voters, ['ix', 'at']);
const event = offered.victoryContinuation!.event;
offered = applyAction(offered, 'ix', { type: 'victoryContinuation', event, mode: 'continue', extra: 1 });
offered = applyAction(offered, 'at', { type: 'victoryContinuation', event, mode: 'accept' });
assert.equal(offered.status, 'playing');
assert.equal(offered.victoryExtraStrongholds, 1);
assert.deepEqual(offered.winner, []);
});
void test('the complete Tech set is one extra stronghold for comparing qualified solos', () => {
@@ -75,7 +95,12 @@ void test('Basic retains its existing shared-win policy for unequal qualified so
holds(g, 'at', 'tueks_sietch', 'habbanya_ridge_sietch');
tech(g, 'at');
assert.deepEqual(quoteVictory(g).winner, ['ix', 'at']);
assert.deepEqual(actual(g).winner, ['ix', 'at']);
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']);
});
void test('a qualifying alliance and solo still share an Advanced win regardless of their totals', () => {
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { continueVictoryCalendar, victoryFinalTurn, VictoryCalendarError } from '../game/victory-calendar';
void test('an unstamped game retains turn ten as its final turn without mutation', () => {
const game = { turn: 4 };
assert.equal(victoryFinalTurn(game), 10);
assert.equal(continueVictoryCalendar(game), 10);
assert.deepEqual(game, { turn: 4 });
});
void test('unanimous continuation at the final turn moves the fallback one full turn', () => {
assert.equal(continueVictoryCalendar({ turn: 10 }), 11);
assert.equal(victoryFinalTurn({ turn: 11, victoryFinalTurn: 11 }), 11);
assert.equal(continueVictoryCalendar({ turn: 11, victoryFinalTurn: 11 }), 12);
assert.equal(continueVictoryCalendar({ turn: 10, victoryFinalTurn: 11 }), 11);
});
void test('an extended final-turn stamp survives an ordinary JSON continuation', () => {
const saved = JSON.parse(JSON.stringify({ turn: 11, victoryFinalTurn: 12 }));
assert.equal(victoryFinalTurn(saved), 12);
assert.equal(continueVictoryCalendar(saved), 12);
});
void test('invalid or expired calendars reject rather than silently extending', () => {
for (const game of [
{ turn: 0 }, { turn: 1.5 }, { turn: 11 },
{ turn: 10, victoryFinalTurn: 9 }, { turn: 11, victoryFinalTurn: 10 },
{ turn: 11, victoryFinalTurn: 11.5 }, { turn: NaN, victoryFinalTurn: 10 },
]) assert.throws(() => victoryFinalTurn(game), VictoryCalendarError);
assert.throws(() => victoryFinalTurn({ turn: 1, victoryFinalTurn: null } as never), VictoryCalendarError);
});
void test('extension rejects safe-integer overflow without changing the saved calendar', () => {
const game = { turn: Number.MAX_SAFE_INTEGER, victoryFinalTurn: Number.MAX_SAFE_INTEGER };
assert.throws(() => continueVictoryCalendar(game), VictoryCalendarError);
assert.equal(game.victoryFinalTurn, Number.MAX_SAFE_INTEGER);
});
+9 -6
View File
@@ -132,12 +132,15 @@ void test('zero-increase agreement preserves ordinary targets for the next turn'
assert.equal(progress.find(row => row.player === 'at')?.target, 4);
});
void test('Basic, one qualifying side and the turn-ten boundary retain their native victory outcome', () => {
for (const options of [{ advanced: false }, { onlyAlliance: true }, { turn: 10 }] as const) {
const game = reachMentat(position(options));
assert.equal(game.status, 'finished'); assert.ok(game.winner.length > 0);
assert.ok(!game.victoryContinuation);
}
void test('Basic multiple qualifiers can agree while one qualifying side still wins directly', () => {
const basic = reachMentat(position({ advanced: false }));
assert.equal(basic.status, 'playing');
assert.ok(basic.victoryContinuation);
const settled = act(basic, basic.victoryContinuation.voters[0], 'finish');
assert.equal(settled.status, 'finished');
const single = reachMentat(position({ onlyAlliance: true }));
assert.equal(single.status, 'finished'); assert.ok(single.winner.length > 0);
assert.ok(!single.victoryContinuation);
});
void test('all four minimal bot profiles produce a legal response for the public continuation', () => {
+18 -4
View File
@@ -23,6 +23,20 @@ void test('allied rows are deduplicated and every qualifying member votes in pub
validateVictoryContinuation(frame);
});
void test('distinct solo sides may agree in turn ten and in a later extended turn', () => {
for (const turn of [10, 11]) {
const frame = createVictoryContinuation({ event: `victory:${turn}`, turn, resume: 'advance',
order: ['a', 'b', 'c'], sides: [['c'], ['a']] });
assert.deepEqual(frame.sides, [['a'], ['c']]);
assert.deepEqual(frame.voters, ['a', 'c']);
const first = voteVictoryContinuation(frame, { event: frame.event, player: 'a', choice: 'continue', extraStrongholds: 1 });
assert.equal(first.outcome, 'pending');
if (first.outcome === 'pending')
assert.deepEqual(voteVictoryContinuation(first.frame, { event: frame.event, player: 'c', choice: 'continue' }),
{ outcome: 'continue', extraStrongholds: 1 });
}
});
for (const extra of [0, 3, MAX_VICTORY_EXTRA_STRONGHOLDS]) void test(
`the exact shared proposal of ${extra} extra strongholds requires every qualifier's acceptance`, () => {
const initial = source(), original = structuredClone(initial);
@@ -73,10 +87,10 @@ void test('stale events, non-voters and unauthorized proposal changes reject wit
void test('unsupported source profiles and forged saved receipts are rejected', () => {
const frame = source(), snapshot = structuredClone(frame);
assert.throws(() => createVictoryContinuation({ event: 'turn10', turn: 10, resume: 'mentat',
assert.throws(() => createVictoryContinuation({ event: 'invalid', turn: 0, resume: 'mentat',
order: ['a', 'b', 'c'], sides: [['a', 'b'], ['c']] }), VictoryContinuationError);
assert.throws(() => createVictoryContinuation({ event: 'solos', turn: 2, resume: 'mentat',
order: ['a', 'b'], sides: [['a'], ['b']] }), VictoryContinuationError);
assert.throws(() => createVictoryContinuation({ event: 'single', turn: 2, resume: 'mentat',
order: ['a', 'b'], sides: [['a']] }), VictoryContinuationError);
assert.throws(() => createVictoryContinuation({ event: 'overlap', turn: 2, resume: 'mentat',
order: ['a', 'b', 'c'], sides: [['a', 'b'], ['b', 'c']] }), VictoryContinuationError);
for (const forged of [
@@ -84,7 +98,7 @@ void test('unsupported source profiles and forged saved receipts are rejected',
{ ...frame, accepted: ['c'], extraStrongholds: 0 },
{ ...frame, voters: ['a', 'd'] },
{ ...frame, sides: [['a', 'c'], ['a', 'd']] },
{ ...frame, turn: 10 },
{ ...frame, turn: 0 },
{ ...frame, accepted: ['a', 'c', 'd'], extraStrongholds: 0 },
{ ...frame, extra: true },
]) assert.throws(() => validateVictoryContinuation(forged as VictoryContinuation), VictoryContinuationError);
+212
View File
@@ -0,0 +1,212 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { baseDeck, spiceDeck } from '../game/cards';
import { createTechTokens } from '../game/tech-tokens';
import type { FactionId } from '../game/catalog';
import { actAdvancedRevivalOrder, nextAdvancedRevivalOrder } from './fixture-advanced-revival-order';
/** Controlled, conserved late-game position. Original strongholds and Tech
* ownership are directly arranged to expose the rare simultaneous qualifying
* result. Collection, Mentat and agreement actions use the live engine; this
* is not an earned ten-turn game or a saved-room test. */
function position(options: { advanced?: boolean; guild?: boolean; fremen?: boolean; choam?: boolean; prediction?: boolean } = {}): Game {
const g = createGame('EXTENDEDVICTORY', newPlayer('ix', 'Ixians', 'ixians'), options.advanced ?? true, options.choam ? ['choam'] : []);
const seats: [string, string, FactionId][] = [
['at', 'Atreides', 'atreides'], ['em', 'Emperor', 'emperor'], ['bg', 'Bene Gesserit', 'beneGesserit'],
];
if (options.guild) seats.push(['gu', 'Guild', 'guild']);
if (options.fremen) seats.push(['fr', 'Fremen', 'fremen']);
if (options.choam) seats.push(['ch', 'CHOAM', 'choam']);
for (const [id, name, faction] of seats) g.players.push(newPlayer(id, name, faction));
assert.ok(g.players.length <= 6);
Object.assign(g, { status: 'playing', phase: 7, turn: 10, storm: 18,
order: g.players.map(player => player.id), ready: [], phaseOpening: null,
decision: null, response: null, active: null, deck: baseDeck(), spiceDeck: spiceDeck(),
mobileStronghold: { location: 'red_chasm:7' }, techTokens: createTechTokens() });
for (const p of g.players) Object.assign(p, { hand: [], forces: {},
reserves: 20, spice: 20, traitors: [], traitorChoices: [] });
const hold = (id: string, ...names: string[]) => {
const p = g.players.find(player => player.id === id)!;
for (const name of names) p.forces[`${name}:${territory(name).sectors[0]}`] = 1;
p.reserves -= names.length;
};
g.players[1].ally = 'em'; g.players[2].ally = 'at';
hold('at', 'arrakeen', 'carthag'); hold('em', 'sietch_tabr');
hold('ix', 'tueks_sietch', 'habbanya_ridge_sietch');
g.players[0].forces['hidden_mobile_stronghold:0'] = 1; g.players[0].reserves--;
for (const token of Object.values(g.techTokens!)) token.owner = 'at';
if (options.prediction) g.players[3].prediction = { faction: 'atreides', turn: 10 };
return g;
}
function enterMentat(source: Game): Game {
let g = structuredClone(source);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
if (g.phase === 7 && g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
assert.equal(g.phase, 8);
if (g.players.some(player => player.faction === 'choam')) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
if (g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
assert.equal(g.decision?.kind, 'choamMentat');
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
}
return g;
}
function offer(g: Game) {
assert.ok(g.victoryContinuation, 'The actual Mentat check offers the qualifying sides a vote');
return g.victoryContinuation;
}
function vote(g: Game, id: string, mode: 'finish' | 'continue' | 'accept', extra?: number): Game {
return applyAction(g, id, { type: 'victoryContinuation', event: offer(g).event, mode,
...(extra === undefined ? {} : { extra }) });
}
function accept(g: Game, extra = 0): Game {
const voters = [...offer(g).voters];
g = vote(g, voters[0], 'continue', extra);
for (const id of voters.slice(1)) g = vote(g, id, 'accept');
return g;
}
function finishMentat(g: Game): Game {
if (g.phase !== 8) return g;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function reject(g: Game, player: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, player, action));
assert.deepEqual(g, before);
}
function startEleventh(source = position(), extra = 0): Game {
let g = accept(enterMentat(source), extra);
assert.equal(g.victoryFinalTurn, 11);
if (g.phase === 8) g = finishMentat(g);
assert.equal(g.status, 'playing'); assert.equal(g.turn, 11); assert.equal(g.phase, 0);
return g;
}
/** Controlled follow-up: retain the actual extended-turn state, but advance
* its phase/ready boundary directly to Collection. Tests below do not claim a
* full natural turn from Storm to Collection; all later checks and votes are
* native engine actions on the labelled board. */
function stagedCollection(source: Game): Game {
const g = structuredClone(source);
Object.assign(g, { phase: 7, ready: [], active: null, decision: null, response: null,
phaseOpening: null, stormPending: null, stormResolution: null });
return g;
}
function clearForces(g: Game) {
for (const p of g.players) {
p.reserves += Object.values(p.forces).reduce((total, amount) => total + amount, 0);
p.forces = {};
}
}
void test('unanimous turn-ten agreement opens real turn eleven without replaying the prior board', () => {
const original = position();
const opening = enterMentat(original);
const before = opening.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves }));
const agreed = accept(opening, 1);
assert.equal(agreed.turn, 10); assert.equal(agreed.phase, 8);
assert.equal(agreed.victoryFinalTurn, 11); assert.equal(agreed.victoryExtraStrongholds, 1);
assert.deepEqual(agreed.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves })), before);
const next = finishMentat(agreed);
assert.equal(next.status, 'playing'); assert.equal(next.turn, 11); assert.equal(next.phase, 0);
assert.equal(next.victoryFinalTurn, 11); assert.equal(viewGame(next, 'ix').turn, 11);
assert.deepEqual(next.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves })), before);
});
void test('Basic qualifying sides may also agree to play turn eleven', () => {
const next = startEleventh(position({ advanced: false }));
assert.equal(next.advanced, false);
assert.equal(next.victoryFinalTurn, 11);
});
void test('accepted turn eleven follows native phases through its next Mentat check', () => {
const source = position();
// The turn-ten public board is controlled as above. Select two existing,
// unopened ordinary Spice cards to avoid an unrelated worm negotiation;
// this is not a claim about their naturally shuffled original order.
const originalSpice = source.spiceDeck.map(card => JSON.stringify(card)).sort();
const front: Game['spiceDeck'] = [];
for (let i = 0; i < 2; i++) {
const index = source.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(index >= 0);
front.push(source.spiceDeck.splice(index, 1)[0]);
}
source.spiceDeck.unshift(...front);
assert.deepEqual(source.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let game = startEleventh(source, 1);
const phases = new Set<number>();
const actions: { actor: string; action: Action }[] = [];
for (let count = 0; count < 1500 && game.status === 'playing' && game.phase !== 8; count++) {
assert.equal(game.turn, 11);
phases.add(game.phase);
const next = nextAdvancedRevivalOrder(game);
assert.ok(next, `Native turn-11 control unavailable at phase ${game.phase}, decision ${game.decision?.kind ?? 'none'}`);
game = actAdvancedRevivalOrder(game, next.actor, next.action, actions);
}
assert.equal(game.turn, 11);
assert.equal(game.phase, 8, 'The original phase sequence reaches the next Mentat check');
assert.ok(game.status === 'finished' || game.victoryContinuation,
'The extended final-turn check settles a winner or offers the next agreement');
assert.equal(game.victoryFinalTurn, 11);
assert.ok(actions.length > 0);
for (const phase of [0, 1, 2, 3, 4, 5, 7])
assert.ok(phases.has(phase), `Native phase ${phase} was not exercised`);
});
void test('a turn-ten refusal settles the original result and private prediction', () => {
const g = enterMentat(position({ prediction: true })), pending = offer(g);
reject(g, 'bg', { type: 'victoryContinuation', event: pending.event, mode: 'finish' });
const settled = vote(g, pending.voters[0], 'finish');
assert.equal(settled.status, 'finished'); assert.equal(settled.turn, 10);
assert.deepEqual(settled.winner, ['bg']); assert.ok(!settled.victoryContinuation);
});
void test('a second unanimous agreement at turn eleven extends the final turn again', () => {
const controlled = stagedCollection(startEleventh()), opening = enterMentat(controlled);
assert.equal(opening.turn, 11); assert.equal(offer(opening).turn, 11);
const agreed = accept(opening);
assert.equal(agreed.victoryFinalTurn, 12);
const next = finishMentat(agreed);
assert.equal(next.status, 'playing'); assert.equal(next.turn, 12); assert.equal(next.phase, 0);
assert.equal(next.victoryFinalTurn, 12);
});
void test('extended final-turn fallback applies Fremen, Guild and absent-faction outcomes', () => {
for (const [options, expected] of [
[{ guild: true, fremen: true }, 'fr'], [{ guild: true }, 'gu'], [{}, 'ix'],
] as const) {
const controlled = stagedCollection(startEleventh(position(options), 1));
clearForces(controlled);
// The Atreides-owned Tech set gives it one hold in the no-Guild fallback.
// Shift that physical set to Ix for an unambiguous highest-count winner.
for (const token of Object.values(controlled.techTokens!)) token.owner = 'ix';
const settled = enterMentat(controlled);
assert.equal(settled.turn, 11); assert.equal(settled.status, 'finished');
assert.deepEqual(settled.winner, [expected]);
}
});
void test('a sole qualifying stronghold winner precedes fallback on the extended final turn', () => {
const controlled = stagedCollection(startEleventh());
// Controlled conserved removal prevents the alliance from qualifying while
// leaving the original Ixian three-stronghold group intact.
const emperor = controlled.players.find(p => p.id === 'em')!;
emperor.reserves += Object.values(emperor.forces).reduce((sum, count) => sum + count, 0);
emperor.forces = {};
const settled = enterMentat(controlled);
assert.equal(settled.status, 'finished'); assert.deepEqual(settled.winner, ['ix']);
});
void test('CHOAM Mentat continuation advances exactly once from turn ten to eleven', () => {
const opening = enterMentat(position({ choam: true }));
assert.equal(offer(opening).resume, 'advance');
const next = accept(opening, 1);
assert.equal(next.status, 'playing'); assert.equal(next.turn, 11); assert.equal(next.phase, 0);
assert.equal(next.victoryFinalTurn, 11); assert.equal(next.victoryExtraStrongholds, 1);
});