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

+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)