Fremen final-turn victory
- 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.
+ 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.
{fremen.sietches.map((site) => (
-
diff --git a/docs/ADVANCED_PREVIEW.md b/docs/ADVANCED_PREVIEW.md
index 946a7ca9..2bd93791 100644
--- a/docs/ADVANCED_PREVIEW.md
+++ b/docs/ADVANCED_PREVIEW.md
@@ -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
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 8168fda9..6ef5ae06 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -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
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 88e91a3f..a40e4fc0 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -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
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index b776a1a8..6686e4c7 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -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
diff --git a/game/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts
index ac83f9da..f8246927 100644
--- a/game/admin-seat-ai-shape.ts
+++ b/game/admin-seat-ai-shape.ts
@@ -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;
diff --git a/game/engine.ts b/game/engine.ts
index a89ed748..41b8660d 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -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(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' &&
diff --git a/game/reference.ts b/game/reference.ts
index 4b6043ee..f4105fd3 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -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.',
],
diff --git a/game/victory-calendar.ts b/game/victory-calendar.ts
new file mode 100644
index 00000000..80fb4f7f
--- /dev/null
+++ b/game/victory-calendar.ts
@@ -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;
+}
diff --git a/game/victory-continuation.ts b/game/victory-continuation.ts
index 587143d5..342cdf0e 100644
--- a/game/victory-continuation.ts
+++ b/game/victory-continuation.ts
@@ -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),
diff --git a/game/victory-quote.ts b/game/victory-quote.ts
index 643f58b3..534e9eb7 100644
--- a/game/victory-quote.ts
+++ b/game/victory-quote.ts
@@ -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 =>
export type VictoryContext = Omit &
Pick<
Game,
- 'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds'
+ 'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds' | 'victoryFinalTurn'
> & {
players: readonly (BoardSeat & Pick)[];
};
@@ -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)
diff --git a/tests/admin-seat-ai-extended-turn.test.ts b/tests/admin-seat-ai-extended-turn.test.ts
new file mode 100644
index 00000000..8e0f43c8
--- /dev/null
+++ b/tests/admin-seat-ai-extended-turn.test.ts
@@ -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;
+ 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;
+ invalidEarly.victoryFinalTurn = 9;
+ assert.equal(adminSeatAiCoreShape(invalidEarly), false);
+});
diff --git a/tests/advanced-victory-stronghold-tiebreak.test.ts b/tests/advanced-victory-stronghold-tiebreak.test.ts
index 91e327a7..f2cc95ec 100644
--- a/tests/advanced-victory-stronghold-tiebreak.test.ts
+++ b/tests/advanced-victory-stronghold-tiebreak.test.ts
@@ -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', () => {
diff --git a/tests/victory-calendar.test.ts b/tests/victory-calendar.test.ts
new file mode 100644
index 00000000..a7c53df2
--- /dev/null
+++ b/tests/victory-calendar.test.ts
@@ -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);
+});
diff --git a/tests/victory-continuation-runtime.test.ts b/tests/victory-continuation-runtime.test.ts
index de5d72ea..ae1d72b5 100644
--- a/tests/victory-continuation-runtime.test.ts
+++ b/tests/victory-continuation-runtime.test.ts
@@ -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', () => {
diff --git a/tests/victory-continuation.test.ts b/tests/victory-continuation.test.ts
index 79ae432c..48218afe 100644
--- a/tests/victory-continuation.test.ts
+++ b/tests/victory-continuation.test.ts
@@ -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);
diff --git a/tests/victory-extended-turn-runtime.test.ts b/tests/victory-extended-turn-runtime.test.ts
new file mode 100644
index 00000000..0f046942
--- /dev/null
+++ b/tests/victory-extended-turn-runtime.test.ts
@@ -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();
+ 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);
+});