diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx index 6caa3df1..8f83ef3f 100644 --- a/components/juice-of-sapho.tsx +++ b/components/juice-of-sapho.tsx @@ -22,7 +22,7 @@ function unavailableReason(game: GameView) { if (game.phase === 4) return !game.advanced || !game.revivalOrder ? 'Revival order changes require an ordered Advanced Revival phase. Basic and an already-opened unstamped phase keep their existing timing.' - : 'Choose first or last only among remaining unstarted Revival turns. A player who has started finishes their Revival choices unchanged. Completed turns stay finished; already-held positions and committed cards are unavailable.'; + : 'Change the order of remaining Revival turns. Another player finishes any started turn first. At a clean boundary, you may pause your own started turn and resume it last with used allowances unchanged. Completed turns stay finished; already-held positions and committed cards are unavailable.'; if (game.phase === 8 && game.extortion?.pending) return 'Only an unanswered non-Moritani player can change their Extortion choice position. Earlier declines stay finished. Already-held first/last positions and committed cards are unavailable; you may still decline payment when your choice arrives.'; if (game.phase === 3) { @@ -67,7 +67,7 @@ export function SaphoOrderButton({ }} > {option.scope === 'battleAggressor' ? 'Become aggressor and discard Juice of Sapho' : - option.scope === 'movement' && option.resumeOwn ? 'Resume your movement last and discard Juice of Sapho' : + (option.scope === 'movement' || option.scope === 'revival') && option.resumeOwn ? `Resume your ${option.scope} last and discard Juice of Sapho` : `${bidding ? 'Bid' : option.scope === 'battleOrder' ? 'Choose battles' : option.scope === 'extortion' ? 'Take your Extortion choice' : option.scope === 'revival' ? 'Take your Revival turn' : option.scope === 'charity' ? 'Take your Charity turn' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`} ); @@ -114,7 +114,7 @@ export function JuiceOfSapho({ const extortion = option.scope === 'extortion'; const revival = option.scope === 'revival'; const charity = option.scope === 'charity'; - const resumeOwn = option.scope === 'movement' && option.resumeOwn; + const resumeOwn = (option.scope === 'movement' || revival) && option.resumeOwn; const afterCurrent = (option.scope === 'movement' || revival || charity) && option.afterCurrent; const currentPlayer = afterCurrent ? game.players.find(player => player.id === afterCurrent)?.name ?? 'The active player' @@ -154,7 +154,9 @@ export function JuiceOfSapho({ ))}

{resumeOwn - ? 'Pause your started turn and resume it last, after the other remaining players. Shipment and movement already used stay spent; no extra action is granted. This Basic timing is provisional.' + ? revival + ? 'Pause your started Revival turn and resume it last, after the other remaining players. Returned pieces, costs and used revival allowances remain unchanged. This timing is provisional.' + : 'Pause your started turn and resume it last, after the other remaining players. Shipment and movement already used stay spent; no extra action is granted. This timing is provisional.' : aggressor ? 'Become this battle’s aggressor. You win ordinary ties; a Habbanya Stronghold advantage still takes precedence. The participants, committed plans and later battle choices stay unchanged.' : extortion diff --git a/docs/ADVANCED_PREVIEW.md b/docs/ADVANCED_PREVIEW.md index 90669dc6..5e11ea9f 100644 --- a/docs/ADVANCED_PREVIEW.md +++ b/docs/ADVANCED_PREVIEW.md @@ -29,6 +29,20 @@ The warning remains visible throughout Advanced play, including older Advanced development saves. Games use the existing room save, reconnect and private seat system. Basic remains the default. +## Simultaneous victories + +When multiple unallied players qualify for ordinary victory, Advanced compares +their stronghold counts and awards the higher count before checking a Bene +Gesserit prediction. Equal highest counts provisionally share the win; a set of +all three Tech Tokens counts as one additional stronghold in this comparison. +Qualifying alliances and the separate Ecaz Homeworld victory retain their +existing shared-winner treatment. Basic retains its existing behavior. + +The authorized revised rules also permit all qualifying players or alliances to +agree to play another turn and optionally raise the stronghold target. That +choice is not yet implemented in the preview, which currently ends in a shared +win when applicable. [Source and interpretation](RULE_DECISIONS.md#advanced-simultaneous-solo-stronghold-victories--27-october-2026). + ## Admission and saved behavior Normal room creation already stores `advanced: boolean`; the same request format diff --git a/docs/ADVANCED_REVIVAL_ORDER.md b/docs/ADVANCED_REVIVAL_ORDER.md index 5d474754..cf6e9805 100644 --- a/docs/ADVANCED_REVIVAL_ORDER.md +++ b/docs/ADVANCED_REVIVAL_ORDER.md @@ -65,15 +65,17 @@ At a clean boundary, an owned physical Sapho changes an unfinished opportunity to first or last. Existing completed participants, requests, quotas and physical First Player order stay. The standing choose-recommended instruction selects the same provisional started-tail protocol as movement: a current participant -who has made a native request finishes unchanged, while another unstarted holder -may go first behind it or last. Splitting the holder's own started opportunity -and consuming the card for an unchanged position are rejected. +who has made a native request finishes unchanged when another unstarted holder +goes first behind it or last. The later own-started policy below permits the +current holder to pause its own opportunity. Consuming the card for an unchanged +position remains rejected. -Actual use upgrades only that phase frame to version2. Public use history binds -the original turn/order, completed prefix, current remaining queue, started -participant and revision. Replay advances exact completed opportunities and -never unlocks a started participant before completion. Native start/finish retain -that history. Original version1 creation and bytes remain unchanged; no phase +An ordinary reorder upgrades only that phase frame to version2. Public use +history binds the original turn/order, completed prefix, current remaining +queue, started participant and revision. Version2 replay advances exact +completed opportunities and never unlocks a started participant before +completion. Native start/finish retain that history. Original version1 creation +and bytes remain unchanged; no phase is converted merely by viewing it. Real recovered-card reuse is allowed when the new position changes; no once-per-phase limit is invented. @@ -86,6 +88,29 @@ The human action uses only existing card/scope/event/mode fields. Optional `afterCurrent` is display guidance, not submitted authority. Existing minimal policies already consume the shared server-derived Sapho options. +## Own-started Sapho suspension — 27 October 2026 + +At a clean boundary, the current holder may provisionally use its physical +Sapho to pause its own started Revival opportunity and resume it after the +other remaining opportunities. Earlier completed returns, paid spice and +force, elite and leader allowances stay used; no player gains another turn or +allowance. Unresolved requests or responses must finish first. A no-op position +cannot consume the card. Another holder still cannot interrupt a started +player's opportunity. + +An actual own-started suspension upgrades the frame to version3 and records +the paused faction with the public use history. The engine restores its +started marker when that faction becomes current again. Existing version1/2 +frames keep their format until such a suspension; Basic and already-opened +concurrent phases gain no ordered turn. This clean-boundary timing is a +provisional online interpretation of the Sapho face, not a publisher ruling. +Nested combinations, full-mode and deployed acceptance remain open. + +### Earlier first/last evidence — 26 October 2026 + +The following evidence predates the own-started suspension above and does not +verify that later path. + Focused evidence uses `tests/sapho-revival-order.test.ts`, `tests/fixture-sapho-revival-order.ts`, `tests/sapho-revival-order-engine.test.ts` and diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6b9dcc89..996c5eeb 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,6 +20,15 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Advanced Revival order and competing winners — 27 October. Prototyped / +Partial.** Sapho can provisionally pause a clean started Revival turn and resume +it last with spent allowances and legacy order records preserved. Simultaneous +unallied Advanced qualifiers are ranked by stronghold count before prediction; +ties, Tech counting and other simultaneous victory sources retain the documented +provisional composition. The 12-file affected union passes 144 cases. The +unanimous option to play another turn and broader acceptance remain open. +[Revival](ADVANCED_REVIVAL_ORDER.md) · [Victory](ADVANCED_PREVIEW.md). + **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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index b6445df2..4f27ca8a 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,15 @@ # Dune implementation status +## 27 October 2026 — Advanced Revival and simultaneous victory + +A clean already-started Advanced Revival can provisionally pause through +Sapho and resume last without repeating used force or leader allowances. +Simultaneous unallied Advanced stronghold qualifiers are compared before Bene +Gesserit prediction; ties and Tech counting use documented provisional +interpretations. The affected 12-file union passes 144 cases. The unanimous +choice to continue another turn and broader acceptance remain unfinished. +[Revival](ADVANCED_REVIVAL_ORDER.md) · [Victory](ADVANCED_PREVIEW.md). + ## 27 October 2026 — Advanced Sapho own-turn suspension A player can provisionally pause its own already-started Advanced Shipment and diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md index 390a9393..8d53d837 100644 --- a/docs/JUICE_OF_SAPHO_RUNTIME.md +++ b/docs/JUICE_OF_SAPHO_RUNTIME.md @@ -1,5 +1,27 @@ # Juice of Sapho: finite ordering implementation +## Own started Advanced Revival turn — 27 October 2026 + +**Prototyped / Partial.** At a clean boundary in ordered Advanced Revival, the +current holder may use Juice of Sapho to pause its own started opportunity and +place it last among the remaining turns. Its earlier completed returns, paid +spice, native force and leader allowances and physical pieces remain unchanged. +The same opportunity resumes, without a second allowance. Pending requests, +responses and other preparations must finish before the order can change. + +The first actual own-started suspension upgrades the order frame to version 3, +recording the paused player alongside the original public Sapho use history. +The started marker is restored when that player becomes current again. Existing +version 1 and 2 frames retain their prior format until an actual qualifying +suspension. Older concurrent Revival phases and Basic remain unchanged. + +The Sapho face permits first or last in an ordered phase or action; it does not +specify suspension of a partially completed Revival opportunity. This policy is +a provisional online composition, not an official clarification. It supersedes +the earlier own-started Revival exclusion below. Nested interactions, full +rules, browser and deployed acceptance remain open. +[Native Revival order](ADVANCED_REVIVAL_ORDER.md#own-started-sapho-suspension--27-october-2026). + ## Own started Advanced movement turn — 27 October 2026 **Prototyped / Partial.** The clean-boundary own-turn Last option now extends @@ -16,8 +38,8 @@ 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. +movement rule does not extend to Charity or unfinished nested interactions; +Revival has the separate provisional suspension described above. 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 @@ -155,8 +177,9 @@ only its later unstarted tail. Completed turns stay complete; native quotas, requests and physical First Player order remain. Basic and older concurrent phases receive no invented sequence. -Actual card disposal upgrades that phase to validated version2 use history. -Original version1 frames retain their bytes and native behavior. Current actor, +The original card disposal implementation upgrades that phase to validated +version2 use history. The later own-started suspension above upgrades to +version3. Original version1 frames retain their bytes and native behavior. Current actor, completed/remaining opportunities and started state bind the exact event and use revision. The opted-in Semuta continuation claims or leaves the physical discard before the same queue resumes. A legal recovered card can be used @@ -419,8 +442,9 @@ mode discussed below. Later sections supersede the earlier early-only aggressor, cyclic-auction and blanket started-movement exclusions. Queued movement now supports an unstarted different holder behind the active turn under the selected provisional policy. -Other ordered actions, splitting the holder's own started turn and competing -first/Guild precedence beyond the retained native choice remain unfinished. +Other ordered actions and competing first/Guild precedence beyond the retained +native choice remain unfinished. The own-started Movement and Revival policies +above supersede the earlier blanket split-turn exclusion. Reacquiring and replaying Sapho while existing last protection remains is still guarded. Silent submission stays simultaneous with its original storm tie order. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 4a590362..504bd9fb 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,23 @@ # Rule decision index +## Advanced simultaneous solo stronghold victories — 27 October 2026 + +The authorized supplied revision, physical p. 14 (printed p. 12), awards a +normal win among multiple qualifying unallied players to the player occupying +the most strongholds. Advanced victory now compares their ordinary qualifying +counts before applying Bene Gesserit prediction. Equal counts provisionally +share the win. The existing three-Tech-token stronghold equivalent is counted +as one in this comparison; this composition is provisional. Competition with +a qualifying alliance or the separate Ecaz Homeworld victory retains the +existing shared-winner treatment. Basic and recorded winners retain their +existing behavior. + +The same revision permits all qualifying players or alliances to agree to play +another turn and optionally raise the required stronghold count. This choice is +not yet implemented; automatic shared victory remains the current preview +behavior. Implementing the solo comparison does not complete Advanced victory +or release acceptance. [Preview](ADVANCED_PREVIEW.md#simultaneous-victories). + ## Juice of Sapho and an already-started Advanced movement turn — 27 October 2026 Extend the existing provisional clean own-turn Last policy to Advanced diff --git a/game/engine.ts b/game/engine.ts index caa65d5d..d058cd86 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -7709,8 +7709,8 @@ function treacheryDiscardIntegrity(g: Game) { charityOrderIntegrity(g); } else if (continuation?.kind === 'saphoRevivalDiscard') { const c=continuation,entry=batch.entries[0],frame=g.revivalOrder; - const latest=frame?.version===2 ? frame.uses.at(-1) : null; - requireRule(g.advanced && g.phase===4 && frame?.version===2 && + const latest=frame && frame.version!==1 ? frame.uses.at(-1) : null; + requireRule(g.advanced && g.phase===4 && frame && frame.version!==1 && latest?.event===c.event && latest.player===c.player && latest.mode===c.mode && batch.cause==='sapho:revival' && batch.entries.length===1 && entry.publicFace && entry.discardedBy===c.player && entry.card.id===c.card && @@ -9048,7 +9048,7 @@ function semutaOfferSupported( continuation.resume.pendingKarama || continuation.resume.phaseOpening || g.response || g.decision || g.pendingRevival)) return false; if (continuation.kind === 'saphoRevivalDiscard' && - (!g.advanced || g.phase!==4 || g.revivalOrder?.version!==2 || + (!g.advanced || g.phase!==4 || !g.revivalOrder || g.revivalOrder.version===1 || continuation.resume.response || continuation.resume.decision || continuation.resume.pendingKarama || continuation.resume.phaseOpening || g.response || g.decision || g.pendingRevival)) return false; @@ -27174,12 +27174,14 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] { } } if (g.phase===4 && g.advanced && g.revivalOrder) { + if (g.pendingRevival) return options; const context=revivalOrderContext(g); const event=revivalOrderRule(()=>saphoRevivalOrderEvent(context,g.revivalOrder!)); for (const mode of ['first','last'] as const) { try { const quote=reorderSaphoRevivalOrder(context,g.revivalOrder,{event,player:p.id,mode}); - options.push({scope:'revival',event,mode,...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {})}); + options.push({scope:'revival',event,mode,...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {}), + ...(quote.suspended ? {resumeOwn:true as const} : {})}); } catch(error) { if (!(error instanceof RevivalOrderError)) throw error; } @@ -27389,7 +27391,7 @@ function playSapho(g: Game, p: Player, action: Action) { const used=discard(g,p,'richese-juice-of-sapho'); g.revivalOrder=quote.order; g.active=quote.order.remaining[0] ?? null; - log(g,`${p.name} discarded Juice of Sapho to take their remaining Revival turn ${option.mode}.${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the started turn first.` : ''} Completed turns, native allowances and physical First Player order stay unchanged.`, + log(g,`${p.name} discarded Juice of Sapho to take their remaining Revival turn ${option.mode}.${quote.suspended ? ' Their started turn pauses provisionally and resumes last with its used allowances unchanged.' : ''}${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the started turn first.` : ''} Completed turns, native allowances and physical First Player order stay unchanged.`, {faction:p.faction,name:'Juice of Sapho'}); if (g.semutaPreview) { const continuation:SaphoRevivalDiscardContinuation={kind:'saphoRevivalDiscard',player:p.id,card:used.id, diff --git a/game/reference.ts b/game/reference.ts index 342f98cb..53e1af53 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -505,7 +505,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', 'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.', 'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. Each player still claims at most once, completed turns stay closed, and the holder cannot split its own started turn. Physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.', - 'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If the current player has already begun a native return or negotiation, provisional queued timing lets that player finish unchanged; first means first among the later unstarted turns. You cannot split your own started turn, repeat a completed turn or consume the card for a position you already hold. Basic and older concurrent Revival phases have no ordered scope.', + 'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If another player has started, provisional queued timing lets that player finish unchanged. At a clean boundary, you may instead pause your own started turn and resume last, retaining used allowances. Pending requests must finish first. Completed turns and already-held positions cannot be repeated. Basic and older concurrent Revival phases have no ordered scope.', '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.', @@ -4445,7 +4445,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', '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. If several sides reach their target at the same check, they share a normal win before any correct prediction replaces it.', + '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. A qualifying alliance and other qualifying sides share before a correct prediction replaces the result. The optional unanimous decision to play another turn remains unfinished.', '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.', ], @@ -5484,7 +5484,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - "Fresh Basic Southern multi-star returns have provisional per-star allocation with exact child arrivals, a fixed original grant and one deferred income. All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. Ghola can raise Salusa before full seals, retaining feasible inspections or reopening impossible original fields. Feasible Truthtrance Yes/No/grouped promises remain; self-impossible returns reject with exact force-group control reasons. Full-seal timing remains guarded. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.", + "Fresh Basic Southern multi-star returns have provisional per-star allocation with exact child arrivals, a fixed original grant and one deferred income. All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. Low Salusa prohibits free Sardaukar revival from ordinary, Ghola, Emperor special Karama or Tleilaxu Ambassador sources; paid ordinary Sardaukar and eligible normal or leader returns remain. Retained occupation after repopulation is provisional. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.", evidence: [ 'game/homeworld-module-profile.ts', 'game/faction-module-profile.ts', diff --git a/game/revival-order.ts b/game/revival-order.ts index a032be37..da5cd4aa 100644 --- a/game/revival-order.ts +++ b/game/revival-order.ts @@ -16,10 +16,13 @@ export type SaphoRevivalOrderUse = { mode: 'first' | 'last'; completed: string[]; started: boolean; + /** Version 3 only: the current started player voluntarily paused last. */ + suspended?: true; }; export type RevivalOrder = RevivalOrderFields & ( | { version: 1 } | { version: 2; uses: SaphoRevivalOrderUse[] } + | { version: 3; uses: SaphoRevivalOrderUse[]; paused: string[] } ); export type RevivalOrderContext = { turn: number; @@ -53,8 +56,10 @@ function validateContext(context: RevivalOrderContext): void { function copy(frame: RevivalOrder): RevivalOrder { const fields = { turn: frame.turn, original: [...frame.original], remaining: [...frame.remaining], completed: [...frame.completed], started: frame.started }; - return frame.version === 1 ? { version: 1, ...fields } - : { version: 2, ...fields, uses: frame.uses.map(use => ({ ...use, completed: [...use.completed] })) }; + if (frame.version === 1) return { version: 1, ...fields }; + const uses = frame.uses.map(use => ({ ...use, completed: [...use.completed] })); + return frame.version === 2 ? { version: 2, ...fields, uses } + : { version: 3, ...fields, uses, paused: [...frame.paused] }; } export function createRevivalOrder(context: RevivalOrderContext): RevivalOrder { @@ -67,9 +72,11 @@ export function createRevivalOrder(context: RevivalOrderContext): RevivalOrder { export function validateRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder): void { validateContext(context); - requireOrder(record(frame) && (frame.version === 1 || frame.version === 2) && + requireOrder(record(frame) && (frame.version === 1 || frame.version === 2 || frame.version === 3) && Object.keys(frame).sort().join(',') === (frame.version === 1 - ? 'completed,original,remaining,started,turn,version' : 'completed,original,remaining,started,turn,uses,version') && + ? 'completed,original,remaining,started,turn,version' : frame.version === 2 + ? 'completed,original,remaining,started,turn,uses,version' + : 'completed,original,paused,remaining,started,turn,uses,version') && Number.isSafeInteger(frame.turn) && frame.turn === context.turn && ids(frame.original) && ids(frame.remaining) && ids(frame.completed) && typeof frame.started === 'boolean', 'The current Revival order is malformed or belongs to another turn.'); @@ -78,7 +85,8 @@ export function validateRevivalOrder(context: RevivalOrderContext, frame: Reviva if (frame.version === 1) requireOrder(same(frame.completed, frame.original.slice(0, frame.completed.length)) && same(frame.remaining, frame.original.slice(frame.completed.length)), 'The Revival order must retain the original public order and its completed prefix.'); - else validateSaphoHistory(frame); + else if (frame.version === 2) validateSaphoHistory(frame); + else validateSuspendedSaphoHistory(frame); requireOrder(frame.remaining.length > 0 || !frame.started, 'Completed Revival cannot retain a started opportunity.'); } @@ -138,28 +146,84 @@ function validateSaphoHistory(frame: Extract): voi 'The Revival frame contradicts its Sapho use history or unlocks a started opportunity.'); } +/** Version 3 extends the same public use history with a voluntary suspension. + * Pending native requests are checked by the engine before this quote. A + * suspended faction stays started, even if later uses reorder its position. */ +function validateSuspendedSaphoHistory(frame: Extract): void { + requireOrder(Array.isArray(frame.uses) && frame.uses.length > 0 && ids(frame.paused) && + new Set(frame.paused).size === frame.paused.length, + 'A suspended Revival needs its actual Sapho use history and unique paused factions.'); + let remaining = [...frame.original], completed: string[] = [], started = false, paused: string[] = []; + const advance = (next: readonly string[]) => { + const advanced = advanceCompleted(remaining, completed, started, next); + const count = next.length - completed.length; + paused = paused.filter(player => !next.includes(player)); + remaining = advanced.remaining; + completed = advanced.completed; + started = count === 0 ? advanced.started : !!remaining.length && paused.includes(remaining[0]); + }; + for (const [index, use] of frame.uses.entries()) { + requireOrder(record(use) && + (Object.keys(use).sort().join(',') === 'completed,event,mode,player,started' || + Object.keys(use).sort().join(',') === 'completed,event,mode,player,started,suspended' && use.suspended === true) && + typeof use.event === 'string' && id(use.player) && (use.mode === 'first' || use.mode === 'last') && + ids(use.completed) && typeof use.started === 'boolean', + 'The saved Sapho Revival use is malformed.'); + advance(use.completed); + requireOrder(!started || use.started, 'A later Sapho use cannot unlock the current started Revival opportunity.'); + requireOrder(!use.started || remaining.length > 0, 'Only a current Revival opportunity can have started.'); + requireOrder(use.event === eventFor(frame.turn, frame.original, remaining, completed, use.started, index), + 'The saved Sapho Revival use lost its original public boundary.'); + if (use.suspended) { + requireOrder(use.started && use.player === remaining[0] && use.mode === 'last' && remaining.length > 1, + 'Only the current started faction can suspend its own Revival turn last.'); + if (!paused.includes(use.player)) paused.push(use.player); + remaining = [...remaining.slice(1), use.player]; + started = paused.includes(remaining[0]); + } else { + remaining = reordered(frame.original, remaining, completed, use.started, use); + started = use.started || paused.includes(remaining[0]); + } + } + advance(frame.completed); + requireOrder(same(frame.remaining, remaining) && same(frame.paused, paused) && (!started || frame.started), + 'The Revival frame contradicts its Sapho history or loses a suspended opportunity.'); + requireOrder(frame.uses.some(use => use.suspended === true), 'Version 3 requires an actual suspended Revival turn.'); +} + /** This boundary binds the actual public queue and every prior genuine use's * revision. It grants no card ownership, payment or additional opportunity. */ export function saphoRevivalOrderEvent(context: RevivalOrderContext, frame: RevivalOrder): string { validateRevivalOrder(context, frame); return eventFor(frame.turn, frame.original, frame.remaining, frame.completed, - frame.started, frame.version === 2 ? frame.uses.length : 0); + frame.started, frame.version === 1 ? 0 : frame.uses.length); } export function reorderSaphoRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder, request: { event: string; player: string; mode: 'first' | 'last'; -}): { order: RevivalOrder; afterCurrent?: string } { +}): { order: RevivalOrder; afterCurrent?: string; suspended?: string } { validateRevivalOrder(context, frame); const event = saphoRevivalOrderEvent(context, frame); requireOrder(record(request) && Object.keys(request).sort().join(',') === 'event,mode,player' && request.event === event && id(request.player) && (request.mode === 'first' || request.mode === 'last'), 'Choose first or last at the current Sapho Revival opportunity.'); - const remaining = reordered(frame.original, frame.remaining, frame.completed, frame.started, request); - const order: RevivalOrder = { version: 2, turn: frame.turn, original: [...frame.original], remaining, - completed: [...frame.completed], started: frame.started, - uses: [...(frame.version === 2 ? frame.uses.map(use => ({ ...use, completed: [...use.completed] })) : []), - { event, player: request.player, mode: request.mode, completed: [...frame.completed], started: frame.started }] }; + const suspended = frame.started && request.player === frame.remaining[0]; + if (suspended) requireOrder(request.mode === 'last' && frame.remaining.length > 1, + 'A started Revival turn can only be suspended last when another opportunity remains.'); + const remaining = suspended ? [...frame.remaining.slice(1), request.player] + : reordered(frame.original, frame.remaining, frame.completed, frame.started, request); + const paused = frame.version === 3 ? [...frame.paused] : []; + if (suspended && !paused.includes(request.player)) paused.push(request.player); + const uses = [...(frame.version === 1 ? [] : frame.uses.map(use => ({ ...use, completed: [...use.completed] }))), + { event, player: request.player, mode: request.mode, completed: [...frame.completed], started: frame.started, + ...(suspended ? { suspended: true as const } : {}) }]; + const fields = { turn: frame.turn, original: [...frame.original], remaining, + completed: [...frame.completed], started: suspended ? paused.includes(remaining[0]) : frame.started || paused.includes(remaining[0]) }; + const order: RevivalOrder = frame.version === 3 || suspended + ? { version: 3, ...fields, uses, paused } + : { version: 2, ...fields, uses }; validateRevivalOrder(context, order); - return { order, ...(frame.started ? { afterCurrent: frame.remaining[0] } : {}) }; + return { order, ...(suspended ? { suspended: request.player } : + frame.started ? { afterCurrent: frame.remaining[0] } : {}) }; } export function revivalOrderBlock(context: RevivalOrderContext, frame: RevivalOrder, player: string): string | null { @@ -186,6 +250,10 @@ export function finishRevivalOrder(context: RevivalOrderContext, frame: RevivalO requireOrder(!blocked, blocked ?? 'This Revival opportunity is unavailable.'); const next = { ...copy(frame), remaining: frame.remaining.slice(1), completed: [...frame.completed, player], started: false }; + if (next.version === 3) { + next.paused = next.paused.filter(id => id !== player); + next.started = next.remaining.length > 0 && next.paused.includes(next.remaining[0]); + } validateRevivalOrder({ ...context, ready: [...context.ready, player] }, next); return next; } diff --git a/game/victory-quote.ts b/game/victory-quote.ts index 855ccd18..e229d088 100644 --- a/game/victory-quote.ts +++ b/game/victory-quote.ts @@ -179,8 +179,21 @@ export function quoteVictory(g: VictoryContext, ecazContext?: EcazHomeworldVicto const predicted = () => bg?.prediction?.turn === g.turn && winner.some((id) => player(id).faction === bg.prediction!.faction); - for (const row of progress) - if (row.qualifies) winner = [...new Set([...winner, ...row.members])]; + const qualifying = progress.filter((row) => row.qualifies); + // The authorized Advanced rules first compare the holdings of qualifying + // unallied players; their general shared-win rule covers ties and any + // competition that includes a qualifying alliance. A complete Tech set is + // the same additional stronghold used in the ordinary victory threshold. + // Keep existing settled winners and the separate Homeworld victory source + // intact rather than retroactively ranking a recorded result. + const rankSolo = g.advanced && winner.length === 0 && !ecazHomeworld && + qualifying.length > 1 && qualifying.every((row) => row.members.length === 1); + const most = rankSolo + ? Math.max(...qualifying.map((row) => row.strongholds.length + Number(row.techStronghold))) + : null; + for (const row of qualifying) + if (most === null || row.strongholds.length + Number(row.techStronghold) === most) + winner = [...new Set([...winner, ...row.members])]; if (ecazHomeworld) winner = [...new Set([...winner, ...ecazHomeworld.members])]; if (predicted()) winner = [bg!.id]; let status = g.status; diff --git a/tests/advanced-victory-stronghold-tiebreak.test.ts b/tests/advanced-victory-stronghold-tiebreak.test.ts new file mode 100644 index 00000000..3cb37516 --- /dev/null +++ b/tests/advanced-victory-stronghold-tiebreak.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, newPlayer, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import { baseDeck } from '../game/cards'; +import { createTechTokens } from '../game/tech-tokens'; +import { quoteVictory } from '../game/victory-quote'; + +/** Controlled public board using five printed strongholds, one physically + * placed mobile stronghold, and the original three-token stronghold bonus. */ +function position(advanced = true): Game { + const g = createGame('VICTORYORDER', newPlayer('ix', 'Ixians', 'ixians'), advanced); + g.players.push(newPlayer('at', 'Atreides', 'atreides')); + g.players.push(newPlayer('em', 'Emperor', 'emperor')); + g.players.push(newPlayer('bg', 'Bene Gesserit', 'beneGesserit')); + Object.assign(g, { status: 'playing', phase: 8, turn: 2, storm: 18, + order: g.players.map(p => p.id), ready: [], phaseOpening: null, + deck: baseDeck(), active: null, mobileStronghold: { location: 'red_chasm:7' }, + techTokens: createTechTokens() }); + for (const p of g.players) Object.assign(p, { hand: [], forces: {}, + reserves: 20, spice: 20, traitors: [], traitorChoices: [] }); + return g; +} +function holds(g: Game, id: string, ...names: string[]) { + const player = g.players.find(p => p.id === id)!; + player.forces = Object.fromEntries(names.map(name => [ + `${name}:${territory(name).sectors[0]}`, 1, + ])); +} +function tech(g: Game, id: string) { + for (const token of Object.values(g.techTokens!)) token.owner = id; +} +function actual(g: Game) { + let result = structuredClone(g); + result.phase = 7; + for (const p of result.players) result = applyAction(result, p.id, { type: 'ready' }); + return result; +} + +void test('Advanced awards the unallied higher qualified stronghold count, including a mobile and Tech stronghold', () => { + const g = position(); + holds(g, 'ix', 'arrakeen', 'carthag', 'sietch_tabr'); + g.players[0].forces['hidden_mobile_stronghold:0'] = 1; + holds(g, 'at', 'tueks_sietch', 'habbanya_ridge_sietch'); + tech(g, 'at'); + const before = structuredClone(g); + assert.deepEqual(quoteVictory(g).winner, ['ix']); + assert.deepEqual(actual(g).winner, ['ix']); + assert.deepEqual(g, before); +}); + +void test('equal qualified Advanced solo stronghold counts still share the win', () => { + const g = position(); + holds(g, 'ix', 'arrakeen', 'carthag'); + g.players[0].forces['hidden_mobile_stronghold:0'] = 1; + holds(g, 'at', 'sietch_tabr', 'tueks_sietch', 'habbanya_ridge_sietch'); + assert.deepEqual(quoteVictory(g).winner, ['ix', 'at']); + assert.deepEqual(actual(g).winner, ['ix', 'at']); +}); + +void test('the complete Tech set is one extra stronghold for comparing qualified solos', () => { + const g = position(); + holds(g, 'ix', 'arrakeen', 'carthag'); + g.players[0].forces['hidden_mobile_stronghold:0'] = 1; + holds(g, 'at', 'sietch_tabr', 'tueks_sietch', 'habbanya_ridge_sietch'); + tech(g, 'at'); + assert.deepEqual(quoteVictory(g).winner, ['at']); + assert.deepEqual(actual(g).winner, ['at']); +}); + +void test('Basic retains its existing shared-win policy for unequal qualified solos', () => { + const g = position(false); + holds(g, 'ix', 'arrakeen', 'carthag', 'sietch_tabr'); + g.players[0].forces['hidden_mobile_stronghold:0'] = 1; + holds(g, 'at', 'tueks_sietch', 'habbanya_ridge_sietch'); + tech(g, 'at'); + assert.deepEqual(quoteVictory(g).winner, ['ix', 'at']); + assert.deepEqual(actual(g).winner, ['ix', 'at']); +}); + +void test('a qualifying alliance and solo still share an Advanced win regardless of their totals', () => { + const g = position(); + g.players[1].ally = 'em'; g.players[2].ally = 'at'; + holds(g, 'at', 'arrakeen', 'carthag'); + holds(g, 'em', 'sietch_tabr'); + tech(g, 'at'); + holds(g, 'ix', 'tueks_sietch', 'habbanya_ridge_sietch'); + g.players[0].forces['hidden_mobile_stronghold:0'] = 1; + assert.deepEqual(quoteVictory(g).winner, ['ix', 'at', 'em']); + assert.deepEqual(actual(g).winner, ['ix', 'at', 'em']); +}); + +void test('Bene Gesserit prediction uses the actual higher solo winner, not the lower qualifier', () => { + const g = position(); + holds(g, 'ix', 'arrakeen', 'carthag', 'sietch_tabr'); + g.players[0].forces['hidden_mobile_stronghold:0'] = 1; + holds(g, 'at', 'tueks_sietch', 'habbanya_ridge_sietch'); + tech(g, 'at'); + g.players[3].prediction = { faction: 'atreides', turn: 2 }; + assert.deepEqual(quoteVictory(g).winner, ['ix']); + assert.deepEqual(actual(g).winner, ['ix']); + g.players[3].prediction = { faction: 'ixians', turn: 2 }; + assert.deepEqual(quoteVictory(g).winner, ['bg']); + assert.deepEqual(actual(g).winner, ['bg']); +}); diff --git a/tests/sapho-revival-order-controls.test.tsx b/tests/sapho-revival-order-controls.test.tsx index add84623..9e63f1fb 100644 --- a/tests/sapho-revival-order-controls.test.tsx +++ b/tests/sapho-revival-order-controls.test.tsx @@ -95,6 +95,17 @@ void test('actual clean and queued callbacks send only the original five action } }); +void test('own started Revival pause explains retained allowances and sends the native action', () => { + const game = projected('last'); + const option: Option = { scope: 'revival', event: 'controlled-own-revival-event', mode: 'last', resumeOwn: true }; + game.saphoOptions = [option]; + const html = panel(game); + assert.match(html, /Resume your revival last and discard Juice of Sapho/); + assert.match(html, /Returned pieces, costs and used revival allowances remain unchanged/); + assert.deepEqual(press(option), [{ type: 'card', card: sapho!.id, scope: 'revival', event: option.event, mode: 'last' }]); + assert.deepEqual(press(option, true), []); +}); + void test('Basic unstamped and completed controlled views offer no Revival order button even with stale projected options', () => { const basic = projected(); basic.advanced = false; basic.revivalOrder = null; const legacy = projected(); legacy.revivalOrder = null; @@ -113,8 +124,8 @@ void test('Basic unstamped and completed controlled views offer no Revival order void test('unavailable ordered Revival guidance keeps completed turns and the current started actor intact', () => { const game = projected(); game.saphoOptions = []; const html = panel(game); - assert.match(html, /only among remaining unstarted Revival turns/); - assert.match(html, /A player who has started finishes their Revival choices unchanged/); + assert.match(html, /pause your own started turn and resume it last/); + assert.match(html, /Another player finishes any started turn first/); assert.match(html, /already-held positions and committed cards are unavailable/); assert.match(revival(game), /Juice of Sapho may change the remaining order; completed Revival turns stay finished/); }); diff --git a/tests/sapho-revival-order-engine.test.ts b/tests/sapho-revival-order-engine.test.ts index 6ea1db1d..663ee4ff 100644 --- a/tests/sapho-revival-order-engine.test.ts +++ b/tests/sapho-revival-order-engine.test.ts @@ -114,7 +114,7 @@ void test('all four minimal profiles propose an accepted native queued Revival o } }); -void test('controlled conserved-card supplement refuses to split the genuine started owner opportunity', () => { +void test('controlled conserved-card supplement can pause the genuine started owner opportunity', () => { const f = fixture({ started: true }), game = structuredClone(f.game); // This supplement conserves the actual paid card but does not claim a native // transfer to Richese. Its force return and started opportunity remain native. @@ -122,9 +122,13 @@ void test('controlled conserved-card supplement refuses to split the genuine sta player(game, f.current).hand.push(donor.hand.splice(index, 1)[0]); assert.equal(game.revivalOrder?.started, true); assert.deepEqual(inventory(game), f.originalInventory); - assert.deepEqual(viewGame(game, f.current).saphoOptions.filter(option => option.scope === 'revival'), []); const borrowed = action(f.game, 'last'); - reject(game, f.current, borrowed); + reject(game, f.current, { ...borrowed, event: 'stale-revival-order-event' }); + const option = viewGame(game, f.current).saphoOptions.find(option => option.scope === 'revival' && option.mode === 'last'); + assert.ok(option && option.scope === 'revival' && option.resumeOwn); + const paused = act(game, f.current, {type:'card', card:f.card, scope:'revival', mode:'last', event:option.event}); + assert.equal(paused.revivalOrder?.remaining.at(-1), f.current); + assert.equal(player(paused, f.current).revived, 1); }); for (const claim of [false, true]) { diff --git a/tests/sapho-revival-order.test.ts b/tests/sapho-revival-order.test.ts index 322d6039..46b681cf 100644 --- a/tests/sapho-revival-order.test.ts +++ b/tests/sapho-revival-order.test.ts @@ -57,7 +57,10 @@ void test('a started current actor locks its entire opportunity while another fa assert.notEqual(event(ctx, frame), old); rejects(ctx, frame, () => reorder(ctx, frame, { event: old, player: 'd', mode: 'first' })); rejects(ctx, frame, () => use(ctx, frame, 'a', 'first')); - rejects(ctx, frame, () => use(ctx, frame, 'a', 'last')); + const paused = use(ctx, frame, 'a', 'last'); + assert.equal(paused.suspended, 'a'); + assert.deepEqual(paused.order.remaining, ['b', 'c', 'd', 'a']); + assert.equal(paused.order.started, false); const first = use(ctx, frame, 'd', 'first'); assert.equal(first.afterCurrent, 'a'); assert.equal(first.order.started, true); assert.deepEqual(first.order.remaining, ['a', 'd', 'b', 'c']); diff --git a/tests/sapho-revival-own-suspension.test.ts b/tests/sapho-revival-own-suspension.test.ts new file mode 100644 index 00000000..83d0c2c5 --- /dev/null +++ b/tests/sapho-revival-own-suspension.test.ts @@ -0,0 +1,173 @@ +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 { + actSaphoRevivalOrder as act, advanceSaphoRevivalOrder as advance, + createSaphoRevivalOrderFixture as fixture, createSaphoRevivalSemutaFixture, + finishSaphoRevivalOrder as finish, reactSaphoRevivalSemuta, + saphoRevivalOrderClean as clean, saphoRevivalOrderInventory as inventory, + saphoRevivalOrderPlayer as player, +} from './fixture-sapho-revival-order'; + +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, real purchased Sapho, Richese shipment, Storm + * casualty and its free revival. Only the card's owner is controlled: the + * physical purchased card is moved from Emperor to the current Richese seat. */ +function source() { + const f = fixture({ started: true }); + const game = structuredClone(f.game); + const donor = player(game, f.owner); + const index = donor.hand.findIndex(card => card.id === f.card); + assert.ok(index >= 0); + player(game, f.current).hand.push(...donor.hand.splice(index, 1)); + assert.equal(game.revivalOrder?.started, true); + assert.equal(player(game, f.current).revived, 1); + assert.deepEqual(inventory(game), f.originalInventory); + return { f, game }; +} + +function ownLast(game: Game, actor: string, card: string): Action { + const option = viewGame(game, actor).saphoOptions.find(row => row.scope === 'revival' && row.mode === 'last'); + assert.ok(option && option.scope === 'revival' && option.resumeOwn); + return { type: 'card', card, scope: 'revival', mode: 'last', event: option.event }; +} + +function resume(game: Game, actor: string): Game { + return advance(game, state => state.phase === 4 && clean(state) && state.revivalOrder?.remaining[0] === actor); +} + +void test('Advanced own Revival suspension retains an earned free return and resumes once after the remaining players', () => { + const { f, game: original } = source(); + const before = structuredClone(player(original, f.current)); + const expected = [...original.revivalOrder!.remaining.slice(1), f.current]; + const declaration = ownLast(original, f.current, f.card); + reject(original, f.current, { ...declaration, event: 'stale-revival-event' }); + let game = act(original, f.current, declaration); + assert.deepEqual(game.revivalOrder?.remaining, expected); + assert.equal(game.revivalOrder?.version, 3); + assert.equal(game.revivalOrder?.started, false); + assert.deepEqual(game.order, original.order); + assert.equal(player(game, f.current).reserves, before.reserves); + assert.equal(player(game, f.current).tanks, before.tanks); + assert.equal(player(game, f.current).revived, before.revived); + assert.equal(player(game, f.current).freeForcesRevived, before.freeForcesRevived); + assert.equal(player(game, f.current).spice, before.spice); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(inventory(game), f.originalInventory); + reject(game, f.current, declaration); + game = resume(game, f.current); + assert.equal(game.revivalOrder?.started, true); + assert.equal(player(game, f.current).revived, before.revived); + assert.equal(player(game, f.current).freeForcesRevived, before.freeForcesRevived); + game = act(game, f.current, { type: 'ready' }); + game = finish(game); + assert.equal(game.phase, 5); + assert.deepEqual(game.order, original.order); + assert.equal(game.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(inventory(game), f.originalInventory); +}); + +void test('Advanced own Revival suspension preserves paid force and leader usage on return', () => { + const { f, game: original } = source(); + let game = structuredClone(original); + // Controlled physical supplement: move two Richese reserves to the Tanks, + // and mark its existing five leader discs dead once to open their native + // revival cycle. The paid return and leader revival below are real actions. + const current = player(game, f.current); + assert.ok(current.reserves >= 2); + current.reserves -= 2; + current.tanks += 2; + for (const leader of current.leaders) { leader.dead = true; leader.deaths = 1; } + // Richese receives two free force returns; the native source already used + // one. Complete its remaining free return before making a paid one. + const beforeFree = structuredClone(current); + game = advance(act(game, f.current, { type: 'revive', amount: 1, elite: 0 }), clean); + assert.equal(player(game, f.current).spice, beforeFree.spice); + assert.equal(player(game, f.current).revived, beforeFree.revived + 1); + const beforePaid = structuredClone(player(game, f.current)); + game = advance(act(game, f.current, { type: 'revive', amount: 1, elite: 0 }), clean); + assert.equal(player(game, f.current).spice, beforePaid.spice - 2); + assert.equal(player(game, f.current).revived, beforePaid.revived + 1); + assert.equal(player(game, f.current).freeForcesRevived, beforePaid.freeForcesRevived); + const eligible = viewGame(game, f.current).revival.leaders.find(row => row.affordable); + assert.ok(eligible); + game = advance(act(game, f.current, { type: 'reviveLeader', leader: eligible.id }), clean); + assert.equal(player(game, f.current).leaderRevived, true); + const used = structuredClone(player(game, f.current)); + const revivalAvailable = viewGame(game, f.current).revival.forcesRemaining; + game = act(game, f.current, ownLast(game, f.current, f.card)); + game = resume(game, f.current); + assert.equal(player(game, f.current).spice, used.spice); + assert.equal(player(game, f.current).reserves, used.reserves); + assert.equal(player(game, f.current).tanks, used.tanks); + assert.equal(player(game, f.current).revived, used.revived); + assert.equal(player(game, f.current).freeForcesRevived, used.freeForcesRevived); + assert.equal(player(game, f.current).leaderRevived, true); + assert.equal(viewGame(game, f.current).revival.forcesRemaining, revivalAvailable); + assert.ok(player(game, f.current).leaders.some(leader => leader.id === eligible.id && !leader.dead)); + reject(game, f.current, { type: 'reviveLeader', leader: eligible.id }); + game = finish(act(game, f.current, { type: 'ready' })); + assert.equal(game.phase, 5); + assert.deepEqual(inventory(game), f.originalInventory); +}); + +void test('All four minimal bot profiles can select the projected Advanced own Revival suspension', () => { + const { f, game } = source(); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, f.current); + view.players.find(seat => seat.id === f.current)!.bot = difficulty; + const action = botActions(view).find(row => row.type === 'card' && row.card === f.card && row.scope === 'revival'); + assert.ok(action, `${difficulty} proposes the owned Sapho action`); + const next = act(game, f.current, action); + assert.equal(next.revivalOrder?.remaining.at(-1), f.current); + assert.equal(player(next, f.current).revived, player(game, f.current).revived); + assert.equal(next.discard.filter(card => card.id === f.card).length, 1); + } +}); + +void test('Semuta reaction completes before a suspended Advanced Revival continues', () => { + const f = createSaphoRevivalSemutaFixture(); + assert.equal(f.nativeAcquisition, false); + // This separate Homeworld/Semuta source uses its documented conserved-card + // setup. Finish earlier actors only until a non-Semuta owner can start a + // real Revival with at least one later participant. + let game = advance(structuredClone(f.game), state => clean(state) && !!state.revivalOrder && + state.revivalOrder.remaining.length > 1 && state.revivalOrder.remaining[0] !== f.holder); + const actor = game.revivalOrder!.remaining[0]; + if (actor !== f.owner) { + const donor = player(game, f.owner); + const index = donor.hand.findIndex(card => card.id === f.card); + assert.ok(index >= 0); + player(game, actor).hand.push(...donor.hand.splice(index, 1)); + } + assert.deepEqual(inventory(game), f.originalInventory); + // Controlled leader deaths open the native revival; the paid action and + // subsequent discard/reaction/queue are all actual engine transitions. + for (const leader of player(game, actor).leaders) { leader.dead = true; leader.deaths = 1; } + const eligible = viewGame(game, actor).revival.leaders.find(row => row.affordable); + assert.ok(eligible); + game = advance(act(game, actor, { type: 'reviveLeader', leader: eligible.id }), clean); + const before = structuredClone(player(game, actor)); + const paused = act(game, actor, ownLast(game, actor, f.card)); + assert.equal(paused.pendingTreacheryDiscard?.continuation?.kind, 'saphoRevivalDiscard'); + assert.ok(paused.pendingTreacheryDiscard?.reaction); + const queue = structuredClone(paused.revivalOrder); + reject(paused, paused.revivalOrder!.remaining[0], { type: 'ready' }); + game = reactSaphoRevivalSemuta(paused, f.holder, false); + game = advance(game, clean); + assert.deepEqual(game.revivalOrder, queue); + assert.equal(player(game, actor).leaderRevived, true); + assert.equal(player(game, actor).spice, before.spice); + game = resume(game, actor); + assert.equal(game.revivalOrder?.started, true); + assert.equal(player(game, actor).leaderRevived, true); + assert.equal(player(game, actor).spice, before.spice); + assert.equal(finish(act(game, actor, { type: 'ready' })).phase, 5); +}); diff --git a/tests/sapho-revival-suspension-quote.test.ts b/tests/sapho-revival-suspension-quote.test.ts new file mode 100644 index 00000000..fc2cdcdb --- /dev/null +++ b/tests/sapho-revival-suspension-quote.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createRevivalOrder, finishRevivalOrder, reorderSaphoRevivalOrder, + saphoRevivalOrderEvent, startRevivalOrder, validateRevivalOrder, + type RevivalOrder, type RevivalOrderContext, +} from '../game/revival-order'; + +const context = (ready: string[] = []): RevivalOrderContext => ({ turn: 4, + order: ['a', 'b', 'c', 'd'], ready }); +function reorder(ctx: RevivalOrderContext, order: RevivalOrder, player: string, mode: 'first' | 'last') { + return reorderSaphoRevivalOrder(ctx, order, { event: saphoRevivalOrderEvent(ctx, order), player, mode }); +} + +void test('started Revival suspends last and resumes the same started opportunity after intervening turns', () => { + const ctx = context(), original = startRevivalOrder(ctx, createRevivalOrder(ctx), 'a'); + const choice = reorder(ctx, original, 'a', 'last'); + assert.equal(choice.suspended, 'a'); assert.equal(choice.afterCurrent, undefined); + assert.equal(choice.order.version, 3); assert.ok(choice.order.version === 3); + assert.deepEqual(choice.order.remaining, ['b', 'c', 'd', 'a']); + assert.deepEqual(choice.order.paused, ['a']); assert.equal(choice.order.started, false); + assert.equal(original.version, 1); assert.equal(original.started, true); + let order: RevivalOrder = choice.order; + for (const [index, player] of ['b', 'c', 'd'].entries()) { + const before = context(['b', 'c', 'd'].slice(0, index)); + order = finishRevivalOrder(before, startRevivalOrder(before, order, player), player); + } + assert.equal(order.version, 3); assert.ok(order.version === 3); + assert.deepEqual(order.remaining, ['a']); assert.deepEqual(order.paused, ['a']); + assert.equal(order.started, true); + const finished = finishRevivalOrder(context(['b', 'c', 'd']), order, 'a'); + validateRevivalOrder(context(['b', 'c', 'd', 'a']), finished); + assert.ok(finished.version === 3); assert.deepEqual(finished.paused, []); + assert.equal(finished.started, false); +}); + +void test('suspension preserves earlier version-two uses and a later reordered paused turn retains its started lock', () => { + const ctx = context(); + const first = reorder(ctx, createRevivalOrder(ctx), 'd', 'first').order; + assert.equal(first.version, 2); + const active = startRevivalOrder(ctx, first, 'd'); + const suspended = reorder(ctx, active, 'd', 'last').order; + assert.ok(first.version === 2 && suspended.version === 3); + assert.deepEqual(suspended.uses[0], first.uses[0]); + assert.deepEqual(suspended.paused, ['d']); + const resumed = reorder(ctx, suspended, 'd', 'first').order; + assert.ok(resumed.version === 3); assert.equal(resumed.started, true); + assert.equal(resumed.remaining[0], 'd'); assert.deepEqual(resumed.paused, ['d']); + const restored = JSON.parse(JSON.stringify(resumed)) as RevivalOrder; + validateRevivalOrder(ctx, restored); + const done = finishRevivalOrder(ctx, restored, 'd'); + assert.ok(done.version === 3); assert.deepEqual(done.paused, []); + validateRevivalOrder(context(['d']), done); +}); + +void test('suspension rejects stale, no-op and forged receipts without changing either input', () => { + const ctx = context(), fresh = createRevivalOrder(ctx); + const old = saphoRevivalOrderEvent(ctx, fresh), active = startRevivalOrder(ctx, fresh, 'a'); + const before = structuredClone(active); + assert.throws(() => reorderSaphoRevivalOrder(ctx, active, { event: old, player: 'a', mode: 'last' })); + assert.throws(() => reorder(ctx, active, 'a', 'first')); + assert.deepEqual(active, before); + const frame = reorder(ctx, active, 'a', 'last').order; assert.ok(frame.version === 3); + for (const bad of [ + { ...frame, paused: [] }, { ...frame, paused: ['a', 'a'] }, + { ...frame, paused: ['foreign'] }, { ...frame, remaining: ['a', 'b', 'c', 'd'] }, + { ...frame, uses: frame.uses.map(use => ({ ...use, suspended: undefined })) }, + ]) assert.throws(() => validateRevivalOrder(ctx, bad as RevivalOrder)); + assert.deepEqual(active, before); +});