At a clean unstarted non-Guild movement boundary, Sapho FIRST now reorders the remaining turn queue and reopens the native Advanced Guild choice. Guild may take its turn ahead or wait while all non-Guild turns retain their relative order. Protect an already granted current Guild turn, keep canceled timing locked and preserve completed turns, payments and last protection. Existing discard continuations and minimal policy options need no new state or signature format. Explain the provisional precedence in the controls and reference. Validation:7-file97/97, types/lint/build and six original-action/rendered-table programmes. Actual cache1 Sapho, original Guild wait/take, paid completed Richese prefix, Emperor2/Guild1 shipments and native phase completion. An actually dealt Karama cancels TAKE without its privilege being revived; a granted current turn cannot be displaced. Four profiles make eight legal holder/Guild proposals. Corrected an obsolete first exclusion and a fixture that mistook a later response pass for a repeated movement turn; exact failures and then the whole batch pass. This nonconflicting composition is provisional. Actual competing first, partly completed turns, other ordered actions, direct combined Semuta claim/capture, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
165 lines
10 KiB
TypeScript
165 lines
10 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
|
|
|
const sapho = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(sapho);
|
|
export const GUILD_FIRST_SAPHO = sapho.id;
|
|
export type SaphoGuildFirstStep = { actor: string; action: Action };
|
|
export const saphoGuildFirstPlayer = (game: Game, actor = 'emperor') => {
|
|
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
|
};
|
|
export const saphoGuildFirstClean = (game: Game) => !game.response && !game.phaseOpening &&
|
|
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
|
export function actSaphoGuildFirst(game: Game, actor: string, action: Action, actions: SaphoGuildFirstStep[] = []): Game {
|
|
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
|
actions.push({ actor, action: structuredClone(action) }); return next;
|
|
}
|
|
export function nextSaphoGuildFirstStep(game: Game): SaphoGuildFirstStep | null {
|
|
if (!game.response && !game.phaseOpening && game.decision) {
|
|
const decision = game.decision;
|
|
if (decision.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
|
|
if (decision.kind === 'richeseCache') {
|
|
const card = game.richeseCache!.find(card => card.id === GUILD_FIRST_SAPHO) ?? game.richeseCache![0]; assert.ok(card);
|
|
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
|
card: card.id, method: 'onceAround', direction: 'clockwise' } };
|
|
}
|
|
if (decision.kind === 'richeseUnbid') return { actor: decision.player,
|
|
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
|
}
|
|
const lot = game.richeseAuction;
|
|
if (saphoGuildFirstClean(game) && lot?.cardId === GUILD_FIRST_SAPHO && !lot.outcome &&
|
|
lot.active === 'emperor' && lot.bid === 0)
|
|
return { actor: 'emperor', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
|
|
return nextRicheseStrongholdsNativeStep(game);
|
|
}
|
|
export function advanceSaphoGuildFirst(game: Game, until: (state: Game) => boolean,
|
|
actions: SaphoGuildFirstStep[] = []): Game {
|
|
for (let count = 0; count < 1800; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished', 'The original Sapho/Guild programme ended before its next control');
|
|
const step = nextSaphoGuildFirstStep(game); assert.ok(step, 'Finish the original owned control before advancing');
|
|
game = actSaphoGuildFirst(game, step.actor, step.action, actions);
|
|
}
|
|
throw Error('The original Sapho/Guild programme did not reach its finite boundary');
|
|
}
|
|
|
|
/** Genuine public host/setup, paid cache1 Sapho and native Guild WAIT. One
|
|
* original Karama is selected while the starting deck is still unopened, then
|
|
* actually dealt to Atreides so native TAKE has a real allow/cancel control.
|
|
* The optional completed prefix is an original paid shipment and explicit end.
|
|
* No dealt hand, force, money, phase, queue or timing receipt is installed. */
|
|
export function createSaphoGuildFirstFixture(options: { completedFirst?: boolean } = {}) {
|
|
let game = createGame('SAPHOGUILDFIRST', newPlayer('richese', 'Richese', 'richese'), true, ['choam']);
|
|
for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
|
const actions: SaphoGuildFirstStep[] = [];
|
|
const act = (actor: string, action: Action) => { game = actSaphoGuildFirst(game, actor, action, actions); };
|
|
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoGuildFirst(game, until, actions); };
|
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
|
const lobby = structuredClone(game);
|
|
act(game.host, { type: 'start', advancedPreview: true });
|
|
const setup = structuredClone(game);
|
|
assert.equal(game.status, 'setup');
|
|
assert.ok(game.players.every(player => player.hand.length === 0));
|
|
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
|
const counter = game.deck[index].id;
|
|
[game.deck[1], game.deck[index]] = [game.deck[index], game.deck[1]];
|
|
const beforeStartingDeal = structuredClone(game);
|
|
reach(state => state.status === 'playing');
|
|
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
|
|
assert.ok(saphoGuildFirstPlayer(game, 'atreides').hand.some(card => card.id === counter));
|
|
for (let count = 0; count < 2; count++) {
|
|
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
|
|
front.push(game.spiceDeck.splice(at, 1)[0]);
|
|
}
|
|
game.spiceDeck.unshift(...front);
|
|
reach(state => saphoGuildFirstPlayer(state).hand.some(card => card.id === GUILD_FIRST_SAPHO));
|
|
const acquired = structuredClone(game);
|
|
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
|
|
const beforeGuildWait = structuredClone(game), waits: { before: Game; step: SaphoGuildFirstStep; after: Game }[] = [];
|
|
const wait = () => {
|
|
assert.equal(game.decision?.kind, 'guildTiming');
|
|
const before = structuredClone(game), step: SaphoGuildFirstStep = { actor: 'guild', action: { type: 'decision', take: false } };
|
|
act(step.actor, step.action); reach(saphoGuildFirstClean);
|
|
waits.push({ before, step, after: structuredClone(game) });
|
|
};
|
|
wait();
|
|
let prefix: { before: Game; shipment: SaphoGuildFirstStep; afterShipment: Game; end: SaphoGuildFirstStep; afterEnd: Game } | null = null;
|
|
if (options.completedFirst) {
|
|
assert.equal(game.active, 'richese');
|
|
const before = structuredClone(game), shipment: SaphoGuildFirstStep = { actor: 'richese', action: {
|
|
type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0,
|
|
} };
|
|
act(shipment.actor, shipment.action); reach(saphoGuildFirstClean);
|
|
const afterShipment = structuredClone(game), end: SaphoGuildFirstStep = { actor: 'richese', action: { type: 'endMovement' } };
|
|
act(end.actor, end.action);
|
|
const afterEnd = structuredClone(game);
|
|
prefix = { before, shipment, afterShipment, end, afterEnd };
|
|
wait();
|
|
}
|
|
return { game, lobby, setup, beforeStartingDeal, afterSetup, acquired, beforeGuildWait, waits, prefix, counter,
|
|
card: GUILD_FIRST_SAPHO, owner: 'emperor', guild: 'guild', canceler: 'atreides',
|
|
completedFirst: options.completedFirst ?? false, sourceActionCount: actions.length, actions,
|
|
staging: ['Only the unopened original Atreides Karama starting-deal position, unopened first-turn land Spice positions and scalar lotteries are selected. Public host/setup, paid cache1, native Guild wait(s) and optional paid Richese prefix provide the original source. No postdeal custody, resources, clocks, queues or timing receipts are assigned.'] };
|
|
}
|
|
export type SaphoGuildFirstFixture = ReturnType<typeof createSaphoGuildFirstFixture>;
|
|
export function saphoGuildFirstAction(game: Game): Action {
|
|
const option = viewGame(game, 'emperor').saphoOptions.find(option => option.scope === 'movement' && option.mode === 'first');
|
|
assert.ok(option, 'The real unstarted non-Guild boundary must offer Sapho first');
|
|
return { type: 'card', card: GUILD_FIRST_SAPHO, ...option };
|
|
}
|
|
|
|
/** Branch from the original first native decision, not a synthesized timing
|
|
* state. A real distinct Atreides Karama may cancel TAKE; ordinary allowance
|
|
* uses its actual pass response instead. */
|
|
export function prepareSaphoGuildTake(fixture: SaphoGuildFirstFixture, cancel = false) {
|
|
const actions: SaphoGuildFirstStep[] = [];
|
|
let game = actSaphoGuildFirst(structuredClone(fixture.beforeGuildWait), fixture.guild,
|
|
{ type: 'decision', take: true }, actions);
|
|
const declared = structuredClone(game);
|
|
assert.equal(game.response?.kind, 'guildTiming');
|
|
if (cancel) game = actSaphoGuildFirst(game, fixture.canceler,
|
|
{ type: 'card', mode: 'cancel', card: fixture.counter }, actions);
|
|
game = advanceSaphoGuildFirst(game, saphoGuildFirstClean, actions);
|
|
return { game, declared, actions, cancel };
|
|
}
|
|
|
|
/** Sapho takes first among unstarted non-Guild turns; the real Guild timing
|
|
* decision is reopened. TAKE is paid and finished before the holder, while WAIT
|
|
* retains the original relative non-Guild suffix. Every turn explicitly ends. */
|
|
export function completeSaphoGuildFirst(fixture: SaphoGuildFirstFixture, take: boolean) {
|
|
let game = structuredClone(fixture.game);
|
|
const actions: SaphoGuildFirstStep[] = [], turns: {
|
|
actor: string; before: Game; shipment: SaphoGuildFirstStep | null; afterShipment: Game;
|
|
end: SaphoGuildFirstStep; afterEnd: Game;
|
|
}[] = [];
|
|
const act = (actor: string, action: Action) => { game = actSaphoGuildFirst(game, actor, action, actions); };
|
|
const settle = () => { game = advanceSaphoGuildFirst(game, saphoGuildFirstClean, actions); };
|
|
const first = saphoGuildFirstAction(game);
|
|
act(fixture.owner, first);
|
|
const reoffered = structuredClone(game);
|
|
assert.equal(game.decision?.kind, 'guildTiming');
|
|
act(fixture.guild, { type: 'decision', take });
|
|
const declaredGuild = structuredClone(game);
|
|
settle();
|
|
const afterGuildChoice = structuredClone(game), seen = new Set<string>();
|
|
for (let count = 0; game.phase === 5 && count < 80; count++) {
|
|
if (!saphoGuildFirstClean(game)) { settle(); continue; }
|
|
const actor = game.active; assert.ok(actor && !seen.has(actor), 'A completed native movement turn cannot recur');
|
|
seen.add(actor);
|
|
const before = structuredClone(game);
|
|
const shipment: SaphoGuildFirstStep | null = actor === fixture.owner || actor === fixture.guild ? {
|
|
actor, action: actor === fixture.owner
|
|
? { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 }
|
|
: { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 },
|
|
} : null;
|
|
if (shipment) { act(shipment.actor, shipment.action); settle(); }
|
|
const afterShipment = structuredClone(game), end: SaphoGuildFirstStep = { actor, action: { type: 'endMovement' } };
|
|
act(end.actor, end.action);
|
|
turns.push({ actor, before, shipment, afterShipment, end, afterEnd: structuredClone(game) });
|
|
}
|
|
assert.notEqual(game.phase, 5, 'The native combined-turn queue finishes finitely');
|
|
return { game, first, reoffered, declaredGuild, afterGuildChoice, turns, actions, take };
|
|
}
|