diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx
index 3a0ddd80..8146b415 100644
--- a/components/juice-of-sapho.tsx
+++ b/components/juice-of-sapho.tsx
@@ -20,7 +20,7 @@ function unavailableReason(game: GameView) {
return 'No order change is available for you in this lot. You must not have bid or passed; discarding this card can open a hand slot, but a completed opportunity cannot be repeated. A card already committed elsewhere cannot be used.';
}
if (game.phase === 5)
- return 'Movement order can change only before the current combined shipment and movement turn begins. First requires all remaining turns to be unstarted and the Advanced Guild to have finished or be absent. Completed turns stay completed. Already-held positions and committed cards are unavailable.';
+ return 'Movement order can change only before the current combined shipment and movement turn begins. First requires all remaining turns to be unstarted. The Advanced Guild retains its next timing choice; an already granted current Guild turn cannot be displaced. A Karama-locked privilege stays locked. Completed turns, already-held positions and committed cards remain unavailable.';
if (game.phase === 6)
return game.battle
? game.battle.saphoTimingVersion === 1
@@ -77,7 +77,7 @@ export function JuiceOfSapho({
? `Bid ${option.mode} in the current Once Around lot. Each eligible player still has only one bidding opportunity.`
: battle
? `Choose your remaining battles ${option.mode}. Completed battles stay resolved. This changes who chooses next, not the aggressor or tie advantage of a battle. Other players may still choose battles against you before your turn.`
- : `Take the ${option.mode} remaining combined shipment and movement turn. Completed turns stay completed${option.mode === 'last' ? ', and the Guild cannot move behind you' : ''}.`}
+ : `Take the ${option.mode} remaining combined shipment and movement turn. Completed turns stay completed${option.mode === 'last' ? ', and the Guild cannot move behind you' : game.advanced && game.players.some(player => player.faction === 'guild' && player.id !== game.me) ? '. Provisional first priority: the Guild retains its next legal timing choice and may take its turn before yours; a Karama-locked privilege stays locked' : ''}.`}
saphoMovementStarted(g, getPlayer(g, id))) &&
!(
- g.advanced &&
- queue.remaining.some((id) => getPlayer(g, id).faction === 'guild')
+ g.advanced && !g.guildTimingLocked && g.guildTimingGranted &&
+ getPlayer(g, queue.current!).faction === 'guild'
) &&
queue.current !== p.id
)
@@ -19749,7 +19749,7 @@ function playSapho(g: Game, p: Player, action: Action) {
const used = discard(g, p, 'richese-juice-of-sapho');
log(
g,
- `${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.`,
+ `${p.name} discarded Juice of Sapho to take the ${option.mode} remaining combined shipment and movement turn${option.mode === 'last' ? ', including after the Spacing Guild' : ''}. Completed turns and physical storm order are unchanged.${option.mode === 'first' && g.advanced && g.movementRemaining.some(id => id !== p.id && getPlayer(g, id).faction === 'guild') ? ' The Guild retains its next legal timing choice and may still take its turn before this reordered turn.' : ''}`,
{ faction: p.faction, name: 'Juice of Sapho' },
);
if (g.semutaPreview) {
diff --git a/game/reference.ts b/game/reference.ts
index bc4d9ca3..720d03c5 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -496,7 +496,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In a current Once Around auction, first or last can reorder remaining bidders while you have not yet bid. Earlier bids stay committed; a position you already hold is unavailable. You still get only one bidding opportunity and must outbid the current high bid to win.',
'In ordinary cyclic bidding or native Advanced normal Black Market, provisional first/last changes the current lot only, anchored at the live next bidder. Later circuits use that local cycle. Keep standing bids, allied funding and passes; a passed holder waits for a real raise, and the high bidder has no pending bid to reorder. Every purchase still requires a strict raise. Full effective-cycle no-ops cannot consume the card. The next lot uses its normal physical opener.',
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. Once Around completed opportunities cannot repeat. A cyclic pass stays spent until a genuine raise reopens it; a finished lot cannot reopen.',
- 'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, the Guild must have finished or be absent. Last stays after the Guild even if the Guild later chooses to wait.',
+ 'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, provisional first ordering retains an unspent Guild’s native next-boundary take/wait choice; Guild may still act ahead. An already granted current Guild turn cannot be displaced, and a Karama-locked privilege stays locked. Last stays after the Guild even if the Guild later chooses to wait.',
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.',
'Finish existing shipment, movement, card preparation and pending decisions before changing order. Your hand panel lists only currently available choices. A reserved card or a position you already hold cannot be selected.',
@@ -541,8 +541,10 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
- '18 October late aggressor:16-file177/177, types/lint/build and eight original-action/rendered-table programmes pass. Actual cache1/paid forces use40 Basic/47 Advanced source actions; native Stone1 turn1/Sapho1 turn2 uses86. Sealed-plan and revised-final-window uses preserve plans/votes; both final acknowledgements precede real count2 and settlement. Sixteen minimal proposals are legal. Controlled reservation probes are separate. Related Homeworld final-vote routing is repaired; no direct combined World/Nexus/Sapho or late Semuta claim/capture proof. Earlier cyclic scope:10-file134/134, types/lint/build and eight original-action/rendered-table continuations pass. Actual cache1, Basic/Advanced ordinary bidding, native Advanced Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policy proposals are legal; actual passes need a real raise. Controlled pure-cycle/post-sale quotes are separate. Fixture opener/completion and external smoke assumptions were repaired; only runtime was rerun against identical source after types/lint/build. No browser click, visual/deployed, direct combined Semuta claim/capture, full-mode or final-assurance acceptance.',
+ '18 October unspent Guild first:7-file97/97, types/lint/build and six original-action/rendered-table programmes pass. Actual cache1/native wait uses49 source actions; real paid completed Richese prefix uses55. TAKE/WAIT preserves non-Guild order and paid Emperor2/Guild1 shipments. Granted-current and actual originally-dealt Karama cancellation both finish without restoring blocked timing. Four profiles make eight legal holder/Guild proposals. Two fixture assumptions were repaired, then all checks passed together. Prior late-aggressor and cyclic evidence remains in the linked feature docs. No browser clicks, visual/deployed, direct combined Semuta claim/capture, actual competing first, partly completed turn, full-mode or final-assurance acceptance',
evidence: [
+ 'tests/fixture-sapho-guild-first.ts',
+ 'tests/sapho-guild-first-engine.test.ts',
'game/battle-outcome-window.ts',
'components/battle-outcome-window.tsx',
'tests/battle-outcome-window.test.ts',
diff --git a/tests/fixture-sapho-guild-first.ts b/tests/fixture-sapho-guild-first.ts
new file mode 100644
index 00000000..ed2b6ed4
--- /dev/null
+++ b/tests/fixture-sapho-guild-first.ts
@@ -0,0 +1,164 @@
+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;
+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();
+ 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 };
+}
diff --git a/tests/juice-of-sapho-engine.test.ts b/tests/juice-of-sapho-engine.test.ts
index f3f45339..2bd23e92 100644
--- a/tests/juice-of-sapho-engine.test.ts
+++ b/tests/juice-of-sapho-engine.test.ts
@@ -314,7 +314,7 @@ void test('Sapho first reorders only the unstarted movement suffix after a real
reject(startedNext, 'g', request);
});
-void test('Genuine Advanced setup permits Sapho first after Guild finishes, retaining unspent Guild priority beforehand', () => {
+void test('Genuine Advanced setup permits Sapho first after Guild waits or finishes, retaining a granted current Guild turn', () => {
let g = movement('e', true);
assert.equal(g.players[1].elites!.reserves, 5);
assert.equal(g.decision?.kind, 'guildTiming');
@@ -324,7 +324,12 @@ void test('Genuine Advanced setup permits Sapho first after Guild finishes, reta
take: false,
});
assert.equal(deferred.active, 'a');
- reject(deferred, 'e', action(deferred, 'first'));
+ const first = applyAction(deferred, 'e', action(deferred, 'first'));
+ assert.deepEqual(first.movementRemaining, ['e', 'a', 'g']);
+ assert.equal(first.active, null);
+ assert.equal(first.decision?.kind, 'guildTiming');
+ assert.equal(first.guildTimingGranted, deferred.guildTimingGranted);
+ assert.equal(first.guildTimingLocked, deferred.guildTimingLocked);
g = applyAction(g, 'g', { type: 'decision', take: true });
assert.equal(g.active, 'g');
@@ -431,7 +436,7 @@ void test('Advanced Guild remains before Sapho last across repeated deferrals an
reject(g, 'e', action(g, 'last'));
g = applyAction(g, 'g', { type: 'decision', take: false });
assert.equal(g.active, 'a');
- reject(g, 'e', action(g, 'first'));
+ assert.ok(viewGame(g, 'e').saphoOptions.some(option => option.scope === 'movement' && option.mode === 'first'));
g = applyAction(g, 'e', action(g, 'last'));
assert.deepEqual(g.movementRemaining, ['a', 'g', 'e']);
assert.equal(
diff --git a/tests/sapho-guild-first-engine.test.ts b/tests/sapho-guild-first-engine.test.ts
new file mode 100644
index 00000000..58603d8b
--- /dev/null
+++ b/tests/sapho-guild-first-engine.test.ts
@@ -0,0 +1,147 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { viewGame } from '../game/engine';
+import { botActions } from '../game/bots';
+import {
+ actSaphoGuildFirst, advanceSaphoGuildFirst, completeSaphoGuildFirst, createSaphoGuildFirstFixture,
+ prepareSaphoGuildTake, saphoGuildFirstAction, saphoGuildFirstClean,
+ saphoGuildFirstPlayer as player, type SaphoGuildFirstFixture,
+} from './fixture-sapho-guild-first';
+
+function source(f: SaphoGuildFirstFixture) {
+ assert.equal(f.game.advanced, true);
+ assert.equal(f.beforeStartingDeal.deck[1].id, f.counter);
+ assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
+ assert.ok(player(f.afterSetup, f.canceler).hand.some(card => card.id === f.counter));
+ assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
+ assert.ok(player(f.game).hand.some(card => card.id === f.card));
+ assert.equal(f.beforeGuildWait.decision?.kind, 'guildTiming');
+ assert.ok(f.waits.every(wait => wait.step.actor === f.guild && wait.step.action.take === false));
+ assert.equal(f.game.decision, null);
+ assert.notEqual(f.game.active, f.guild);
+ assert.notEqual(f.game.active, f.owner);
+ assert.equal(f.game.guildTimingLocked, false);
+ assert.ok(f.game.movementRemaining!.includes(f.guild));
+ for (const actor of f.game.movementRemaining!) {
+ assert.equal(player(f.game, actor).moved, 0);
+ assert.equal(player(f.game, actor).shipped, false);
+ }
+ assert.equal(f.sourceActionCount, f.actions.length);
+}
+function settled(f: SaphoGuildFirstFixture, result: ReturnType) {
+ assert.equal(result.first.scope, 'movement');
+ assert.equal(result.first.mode, 'first');
+ assert.equal(result.reoffered.decision?.kind, 'guildTiming');
+ if (result.reoffered.decision?.kind === 'guildTiming') assert.equal(result.reoffered.decision.next, f.owner);
+ assert.deepEqual(result.reoffered.movementRemaining,
+ [f.owner, ...f.game.movementRemaining!.filter(actor => actor !== f.owner)]);
+ assert.deepEqual(result.reoffered.order, f.game.order);
+ assert.equal(result.reoffered.saphoMovementLast ?? null, null);
+ assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1);
+ assert.deepEqual(result.game.order, f.game.order);
+ assert.deepEqual(result.game.movementRemaining, []);
+ const owner = result.turns.find(turn => turn.actor === f.owner)!, guild = result.turns.find(turn => turn.actor === f.guild)!;
+ assert.ok(owner.shipment && guild.shipment);
+ assert.equal(player(owner.before).spice - player(owner.afterShipment).spice, 2);
+ assert.equal(player(owner.before).reserves - player(owner.afterShipment).reserves, 1);
+ assert.equal(player(guild.before, f.guild).spice - player(guild.afterShipment, f.guild).spice, 1);
+ assert.equal(player(guild.before, f.guild).reserves - player(guild.afterShipment, f.guild).reserves, 1);
+ assert.equal(player(result.game).forces['imperial_basin:9'], 1);
+ assert.equal(player(result.game, f.guild).forces['carthag:11'], 1);
+ assert.equal(result.actions.filter(step => step.actor === f.owner && step.action.type === 'ship').length, 1);
+ assert.equal(result.actions.filter(step => step.actor === f.guild && step.action.type === 'ship').length, 1);
+ assert.equal(result.actions.filter(step => step.action.type === 'endMovement').length, result.turns.length);
+}
+
+void test('genuine paid Sapho first reopens Guild TAKE before its holder without changing the original non-Guild suffix', () => {
+ const f = createSaphoGuildFirstFixture();
+ source(f);
+ const result = completeSaphoGuildFirst(f, true);
+ settled(f, result);
+ assert.equal(result.declaredGuild.response?.kind, 'guildTiming');
+ assert.ok(viewGame(result.declaredGuild, f.canceler).responseControls!.cancelCards.includes(f.counter));
+ assert.ok(result.actions.some(step => step.actor === f.canceler && step.action.type === 'passResponse'));
+ assert.equal(result.afterGuildChoice.active, f.guild);
+ assert.equal(result.afterGuildChoice.guildTimingGranted, true);
+ assert.deepEqual(result.turns.map(turn => turn.actor), ['guild', 'emperor', 'richese', 'atreides']);
+});
+
+void test('genuine Guild WAIT after Sapho first lets the holder complete its paid turn before the original following actors', () => {
+ const f = createSaphoGuildFirstFixture();
+ source(f);
+ const result = completeSaphoGuildFirst(f, false);
+ settled(f, result);
+ assert.equal(result.afterGuildChoice.active, f.owner);
+ assert.deepEqual(result.turns.map(turn => turn.actor), ['emperor', 'richese', 'atreides', 'guild']);
+ const ownTurn = result.turns[0];
+ assert.equal(player(ownTurn.afterShipment, f.guild).spice - player(ownTurn.before, f.guild).spice, 2,
+ 'The originally unspent Guild receives the actual ordinary opposing shipment fee once');
+});
+
+void test('a genuinely completed paid native prefix remains finished when Sapho first reorders the later unstarted suffix', () => {
+ const f = createSaphoGuildFirstFixture({ completedFirst: true });
+ source(f);
+ assert.ok(f.prefix);
+ assert.equal(player(f.prefix!.before, 'richese').spice - player(f.prefix!.afterShipment, 'richese').spice, 2);
+ assert.equal(player(f.game, 'richese').shipped, true);
+ assert.equal(player(f.game, 'richese').forces['polar_sink:0'], 1);
+ assert.equal(f.game.movementRemaining!.includes('richese'), false);
+ const result = completeSaphoGuildFirst(f, false);
+ settled(f, result);
+ assert.deepEqual(result.turns.map(turn => turn.actor), ['emperor', 'atreides', 'guild']);
+ assert.equal(result.actions.some(step => step.actor === 'richese' &&
+ ['ship', 'move', 'endMovement'].includes(step.action.type)), false,
+ 'A completed player may still allow another faction response, without another movement turn');
+ assert.equal(player(result.game, 'richese').forces['polar_sink:0'], 1);
+ assert.equal(player(result.game, 'richese').shipped, true);
+});
+
+void test('real granted Guild TAKE protects its current turn while an actually canceled TAKE stays locked under Sapho first', () => {
+ const f = createSaphoGuildFirstFixture();
+ const granted = prepareSaphoGuildTake(f);
+ assert.equal(granted.game.active, f.guild);
+ assert.equal(granted.game.guildTimingGranted, true);
+ assert.equal(viewGame(granted.game, f.owner).saphoOptions.some(option => option.scope === 'movement' && option.mode === 'first'), false);
+ assert.throws(() => actSaphoGuildFirst(granted.game, f.owner, saphoGuildFirstAction(f.game)), /Sapho|opportunity|Guild|expired/i);
+ assert.ok(player(granted.game).hand.some(card => card.id === f.card));
+ let finished = actSaphoGuildFirst(granted.game, f.guild,
+ { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
+ finished = advanceSaphoGuildFirst(finished, saphoGuildFirstClean);
+ finished = actSaphoGuildFirst(finished, f.guild, { type: 'endMovement' });
+ finished = advanceSaphoGuildFirst(finished, saphoGuildFirstClean);
+ finished = actSaphoGuildFirst(finished, f.owner, saphoGuildFirstAction(finished));
+ assert.equal(finished.active, f.owner);
+ assert.equal(finished.decision, null);
+ assert.equal(finished.movementRemaining!.includes(f.guild), false);
+ assert.equal(player(finished, f.guild).forces['carthag:11'], 1);
+ const canceled = prepareSaphoGuildTake(f, true);
+ assert.equal(canceled.game.guildTimingLocked, true);
+ assert.equal(canceled.game.discard.filter(card => card.id === f.counter).length, 1);
+ const first = actSaphoGuildFirst(canceled.game, f.owner, saphoGuildFirstAction(canceled.game));
+ assert.equal(first.active, f.owner);
+ assert.equal(first.decision, null);
+ assert.equal(first.guildTimingLocked, true);
+ assert.ok(first.movementRemaining!.includes(f.guild), 'Cancellation keeps the native in-order turn without reviving flexible timing');
+});
+
+void test('all four minimal policies propose legal Sapho-first and renewed native Guild timing decisions from original views', () => {
+ const f = createSaphoGuildFirstFixture();
+ for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const holder = viewGame(f.game, f.owner);
+ holder.players.find(player => player.id === f.owner)!.bot = difficulty;
+ const first = botActions(holder).find(action => action.type === 'card' && action.card === f.card && action.scope === 'movement' && action.mode === 'first');
+ assert.ok(first, `${difficulty} retains the actual meaningful first choice`);
+ const renewed = actSaphoGuildFirst(structuredClone(f.game), f.owner, first);
+ assert.equal(renewed.decision?.kind, 'guildTiming');
+ const guild = viewGame(renewed, f.guild);
+ guild.players.find(player => player.id === f.guild)!.bot = difficulty;
+ const choice = botActions(guild).find(action => action.type === 'decision' && typeof action.take === 'boolean');
+ assert.ok(choice, `${difficulty} answers the actual renewed native timing control`);
+ const declared = actSaphoGuildFirst(renewed, f.guild, choice);
+ const continued = advanceSaphoGuildFirst(declared, saphoGuildFirstClean);
+ assert.equal(continued.active, choice.take ? f.guild : f.owner);
+ assert.deepEqual(continued.order, f.game.order);
+ assert.equal(player(continued).shipped, false);
+ assert.equal(player(continued).moved, 0);
+ }
+});