diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx index 93406d15..6caa3df1 100644 --- a/components/juice-of-sapho.tsx +++ b/components/juice-of-sapho.tsx @@ -11,7 +11,7 @@ function unavailableReason(game: GameView) { if (game.status !== 'playing') return 'Order changes are available during play.'; if (game.phase === 5) - return 'Movement order supports clean combined-turn boundaries and queuing behind a started active turn. In Basic, your own started turn may be paused at a clean boundary and resumed last, with spent actions retained. Finish pending interactions and preparations first. The Advanced Guild retains its next timing choice; an already granted current Guild turn cannot be displaced, and last remains after the Guild. A Karama-locked privilege stays locked. Completed turns and actual shipment and movement counters stay unchanged. Already-held positions and committed cards remain unavailable.'; + return 'Movement order supports clean combined-turn boundaries and queuing behind a started active turn. Your own started turn may be paused at a clean boundary and resumed last, with spent actions retained. Finish pending interactions and preparations first. The Advanced Guild retains its next timing choice; another player cannot displace an already granted Guild turn, and last remains after the Guild. A Karama-locked privilege stays locked. Completed turns and actual shipment and movement counters stay unchanged. Already-held positions and committed cards remain unavailable.'; if (game.truthtrance || game.response || game.phaseOpening || (game.decision && !(game.phase === 8 && game.decision.kind === 'moritaniExtortion'))) return 'Finish the current interaction before changing turn order.'; diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f5c6f72a..6b9dcc89 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,13 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Advanced Sapho own-turn suspension — 27 October. Prototyped / Partial.** The +existing clean started Shipment and Movement turn pause now extends to Advanced, +retaining already-paid actions and the Guild's original choice. Seven related +files pass 68 focused cases, including native Advanced action and separately +controlled Guild paths; the production build passes. Wider combinations and +deployed acceptance remain open. [Scope](JUICE_OF_SAPHO_RUNTIME.md#own-started-advanced-movement-turn--27-october-2026). + **Salusa low free-revival prohibition — 27 October. Prototyped / Partial.** Apply the printed low penalty to ordinary force pricing, Ghola, Emperor special Karama and the Tleilaxu Ambassador, preserving normal-force/leader choices, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 7f76e5bf..b6445df2 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,14 @@ # Dune implementation status +## 27 October 2026 — Advanced Sapho own-turn suspension + +A player can provisionally pause its own already-started Advanced Shipment and +Movement turn at a clean boundary and resume last without repeating paid +actions. The Guild retains its native timing choice and can voluntarily pause +its own turn. The seven-file affected union passes 68 cases and the production +build passes. Wider combinations and deployed acceptance remain open. +[Scope](JUICE_OF_SAPHO_RUNTIME.md#own-started-advanced-movement-turn--27-october-2026). + ## 27 October 2026 — Salusa Low free Sardaukar revival Advanced Homeworld play applies the Low Salusa penalty across normal free diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md index 27906c87..390a9393 100644 --- a/docs/JUICE_OF_SAPHO_RUNTIME.md +++ b/docs/JUICE_OF_SAPHO_RUNTIME.md @@ -1,5 +1,32 @@ # Juice of Sapho: finite ordering implementation +## Own started Advanced movement turn — 27 October 2026 + +**Prototyped / Partial.** The clean-boundary own-turn Last option now extends +the existing Basic policy to Advanced Shipment and Movement. The holder may +voluntarily suspend its own already-started combined turn and resume that same +opportunity after the remaining turns. An already completed shipment or move, +its cost, physical pieces and used movement allowance stay in place; the +suspended turn is neither completed nor replayed. The card is unavailable if +the holder is already last or the action cannot meaningfully change the queue. +Pending responses and preparations still have to finish before this boundary. + +The actual Advanced Guild retains its native timing decisions and any existing +Karama lock. Last is protected behind that Guild opportunity. A Guild player +may voluntarily suspend its own started turn; another holder cannot displace an +already granted current Guild turn and may instead reorder the later unstarted +tail. Physical storm order and completed opportunities do not change. This +does not extend the rule to splitting Charity, Revival or unfinished nested +interactions. + +The inspected Sapho face permits first or last in a turn-ordered phase or +action, but does not state whether or how an already-started combined turn can +be suspended. This is a provisional online timing composition under the +standing choose-recommended direction, not a publisher clarification. It +supersedes earlier Advanced-only started-turn exclusions below. Broader +combinations, complete modes and browser/deployed acceptance remain open. +[Earlier Basic source boundary](JUICE_OF_SAPHO_SOURCE_UPDATE.md#basic-own-turn-last--27-october-2026). + ## Truthtrance order in Richese cache auctions — 27 October 2026 **Prototyped / Partial.** The possession-independent public response following @@ -150,11 +177,12 @@ This is visibly provisional online timing, not a new publisher clarification. An unstarted holder at a clean boundary after another player's actual shipment or movement may go first among the tail, or last including after Guild. It -cannot split its own begun turn, displace an active Guild, replay a completed -turn, reset shipment/movement counters or intervene during unresolved card, -arrival, payment or movement preparations. Existing last protection and no-op -rules remain. Advanced Guild receives its native next-boundary choice; moving -first in the tail does not consume or suppress that privilege. +cannot displace an active Guild, replay a completed turn, reset shipment/movement +counters or intervene during unresolved card, arrival, payment or movement +preparations. Existing last protection and no-op rules remain. Advanced Guild +receives its native next-boundary choice; moving first in the tail does not +consume or suppress that privilege. The separate later own-started policy above +permits voluntary suspension by the current holder. The pure [movement-order quote](../game/sapho-movement-order.ts) binds the original phase event, current actor and whole remaining public order in the @@ -166,10 +194,11 @@ physical storm order remain. Semuta's new optional continuation field keeps that same current actor through the original discard claim/pass; absent-field legacy continuation/signature bytes retain their original path. -This supersedes only the earlier blanket partly-completed-turn exclusion for -an **unstarted different holder** behind the current actor. No split-turn mode -or new ordered phase is invented. Wider combinations, full modes and final -assurance/deployed acceptance remain open. +This historical scope superseded the earlier blanket partly-completed-turn +exclusion for an **unstarted different holder** behind the current actor. The +later own-started Basic and Advanced policies above add a separate voluntary +clean-boundary suspension; no new ordered phase is invented. Wider combinations, +full modes and final assurance/deployed acceptance remain open. Frozen **11 files, 133/133**, types/lint/build and eleven actual engine/ GameTable SSR/isolated button-callback journeys pass on unchanged source. New diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 5a6ef4b1..4a590362 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Juice of Sapho and an already-started Advanced movement turn — 27 October 2026 + +Extend the existing provisional clean own-turn Last policy to Advanced +Shipment and Movement. The holder may suspend its own started turn and resume +the same opportunity last without repeating already spent shipment, movement, +costs or pieces. Pending preparations remain excluded. Guild retains its +native timing and locked privilege; the holder's protected last position stays +after Guild. A Guild holder may voluntarily suspend its own turn, while a +different holder cannot displace a granted current Guild turn. The printed +card does not resolve partial-turn suspension, so this is a provisional online +interpretation rather than an official clarification. It supersedes earlier +Advanced own-started restrictions, not the distinct Charity or Revival +policies. [Scope](JUICE_OF_SAPHO_RUNTIME.md#own-started-advanced-movement-turn--27-october-2026). + ## Salusa Low and free Sardaukar revival — 27 October 2026 GF9's [Ecaz and Moritani rulebook, pp. 9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10) @@ -1391,8 +1405,9 @@ retained-current interpretation: an unstarted different Sapho holder may change first/last among later unstarted turns at a clean boundary. The started current actor finishes normally with original counters/resources; the tail then follows the changed priority. Guild keeps its native next-boundary choice and last -protection remains after Guild. Split-own-turn, unresolved preparations and -new ordered phases stay unsupported. Queued timing is visibly provisional, +protection remains after Guild. The later own-started decision above permits a +holder's voluntary clean-boundary suspension; unresolved preparations and +other ordered phases stay separate. Queued timing is visibly provisional, not a publisher clarification or a separately answered numerical user ruling. [Source, native continuation and limits](JUICE_OF_SAPHO_RUNTIME.md#order-after-a-started-combined-turn--25-october-2026). @@ -2747,7 +2762,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Harass / Stone Burner | The earlier keep-gated versus compare-after-withdrawal question is unanswered. Standing choose-recommended provisionally compares counters left after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without hidden custody. Clean Ix priority follows its separate explicitly provisional contract; new physical commitments do not remove other profile/module limits. This is implementation interpretation, not a new publisher ruling or user answer. | [Withdrawal](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) · [Clean Ix priority](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) | | Stone mixed-force allocation timing | The older timing question remains unanswered by a source. Under 14 October choose-recommended, new original outcome-ambiguous Stone/Mirror battles provisionally commit each ambiguous side's undialed physical total after revealed modes/actual withdrawal and decisive Traitor/explosion branches. Storm-order owners choose privately; every original required value stays sealed until all commit. The eventual winner retains all legal typed casualty choices with the count. Ordinary/invariant and unstamped legacy frames keep their existing path. Publisher physical-count text and supplied Advanced physical12/logical10 casualty timing provide the numerical rules, not this online commitment convention. | [Source, versioned procedure and bounded proof](STONE_BURNER_ENGINE_AUDIT.md#outcome-changing-physical-totals--18-october-2026) | | Recruits late accounting and rates | The earlier one/two-further-free question remains unanswered. The prototype preserves paid spice, deducts recorded free usage from the doubled current rate and all returns from the total cap: two-free/one-paid Atreides can take two more free. Clean classic Homeworlds now provisionally recheck native thresholds on separate accepted requests, retaining completed prices/classifications; this is not a numerical user ruling or newly found timing FAQ. Unknown ledgers, pending repricing, later Fremen grants and same-turn replay remain guarded. | [Source alternatives](RECRUITS_RULES.md), [current native runtime](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) | -| Sapho remaining timing | Selected queued timing retains another actor's started combined turn and reorders only later unstarted turns; Guild retains native next-boundary choice. Splitting the holder's own turn and other ordered phases remain open. Queued timing, cyclic duration and shared final battle opportunity are provisional protocols, not publisher rulings. Old battles retain early timing. | [Queued timing](JUICE_OF_SAPHO_RUNTIME.md#order-after-a-started-combined-turn--25-october-2026), [source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [aggressor contract](SAPHO_AGGRESSOR.md) | +| Sapho remaining timing | Queued timing retains another actor's started combined turn and reorders only later unstarted turns; Guild retains native next-boundary choice. The holder's own Basic or Advanced combined turn can be provisionally suspended at a clean boundary and resumed last. Other ordered-phase suspensions remain open. Queued timing, cyclic duration and shared final battle opportunity are provisional protocols, not publisher rulings. Old battles retain early timing. | [Own-turn scope](JUICE_OF_SAPHO_RUNTIME.md#own-started-advanced-movement-turn--27-october-2026), [queued timing](JUICE_OF_SAPHO_RUNTIME.md#order-after-a-started-combined-turn--25-october-2026), [aggressor contract](SAPHO_AGGRESSOR.md) | | Semuta Drug | Full-hand exchange is user-approved; neutral all-seat acknowledgements remain the explicit application policy. Completed Ghola may offer its physical card before the original conditional native Tleilaxu one-spice income, including self-income; claim/pass preserves returned pieces and normal quotas, then resumes the same allow/cancel response once. This admits no unrelated response or market parent. Other producers, nested continuation coverage and full-game acceptance remain unfinished. | [Source update and selected exchange](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) | | Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler modifiers, Mentat empty-hand fallback, late/mirror/numeric Diplomat defenses and simultaneous optional timing remain source gaps. Owned multi-pile Smuggler/Sandmaster allocation and printed alternate/hybrid defense roles now have connected, explicitly interpreted paths. The bounded Diplomat retreat prototype chooses printed/copied strength and surviving-loser retreat before casualties; this is not an official priority clarification. | [Material boundaries and retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation), [Defense limits](DIPLOMAT_DEFENSE.md#evidence-and-remaining-scope) | | Smuggler collection strength | The older source check/question produced no numerical answer. Under the later choose-recommended instruction, provisionally cap by copied/printed disc plus declared skill bonuses and KH2, retaining reveal piles and delayed survival settlement. Actual Artillery zeroing voids collection; the base Stunner remains a projectile. Sole own Traitor bypasses weapons, so it retains collection. Printed bounty does not change; this is not a publisher ruling. Other exceptional compositions remain separate. | [Source and explicit policy](LEADER_SKILLS_RULES.md#smuggler-modifier-policy--17-october-2026), [runtime contract](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) | diff --git a/game/engine.ts b/game/engine.ts index 5041948e..caa65d5d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -27130,8 +27130,8 @@ function saphoMovementOrderContext(g: Game): SaphoMovementOrderContext | null { return {queue,started:queue.remaining.filter(id=>saphoMovementStarted(g,getPlayer(g,id))), guild:guild ? {player:guild.id,granted:!!g.guildTimingGranted,locked:!!g.guildTimingLocked} : null, // These shared preparations are not yet owned/resumed across another - // player's turn. Only a clean Basic physical shipment/move may suspend. - allowStartedLast: !g.advanced && !g.karamaShipping && !g.ornithopter && !g.hajr.includes(active.id) && + // player's turn. Only a clean physical shipment/move may suspend. + allowStartedLast: !g.karamaShipping && !g.ornithopter && !g.hajr.includes(active.id) && !(active.faction === 'choam' && g.choamMovement?.turn === g.turn) && !g.pendingTreacheryDiscard && !g.pendingKull && !g.pendingHomeworldShipment && !g.pendingGuildTransport && !g.pendingArrivalOverlap && !g.pendingArrivalReaction?.length && !g.pendingAdvisorMatching && diff --git a/game/reference.ts b/game/reference.ts index 08f049c1..342f98cb 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -499,7 +499,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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, 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.', - 'At a clean boundary after another player has begun shipment and movement, an unstarted holder may provisionally reorder only later unstarted turns. First means first after that current actor finishes; last remains after Guild. In Basic, the holder may also provisionally pause its own started turn and resume last, retaining already spent shipment and movement. Pending card, arrival, payment and shared movement preparations must finish first. Advanced Guild retains its next-boundary timing choice.', + 'At a clean boundary after another player has begun shipment and movement, an unstarted holder may provisionally reorder only later unstarted turns. First means first after that current actor finishes; last remains after Guild. In Basic or Advanced, the holder may also provisionally pause its own started turn and resume last, retaining already spent shipment and movement. Pending card, arrival, payment and shared movement preparations must finish first. Advanced Guild retains its next-boundary timing choice; Guild may voluntarily pause its own granted turn.', '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.', 'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.', @@ -509,7 +509,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Finish pending shipment, movement and card preparations and decisions before changing order. The exact Extortion payer window is a supported exception for its own order only. Your hand panel lists available choices. Reserved cards and positions you already hold cannot be selected.', 'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.', 'In the explicitly opted-in Semuta preview, clean Once Around, movement and ordered Charity/Revival discards pause after their queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; an aggressor discard pauses after changing ordinary tie priority. Charity/Revival retain their current participant, claims or requests and allowances through Semuta claim/pass. Fresh late continuations also bind revealed choices and the public final revision; old continuation bytes remain unchanged. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unresolved battle continues once. Direct combined late claim/capture proof remains open.', - 'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure, cyclic lot duration, queued movement and the Basic own-turn pause are provisional conventions. Other phase ordering and Advanced own-turn suspension remain unfinished. Silent bids retain simultaneous timing and storm tie order.', + 'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure, cyclic lot duration, queued movement and the own-turn pause are provisional conventions. Other phase ordering remains unfinished. Silent bids retain simultaneous timing and storm tie order.', ], example: 'Atreides bids 2. Before taking its bid, the Emperor uses Sapho to go last. Richese bids 3, then the Emperor must bid at least 4 to win. Atreides does not receive another bid.', @@ -524,7 +524,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Finite Once Around, current-lot cyclic bidding, clean/queued movement and ordered Advanced Charity/Revival, remaining battle-choice and late aggressor modes are connected. Queued timing retains the started actor; Charity/Revival keep native claims or requests/allowances and separate physical order through their Semuta disposal. Tail timing is visibly provisional. Other phases and split-own-turn mode remain unfinished.', + 'Finite Once Around, current-lot cyclic bidding, clean/queued movement and ordered Advanced Charity/Revival, remaining battle-choice and late aggressor modes are connected. Queued timing retains another started actor; a clean own started movement turn may provisionally resume last in either ruleset. Charity/Revival keep native claims or requests/allowances and separate physical order through their Semuta disposal. Tail timing is visibly provisional. Other phases remain unfinished.', }, { area: 'Player controls', diff --git a/game/sapho-movement-order.ts b/game/sapho-movement-order.ts index 2761c659..a47c09d2 100644 --- a/game/sapho-movement-order.ts +++ b/game/sapho-movement-order.ts @@ -8,7 +8,7 @@ export type SaphoMovementOrderContext = { started: readonly string[]; /** Present only for Advanced Guild timing. */ guild: { player: string; granted: boolean; locked: boolean } | null; - /** Explicit Basic clean-boundary prototype; the engine owns all action state. */ + /** Explicit clean-boundary prototype; the engine owns all action state. */ allowStartedLast?: boolean; }; export class SaphoMovementOrderError extends Error { @@ -78,7 +78,6 @@ export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, re 'Only a pending participant may change its movement position.'); if (context.started.includes(request.player) && request.player === queue.current && context.allowStartedLast === true) { - requireMovement(context.guild === null, 'The started-turn Sapho preview supports Basic timing only.'); requireMovement(request.mode === 'last', 'An already-started turn can only be moved to last.'); requireMovement(queue.remaining.length > 1, 'This participant is already last.'); // Suspend this same opportunity; never synthesize a completed turn or a diff --git a/tests/juice-of-sapho-engine.test.ts b/tests/juice-of-sapho-engine.test.ts index 0180b3a9..bb0aea96 100644 --- a/tests/juice-of-sapho-engine.test.ts +++ b/tests/juice-of-sapho-engine.test.ts @@ -545,7 +545,7 @@ void test('early Guild timing makes the previous penultimate faction actually la assert.deepEqual(acted, ['g', 'a', 'e']); }); -void test('after Guild shipment another holder may queue last while Guild cannot split its own started turn', () => { +void test('after Guild shipment another holder may queue last or Guild may pause its own started turn', () => { for (const holder of ['a', 'g']) { let g = movement(holder, true); g = applyAction(g, 'g', { type: 'decision', take: true }); @@ -565,7 +565,30 @@ void test('after Guild shipment another holder may queue last while Guild cannot ); const options = viewGame(g, holder).saphoOptions; if (holder === 'g') { - assert.deepEqual(options, []); + const option = options.find(option => option.scope === 'movement' && option.mode === 'last'); + assert.ok(option && option.scope === 'movement' && option.resumeOwn); + const before = reload(g); + g = applyAction(reload(g), holder, { type: 'card', card, + scope: option.scope, mode: option.mode, event: option.event }); + assert.equal(g.active, 'a'); + assert.deepEqual(g.movementRemaining, ['a', 'e', 'g']); + assert.equal(g.players[2].shipped, true); + assert.equal(g.players[2].reserves, before.players[2].reserves); + assert.equal(g.players[2].spice, before.players[2].spice); + assert.equal(g.guildTimingGranted, before.guildTimingGranted); + assert.equal(g.discard.filter(c => c.id === card).length, 1); + const resumed: string[] = []; + while (g.phase === 5) { + assert.equal(g.decision, null); + resumed.push(g.active!); + if (g.active === 'g') { + assert.equal(g.players[2].shipped, true); + reject(g, 'g', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1 }); + } + g = end(g); + } + assert.deepEqual(resumed, ['a', 'e', 'g']); + assert.equal(g.saphoMovementLast, null); continue; } const option = options.find(option => option.scope === 'movement' && option.mode === 'last'); diff --git a/tests/sapho-advanced-started-movement.test.ts b/tests/sapho-advanced-started-movement.test.ts new file mode 100644 index 00000000..d6fd7a64 --- /dev/null +++ b/tests/sapho-advanced-started-movement.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { TERRITORIES } from '../game/board'; +import { + actSaphoStartedMovement as act, + advanceSaphoStartedMovement as advance, + createSaphoStartedMovementFixture as fixture, + saphoStartedMovementAction as saphoAction, + saphoStartedMovementClean as clean, + saphoStartedMovementInventory as inventory, + saphoStartedMovementPlayer as player, +} from './fixture-sapho-started-movement'; + +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +/** Original Advanced setup and paid Sapho purchase, followed by actual prior + * turns and an Emperor shipment. Inherited fixture selects only unopened + * starting deal/spice positions and scalar lotteries; no played state is set. */ +function source() { + const f = fixture({ advanced: true }); + let game = advance(structuredClone(f.game), state => + state.phase === 5 && clean(state) && state.active === f.owner); + assert.ok(game.movementRemaining?.some(id => id !== f.owner)); + assert.equal(player(game, f.owner).shipped, false); + const prior = saphoAction(game, 'last', f.owner); + const beforeShipment = structuredClone(game); + const declared = act(game, f.owner, { + type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0, + }); + game = advance(declared, clean); + assert.equal(game.active, f.owner); + assert.equal(player(game, f.owner).shipped, true); + assert.equal(player(game, f.owner).moved, 0); + assert.equal(player(beforeShipment, f.owner).reserves - player(game, f.owner).reserves, 1); + assert.equal(player(beforeShipment, f.owner).spice - player(game, f.owner).spice, 2); + assert.equal(player(game, f.owner).forces['polar_sink:0'], 1); + assert.deepEqual(inventory(game), f.originalInventory); + return { f, game, prior, declared }; +} + +void test('Advanced Sapho suspends a genuinely paid shipment and resumes the same owner after Guild', () => { + const { f, game: original, prior } = source(); + const option = viewGame(original, f.owner).saphoOptions.find(row => row.scope === 'movement' && row.mode === 'last'); + assert.ok(option && option.scope === 'movement' && option.resumeOwn); + assert.notEqual(option.event, prior.event); + reject(original, f.owner, prior); + const before = structuredClone(player(original, f.owner)); + let game = advance(act(original, f.owner, saphoAction(original, 'last', f.owner)), clean); + assert.equal(game.active, f.guild); + assert.equal(game.saphoMovementLast?.player, f.owner); + assert.equal(player(game, f.owner).spice, before.spice); + assert.equal(player(game, f.owner).reserves, before.reserves); + assert.deepEqual(player(game, f.owner).forces, before.forces); + assert.equal(player(game, f.owner).shipped, true); + assert.equal(player(game, f.owner).moved, 0); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(inventory(game), f.originalInventory); + + game = advance(game, state => state.phase === 5 && clean(state) && state.active === f.owner); + assert.equal(player(game, f.owner).shipped, true); + assert.equal(player(game, f.owner).moved, 0); + reject(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }); + game = act(game, f.owner, { type: 'endMovement' }); + assert.notEqual(game.phase, 5); + assert.deepEqual(inventory(game), f.originalInventory); +}); + +void test('Advanced own-last waits for the real shipment response before allowing suspension', () => { + const { f, prior, declared } = source(); + assert.ok(!clean(declared)); + assert.equal(viewGame(declared, f.owner).saphoOptions.some(row => row.scope === 'movement'), false); + reject(declared, f.owner, prior); + assert.ok(player(declared, f.owner).hand.some(card => card.id === f.card)); + assert.deepEqual(inventory(declared), f.originalInventory); +}); + +void test('Advanced own-last preserves a completed ground movement without granting another', () => { + const { f, game: original } = source(); + let moved: Game | undefined; + let destination: string | undefined; + for (const place of TERRITORIES) { + for (const sector of place.sectors) { + try { + moved = act(original, f.owner, { type: 'move', from: 'polar_sink:0', territory: place.id, + sector, amount: 1, elite: 0 }); + destination = `${place.id}:${sector}`; + break; + } catch { /* Find a real legal destination using the native validator. */ } + } + if (moved) break; + } + assert.ok(moved && destination); + let game = advance(moved, clean); + assert.equal(player(game, f.owner).moved, 1); + game = advance(act(game, f.owner, saphoAction(game, 'last', f.owner)), clean); + game = advance(game, state => state.phase === 5 && clean(state) && state.active === f.owner); + assert.equal(player(game, f.owner).moved, 1); + assert.equal(player(game, f.owner).forces[destination], 1); + reject(game, f.owner, { type: 'move', from: destination, territory: 'polar_sink', sector: 0, amount: 1, elite: 0 }); + game = act(game, f.owner, { type: 'endMovement' }); + assert.notEqual(game.phase, 5); + assert.deepEqual(inventory(game), f.originalInventory); +}); + +void test('all four minimal profiles can select an actual Advanced own-last opportunity', () => { + const { f, game } = source(); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, f.owner); + view.players.find(seat => seat.id === f.owner)!.bot = difficulty; + const action = botActions(view).find(candidate => candidate.type === 'card' && candidate.card === f.card && + candidate.scope === 'movement' && candidate.mode === 'last'); + assert.ok(action, `${difficulty} proposes the native suspension`); + const next = advance(act(structuredClone(game), f.owner, action), clean); + assert.notEqual(next.active, f.owner); + assert.equal(player(next, f.owner).shipped, true); + assert.deepEqual(inventory(next), f.originalInventory); + } +}); + +for (const take of [false, true]) void test( + `Advanced Guild ${take ? 'TAKE' : 'WAIT'} after an early own-last suspension preserves the protected tail`, () => { + const f = fixture({ advanced: true }); + const game = structuredClone(f.game); + // Controlled supplemental custody: the original purchased Sapho changes + // hands to the actual paid current actor. No card or resource is minted. + const owner = player(game, f.owner), current = player(game, f.current); + const index = owner.hand.findIndex(card => card.id === f.card); + assert.ok(index >= 0); + current.hand.push(...owner.hand.splice(index, 1)); + assert.deepEqual(inventory(game), f.originalInventory); + const before = structuredClone(current); + const option = viewGame(game, f.current).saphoOptions.find(row => row.scope === 'movement' && row.mode === 'last'); + assert.ok(option && option.scope === 'movement' && option.resumeOwn); + const suspended = act(game, f.current, { type: 'card', card: f.card, scope: 'movement', mode: 'last', event: option.event }); + assert.equal(suspended.saphoMovementLast?.player, f.current); + assert.equal(player(suspended, f.current).shipped, true); + assert.equal(player(suspended, f.current).spice, before.spice); + assert.equal(suspended.decision?.kind, 'guildTiming'); + const declared = act(suspended, f.guild, { type: 'decision', take }); + const after = advance(declared, clean); + if (take) assert.equal(after.active, f.guild); + else assert.notEqual(after.active, f.guild); + assert.equal(after.movementRemaining?.at(-1), f.current); + const returned = advance(after, state => state.phase === 5 && clean(state) && state.active === f.current); + assert.equal(player(returned, f.current).shipped, true); + assert.equal(player(returned, f.current).spice, before.spice); + reject(returned, f.current, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }); + const done = act(returned, f.current, { type: 'endMovement' }); + assert.notEqual(done.phase, 5); + assert.deepEqual(inventory(done), f.originalInventory); + }, +); diff --git a/tests/sapho-movement-order.test.ts b/tests/sapho-movement-order.test.ts index c2b6495e..a85beada 100644 --- a/tests/sapho-movement-order.test.ts +++ b/tests/sapho-movement-order.test.ts @@ -115,6 +115,53 @@ void test('ordinary Guild timing flags and Basic null timing retain their own la assert.equal(reorder(locked, 'holder', 'first').next.current, 'holder'); }); +void test('an Advanced started actor can suspend its own turn to last without consuming Guild timing', () => { + for (const guildState of [ + { player: 'guild', granted: false, locked: false }, + { player: 'guild', granted: true, locked: false }, + { player: 'guild', granted: false, locked: true }, + ]) { + const input = { ...context(true), allowStartedLast: true, guild: guildState }; + const before = structuredClone(input); + const quote = reorder(input, 'active', 'last'); + assert.equal(quote.suspended, 'active'); + assert.equal(quote.afterCurrent, null); + assert.deepEqual(quote.next.remaining, ['later', 'holder', 'guild', 'active']); + assert.deepEqual(quote.next.completed, ['done']); + assert.equal(quote.next.current, 'later'); + assert.equal(quote.next.currentStarted, false); + assert.equal(quote.next.protectedLast, 'active'); + assert.equal(quote.next.event, input.queue.event); + assert.deepEqual(input, before); + reject(input, 'active', 'first'); + reject(input, 'active', 'last', input.queue.event); + } +}); + +void test('a started Guild can voluntarily move its granted turn last without creating another opportunity', () => { + const input = context(true); + input.queue = { ...input.queue, remaining: ['guild', 'active', 'later', 'holder'], current: 'guild' }; + input.started = ['done', 'guild']; + input.guild = { player: 'guild', granted: true, locked: false }; + input.allowStartedLast = true; + const before = structuredClone(input); + const quote = reorder(input, 'guild', 'last'); + assert.equal(quote.suspended, 'guild'); + assert.deepEqual(quote.next.remaining, ['active', 'later', 'holder', 'guild']); + assert.equal(quote.next.protectedLast, 'guild'); + assert.deepEqual(input, before); + + const alreadyLast: SaphoMovementOrderContext = { + ...input, + queue: { ...input.queue, remaining: ['guild'], completed: ['done', 'active', 'later', 'holder'] }, + }; + reject(alreadyLast, 'guild', 'last'); + const protectedLast: SaphoMovementOrderContext = { + ...input, queue: { ...input.queue, protectedLast: 'holder' }, + }; + reject(protectedLast, 'guild', 'last', 'movement:3'); +}); + void test('started event binds the base phase, active actor and complete public tail with encoded identities', () => { const input = context(true), event = saphoMovementOrderEvent(input); assert.equal(saphoMovementOrderEvent(structuredClone(input)), event); diff --git a/tests/sapho-started-movement-controls.test.tsx b/tests/sapho-started-movement-controls.test.tsx index 2843b43a..88195b58 100644 --- a/tests/sapho-started-movement-controls.test.tsx +++ b/tests/sapho-started-movement-controls.test.tsx @@ -120,7 +120,7 @@ void test('unavailable movement guidance covers clean and queued windows without const html = panel(game); assert.match(html, /clean combined-turn boundaries and queuing behind a started active turn/); assert.match(html, /Finish pending interactions and preparations first/); - assert.match(html, /your own started turn may be paused at a clean boundary and resumed last/); + assert.match(html, /your own started turn may be paused at a clean boundary and resumed last/i); assert.match(html, /last remains after the Guild/); assert.doesNotMatch(html, /Take your movement turn (first|last) and discard/); game.players.find(player => player.id === game.me)!.hand = []; diff --git a/tests/sapho-started-movement-engine.test.ts b/tests/sapho-started-movement-engine.test.ts index 19d8b41c..ea1320ff 100644 --- a/tests/sapho-started-movement-engine.test.ts +++ b/tests/sapho-started-movement-engine.test.ts @@ -135,16 +135,23 @@ for (const advanced of [false, true]) { assert.deepEqual(inventory(game), f.originalInventory); }); - if (advanced) void test(`${label} an actual active Sapho holder cannot split its own already paid combined turn`, () => { + if (advanced) void test(`${label} an actual active Sapho holder may pause its own paid combined turn for last`, () => { const f = fixture({ advanced }); let game = advance(structuredClone(f.game), state => clean(state) && state.active === f.owner); const proposed = action(game, 'last'); game = act(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }); game = advance(game, clean); assert.equal(game.active, f.owner); assert.equal(player(game, f.owner).shipped, true); - assert.equal(viewGame(game, f.owner).saphoOptions.some(option => option.scope === 'movement'), false); reject(game, f.owner, proposed); - assert.ok(player(game, f.owner).hand.some(card => card.id === f.card)); + const option = viewGame(game, f.owner).saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last'); + assert.ok(option && option.scope === 'movement' && option.resumeOwn); + const before = structuredClone(player(game, f.owner)); + game = act(game, f.owner, { type: 'card', card: f.card, scope: 'movement', mode: 'last', event: option.event }); + assert.deepEqual(game.movementRemaining?.at(-1), f.owner); + assert.equal(player(game, f.owner).shipped, before.shipped); + assert.equal(player(game, f.owner).reserves, before.reserves); + assert.equal(player(game, f.owner).spice, before.spice); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); }); }