diff --git a/components/juice-of-sapho.tsx b/components/juice-of-sapho.tsx index 8146b415..3327fd89 100644 --- a/components/juice-of-sapho.tsx +++ b/components/juice-of-sapho.tsx @@ -10,8 +10,11 @@ import { CardInspector } from './card-inspector'; function unavailableReason(game: GameView) { if (game.status !== 'playing') return 'Order changes are available during play.'; - if (game.truthtrance || game.response || game.decision || game.phaseOpening) + 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.'; + 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) { if (game.richeseAuction?.method === 'silent') return 'Silent bids are simultaneous. These controls do not change their tie order.'; @@ -63,6 +66,7 @@ export function JuiceOfSapho({ const cyclic = option.scope === 'cyclicAuction'; const battle = option.scope === 'battleOrder'; const aggressor = option.scope === 'battleAggressor'; + const extortion = option.scope === 'extortion'; return (
{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 + ? `Take your Extortion choice ${option.mode} among unanswered players. Earlier declines and Moritani’s five-spice bank collection remain. You may decline instead of paying; the first actual three-spice payment ends all offers.` : cyclic ? `Bid ${option.mode} in the current cyclic lot. Provisional duration: this lot only. Existing bids, funding and passes stay committed. Later circuits use the changed cycle; the next lot returns to its normal opener.` : once @@ -87,7 +93,7 @@ export function JuiceOfSapho({ }} > {aggressor ? 'Become aggressor and discard Juice of Sapho' : - `${once || cyclic ? 'Bid' : battle ? 'Choose battles' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`} + `${once || cyclic ? 'Bid' : battle ? 'Choose battles' : extortion ? 'Take your Extortion choice' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`}
); diff --git a/components/moritani-extortion.tsx b/components/moritani-extortion.tsx index 23f185a6..ff48329d 100644 --- a/components/moritani-extortion.tsx +++ b/components/moritani-extortion.tsx @@ -18,12 +18,16 @@ export function MoritaniExtortionNotice({ game }: { game: GameView }) {

)} {pending && ( + <>

Moritani has collected the bank award. {payer?.name ?? 'The current player'} {' '}may pay {pending.amount} spice to prevent Extortion’s return, or decline. The first payment ends the opportunity; if everyone declines, the token returns to Moritani’s hidden supply.

+

Remaining choices: {pending.remaining.map(id => game.players.find(player => player.id === id)?.name ?? id).join(' → ')}.

+ {pending.saphoReordered &&

Juice of Sapho changed the remaining order. Earlier declines stay finished.

} + )} ); diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e9f63912..58064291 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,33 @@ open mode gates. ## Current checkpoint and work +**Sapho Extortion payer order — 18 October. Prototyped / Partial.** The general +ordered-action alternative now provisionally reorders only native Extortion's +unanswered payer suffix. Public replay preserves original membership and prior +declines; ledger answers retain the receipt. Every use expires the native payer +event, even if the same player remains next. Collected bank5, one payment3, +physical storm order and the user's placement-first policy remain. Persistent +controls display remaining names; existing minimal policies use real options. +A typed Semuta suffix parks one updated payer decision without paying/advancing. + +Two disjoint helper/fixture lanes, bounded source/continuation review and owner +integration pass **9 files115/115**, types/lint/build and six original-action/ +rendered-table programmes. Public Basic/Advanced cache1, real Extortion placement, +paid foreign1 entry/reveal, later actual Mentat placement-before-bank5 and one +Atreides decline use120/139 source actions. First/payment3 and last/payment3 or +all-decline use2–4 continuations: Moritani gains8 or5, the token retires or rotates +into hidden supply, and no earlier payer repeats. Four profiles make eight legal +holder/payer proposals and finish. Pure replay/no-op/recovered-reuse cases remain +separate. No dealt piece, wallet, clock, payer queue or receipt is installed. + +Frozen feedback corrected only a deliberate-invalid-mode test cast; the whole +batch then passed together. Actual GameTable and enabled/busy card/payer controls +render public order and payer-only affordability. No browser clicks or visual/ +deployed acceptance. Direct combined Semuta claim/capture, broader combinations, +complete modes and final assurance remain open. No saved-room/database/reset/ +restart/deployment operation. +[Canonical source, protocol and exact proof](MORITANI_EXTORTION_RULES.md#sapho-payer-ordering--18-october-2026). + **Sapho first with an unspent Advanced Guild — 18 October. Prototyped / Partial.** At a clean unstarted non-Guild boundary, first now reorders the remaining queue and retains the Guild's native next-boundary take/wait choice. A granted current diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 80f18a16..16086220 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,31 @@ # Dune implementation status +## 18 October 2026 — Sapho Extortion payer ordering + +The printed ordered-action alternative now has a provisional native Extortion +adapter for the unanswered suffix. Original membership/declines, collected bank5, +one payment3, placement-first policy and physical storm order remain. A public +replay receipt survives native answers; each use expires the old payer event, +even when that payer stays next. Persistent controls show current remaining +names, and existing minimal policies use actual private options. The typed +Semuta suffix parks one updated payer decision and restores without advancement. + +Two disjoint helper/fixture lanes, bounded source/continuation review and owner +integration pass9-file115/115, types/lint/build and six original-action/rendered- +table programmes. Public Basic/Advanced cache1, actual placed Terror, paid foreign1 +entry/reveal, later native Mentat placement-before-bank5 and a real earlier +decline use120/139 source actions. First/payment3 and last/payment3 or all-decline +use2–4 continuations; Moritani gains8/5 and the token retires/rotates. Four profiles +make eight legal holder/payer proposals and complete. Pure replay/no-op/recovered +reuse cases are separate; no dealt pieces, funds, queue or receipt is installed. + +Only a negative-test cast needed frozen-feedback repair; the full batch then +passed. [Canonical source, protocol and exact proof](MORITANI_EXTORTION_RULES.md#sapho-payer-ordering--18-october-2026). +No browser clicks, visual/deployed, direct combined Semuta claim/capture, wider +combination or full-mode acceptance. Final assurance remains due. No saved-room/ +database/reset/restart/deployment operation. + + ## 18 October 2026 — Sapho first with an unspent Advanced Guild A clean unstarted non-Guild boundary now admits provisional first ordering while diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md index 689c2779..e782e2c4 100644 --- a/docs/JUICE_OF_SAPHO_RUNTIME.md +++ b/docs/JUICE_OF_SAPHO_RUNTIME.md @@ -13,6 +13,15 @@ contract supersede the earlier absence of that mode below. The and a fixed public final opportunity in fresh Richese battles; old battles keep their early protocol. +## Extortion payer order — 18 October 2026 + +First/last now has a provisional adapter for native Moritani Extortion's unanswered +payer suffix. Earlier declines, collected bank5 and the one-payment3 limit remain; +every use expires the previous native payer event even when the next player does +not change. The [canonical source, controls and continuation contract](MORITANI_EXTORTION_RULES.md#sapho-payer-ordering--18-october-2026) +records original order replay, payment/recovery and the unchanged placement-first +policy. Other pending decisions are not general Sapho interruption windows. + ## First while the Advanced Guild remains — 18 October 2026 **Prototyped / Partial.** The physical face changes ordered turns and explicitly diff --git a/docs/MORITANI_EXTORTION_RULES.md b/docs/MORITANI_EXTORTION_RULES.md index 11563973..d49cc298 100644 --- a/docs/MORITANI_EXTORTION_RULES.md +++ b/docs/MORITANI_EXTORTION_RULES.md @@ -2,6 +2,77 @@ Initial primary-source audit, 2026-09-06; placement-first implementation interpretation selected by the user on 2026-09-28. No live-room action was performed for this rule work. +## Sapho payer ordering — 18 October 2026 + +**Prototyped / Partial.** Juice of Sapho's physical face permits first/last in +an ordered action, and Extortion supplies a finite ordered payer sequence. +The [Sapho source audit](JUICE_OF_SAPHO_ENGINE_AUDIT.md#all-printed-modes-and-applicable-systems) +identifies this composition. No Extortion-specific Sapho clarification is claimed. +Provisionally apply the general card to the **unanswered payer suffix**; this +online boundary is an explicit implementation interpretation under the standing +recommended-choice instruction. It changes neither printed payment nor the +user's placement-first policy below. + +While the exact native payer decision is current and otherwise clean, an +unreserved holder who is still an eligible unanswered payer may discard Sapho +to choose first/last. Completed declines stay completed. All other unanswered +players retain relative order; allies and insolvent players remain eligible. +Moritani is not added to its own payer queue. Current-first/already-last positions +cannot consume the card, and no one receives another opportunity. First/last +orders the optional **choice**, not a promised payment. + +The original storm payer queue and every use are public ordered-action records. +The ledger retains the receipt through native answers, and validation replays +each change against the original queue, preserving the completed prefix. The +native payer event gains a revision only after a use; older receipt-free events +retain their bytes. A queued last use may leave the same player next, but that +player's older answer event still expires. Physical `g.order`, cursor, token, +collected bank5, balances and later placement are unchanged by Sapho. Actual +payment-time affordability remains the native check. + +The next player receives the existing pay3/decline controls; a payment ends all +offers and retires the token, while all declines use native hidden-supply identity +rotation. The public notice displays the remaining names and explains an applied +Sapho order. Only the current payer sees affordability. The owner's hand panel +uses server-derived options; existing minimal policies can play the card and +answer the actual resulting event without strategy refinement. + +In the opted-in Semuta preview, the physical discard can pause after reordering. +Its typed continuation parks exactly one **updated** payer decision, binding the +receipt and event. Completion restores that decision without paying, advancing, +recollecting bank spice or restarting placement. Existing continuation formats +remain unchanged. Direct combined claim/capture proof remains open. + +Frozen **9 files115/115**, types/lint/build and six original-action/rendered-table +programmes pass. Genuine public Basic/Advanced setup/cache1, first native Terror +placement, paid foreign1 entry/reveal, later actual Mentat placement-before-bank5 +and an Atreides decline use120/139 source actions. First/payment3, last/payment3 +and last/all-decline use2–4 continuation actions. Moritani gains8 or5 from its +pre-placement balance; payment retires the token, otherwise native identity +rotation restores hidden supply. Prior declines never repeat. LAST leaves Guild +next but rejects its old event. Four profiles make eight legal holder/payer +proposals and complete without strategic refinement. + +Only original unopened Spice positions and scalar lotteries are selected. +No dealt hand, counter, wallet, clock, payer queue or receipt is installed. +Seven pure public-order cases cover replay, no-ops, prefix/relative order and +recovered reuse separately from the genuine programmes. Actual GameTable and +isolated enabled/busy card/payer controls render remaining order and owner-only +affordability. Framework aliases/CSS stubs mean no browser clicks or visual/ +deployed acceptance. Bounded source/continuation review found no concrete defect. +Frozen feedback corrected only the deliberate-invalid-mode test cast; all checks +then passed together without application rule repair. + +Private proof: `/home/quintine/.local/share/dune/checkpoints/20261018-sapho-extortion/frozen-integrated-2/report.json`, +base `2d89ddb6e1aaf63a178c22c4f511fa9f832477e7`, source tree +`0ffe797657fdcac40c92f9282eac32a8495e227703f9777bb729b08389e9dc82`. +Final checkpoint prose/reference evidence is a separately reviewed overlay; +private `documentation-overlay.json` and `git-result.json` bind it to the pushed +revision while engine/helpers/controls/tests remain identical to frozen proof. +Direct combined Semuta claim/capture, broader combinations, complete modes, +final assurance and browser/deployed acceptance remain open. No saved-room/ +database, reset, restart or deployment operation was performed. + ## Confirmed sequence The official token paragraph orders the events as follows: diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index c8b0f8b9..74dc11a7 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,16 @@ # Rule decision index +## Provisional Sapho Extortion payer order — 18 October 2026 + +Apply the general printed first/last ordered-action effect to Extortion's +unanswered payer suffix. Preserve earlier declines, native payer membership, +bank5, one payment3 and the user-selected placement-before-collection sequence. +A public replay receipt orders the suffix without changing physical storm order; +its revision expires older native answers even if the same payer remains next. +This is provisional composition, not an Extortion-specific publisher ruling. +[Canonical scope and source](MORITANI_EXTORTION_RULES.md#sapho-payer-ordering--18-october-2026). + + ## Provisional Sapho first with an unspent Guild — 18 October 2026 The face explicitly supersedes Guild for last, without settling competing first diff --git a/game/engine.ts b/game/engine.ts index 2dee8a43..371c859d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -507,6 +507,7 @@ import { type TerrorState, } from './moritani-terror'; import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionState, type ExtortionState } from './moritani-extortion'; +import { saphoExtortionEvent, validateSaphoExtortion, reorderSaphoExtortion, SaphoExtortionError } from './sapho-extortion'; import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions'; import { cashInCards } from './choam-karama'; import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullNexusCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull'; @@ -1400,6 +1401,15 @@ export type Game = { resume: NonNullable['resume']; stateSignature: string; } + | { + kind: 'saphoExtortionDiscard'; + player: string; + card: string; + event: string; + mode: 'first' | 'last'; + resume: NonNullable['resume']; + stateSignature: string; + } | { kind: 'saphoAuctionDiscard'; cyclic?: 'normal' | 'blackMarket'; @@ -5427,6 +5437,17 @@ type SaphoMovementDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'saphoMovementDiscard' } >; +type SaphoExtortionDiscardContinuation = Extract< + NonNullable['continuation'], + { kind: 'saphoExtortionDiscard' } +>; +function saphoExtortionDiscardSignature(g: Game, c: SaphoExtortionDiscardContinuation) { + return JSON.stringify({player: c.player, card: c.card, event: c.event, mode: c.mode, + parent: nullentropyParentSignature(g, c.resume, c.event), + turn: g.turn, phase: g.phase, advanced: g.advanced, order: g.order, + extortion: g.moritaniExtortion, + players: g.players.map(p => ({id: p.id, faction: p.faction}))}); +} function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuation) { return JSON.stringify({ player: c.player, card: c.card, event: c.event, mode: c.mode, @@ -6439,6 +6460,20 @@ function treacheryDiscardIntegrity(g: Game) { c.stateSignature === distransDiscardSignature(g, c), 'The completed private Distrans transfer no longer matches its fresh discard.', ); + } else if (continuation?.kind === 'saphoExtortionDiscard') { + const c = continuation, entry = batch.entries[0], state = g.moritaniExtortion; + const latest = state?.saphoOrder?.uses.at(-1), decision = c.resume.decision; + requireRule(g.phase === 8 && state?.stage === 'payment' && + latest?.event === c.event && latest.player === c.player && latest.mode === c.mode && + decision?.kind === 'moritaniExtortion' && decision.player === state.queue[state.cursor] && + decision.event === extortionEvent(state) && !g.decision && + batch.cause === 'sapho:extortion' && batch.entries.length === 1 && + entry.publicFace && entry.discardedBy === c.player && entry.card.id === c.card && + richeseCardDefinition(entry.card)?.card.effect === 'juiceOfSapho' && + !c.resume.response && !c.resume.pendingKarama && !c.resume.phaseOpening && + c.stateSignature === saphoExtortionDiscardSignature(g, c), + 'The used Juice of Sapho no longer matches its current Extortion payer order.'); + moritaniExtortionIntegrity(g); } else if (continuation?.kind === 'saphoMovementDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( @@ -7492,6 +7527,7 @@ function semutaOfferSupported( continuation.kind !== 'nexusChoamTrade' && continuation.kind !== 'nexusChoamBetrayal' && continuation.kind !== 'saphoMovementDiscard' && + continuation.kind !== 'saphoExtortionDiscard' && continuation.kind !== 'saphoAggressorDiscard' && continuation.kind !== 'saphoAuctionDiscard' && continuation.kind !== 'saphoBattleOrderDiscard' && @@ -7556,6 +7592,7 @@ function semutaOfferSupported( continuation.kind !== 'ornithopterDiscard' && continuation.kind !== 'saphoAuctionDiscard' && continuation.kind !== 'saphoMovementDiscard' && + continuation.kind !== 'saphoExtortionDiscard' && continuation.kind !== 'battleResolved' && continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'battleCleanup' && @@ -7571,6 +7608,13 @@ function semutaOfferSupported( continuation.kind !== 'karamaBattleDiscard') return false; if (continuation.kind === 'battleResolved' && !g.lastBattleContext?.mandatoryDiscard) return false; + if (continuation.kind === 'saphoExtortionDiscard' && + (g.phase !== 8 || g.moritaniExtortion?.stage !== 'payment' || + continuation.resume.decision?.kind !== 'moritaniExtortion' || + continuation.resume.decision.event !== extortionEvent(g.moritaniExtortion) || + continuation.resume.decision.player !== g.moritaniExtortion.queue[g.moritaniExtortion.cursor] || + continuation.resume.response || continuation.resume.pendingKarama || + continuation.resume.phaseOpening || g.decision || g.response)) return false; if ((continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved') && @@ -7661,6 +7705,7 @@ function semutaOfferSupported( continuation.kind === 'choamBalisetDiscard' || continuation.kind === 'choamGamontDiscard' || continuation.kind === 'choamJubbaDiscard' || + continuation.kind === 'saphoExtortionDiscard' || (continuation.kind === 'karamaCharityDiscard' || continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaBgCharityDiscard' || @@ -7920,6 +7965,11 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string g.decision = next.resume.decision; g.pendingKarama = next.resume.pendingKarama; g.phaseOpening = next.resume.phaseOpening; + } else if (next.kind === 'saphoExtortionDiscard') { + g.response = next.resume.response; + g.decision = next.resume.decision; + g.pendingKarama = next.resume.pendingKarama; + g.phaseOpening = next.resume.phaseOpening; } else if (next.kind === 'saphoMovementDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -11121,7 +11171,13 @@ function setupComplete(g: Game) { ); } function extortionEvent(state: ExtortionState): string { - return JSON.stringify(['moritaniExtortion', state.turn, state.token, state.cursor]); + return JSON.stringify(['moritaniExtortion', state.turn, state.token, state.cursor, + ...(state.saphoOrder ? [state.saphoOrder.uses.length] : [])]); +} +function extortionSaphoContext(g: Game) { + const state = g.moritaniExtortion!; + return {owner: state.owner, token: state.token, turn: state.turn, + stormOrder: g.order, queue: state.queue, cursor: state.cursor, order: state.saphoOrder}; } function extortionContext(g: Game) { return { turn: g.turn, phase: g.phase, players: g.players.map(player => ({ id: player.id, spice: player.spice })) }; @@ -11142,8 +11198,7 @@ function moritaniExtortionIntegrity(g: Game) { 'Extortion has lost its revealed token or Moritani owner.'); if (!pending) return; extortionRule(() => validateExtortionState(pending, extortionContext(g))); - const stormPayers = pending.stage === 'payment' - ? g.order.filter(id => id !== pending.owner) : null; + if (pending.stage === 'payment') extortionRule(() => validateSaphoExtortion(extortionSaphoContext(g))); const savedDecisions = pending.stage === 'payment' ? homeworldSavedDecisions(g).filter(decision => decision.kind === 'moritaniExtortion') : []; @@ -11153,10 +11208,8 @@ function moritaniExtortionIntegrity(g: Game) { g.moritaniTerror?.placementTurn === g.turn && savedDecisions.length === 1 && savedDecisions[0].player === pending.queue[pending.cursor] && - savedDecisions[0].event === extortionEvent(pending) && - pending.queue.length === stormPayers?.length && - pending.queue.every((seat, index) => seat === stormPayers?.[index]), - 'Extortion payment must resume in the original Mentat storm order.'); + savedDecisions[0].event === extortionEvent(pending), + 'Extortion payment must resume in its original Mentat scope and current ordered opportunity.'); } function settleMoritaniExtortion(g: Game, state: ExtortionState, recover: boolean) { requireRule(g.moritaniTerror && state.stage === 'settled', @@ -19378,7 +19431,7 @@ function offerMoritaniDuke(g: Game) { return true; } type SaphoOption = { - scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder'; + scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion'; event: string; mode: 'first' | 'last'; } | { scope: 'battleAggressor'; event: string; mode: 'aggressor' }; @@ -19456,13 +19509,19 @@ function saphoMovementIntegrity(g: Game) { protectedLast: last.player, }); } +function saphoExtortionDecision(g: Game) { + const state = g.moritaniExtortion, decision = g.decision; + return g.phase === 8 && state?.stage === 'payment' && !g.pendingTreacheryDiscard && + decision?.kind === 'moritaniExtortion' && decision.player === state.queue[state.cursor] && + decision.event === extortionEvent(state); +} function saphoCleanWindow(g: Game) { return ( g.status === 'playing' && !g.truthtrance && !g.phaseOpening && !g.response && - !g.decision && + (!g.decision || saphoExtortionDecision(g)) && !g.pendingNullentropy && !g.pendingRicheseGift && !g.pendingExchange && @@ -19533,6 +19592,17 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] { ) return []; const options: SaphoOption[] = []; + if (saphoExtortionDecision(g)) { + const context = extortionSaphoContext(g), event = extortionRule(() => saphoExtortionEvent(context)); + for (const mode of ['first', 'last'] as const) { + try { + reorderSaphoExtortion(context, {event, player: p.id, mode}); + options.push({scope: 'extortion', event, mode}); + } catch (error) { + if (!(error instanceof SaphoExtortionError)) throw error; + } + } + } const cyclic = saphoCyclicContext(g,p); if (cyclic) for (const mode of ['first','last'] as const) { try { @@ -19706,6 +19776,29 @@ function playSapho(g: Game, p: Player, action: Action) { } } settleRicheseLot(g); + } else if (option.scope === 'extortion') { + const state = g.moritaniExtortion!; + const ordered = extortionRule(() => reorderSaphoExtortion(extortionSaphoContext(g), + {event: option.event, player: p.id, mode: option.mode})); + state.queue = ordered.queue; + state.saphoOrder = ordered.order; + g.decision = {kind: 'moritaniExtortion', player: state.queue[state.cursor], event: extortionEvent(state)}; + const used = discard(g, p, 'richese-juice-of-sapho'); + log(g, `${p.name} discarded Juice of Sapho to take their Extortion choice ${option.mode} among the unanswered payers. Earlier declines and the five-spice collection remain; each player still chooses whether to pay the one three-spice payment.`, + {faction: p.faction, name: 'Juice of Sapho'}); + if (g.semutaPreview) { + const continuation: SaphoExtortionDiscardContinuation = { + kind: 'saphoExtortionDiscard', player: p.id, card: used.id, event: option.event, mode: option.mode, + resume: {response: g.response, decision: g.decision, + pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening}, stateSignature: '', + }; + const entries = [{card: used, discardedBy: p.id, publicFace: true}]; + g.decision = null; + if (semutaOfferSupported(g, continuation, entries)) { + continuation.stateSignature = saphoExtortionDiscardSignature(g, continuation); + stageTreacheryDiscard(g, 'sapho:extortion', entries, continuation); + } else g.decision = continuation.resume.decision; + } } else if (option.scope === 'battleOrder') { const next = battleOrderRule(() => reorderBattleChoosers({state: g.battleOrder, physicalOrder: g.order, turn: g.turn, afterBattle: battleOrderAfterBattle(g), pairs: quoteCombatBoard(g).battles, @@ -35129,6 +35222,9 @@ export function viewGame(state: Game, id: string) { player: g.decision.player, amount: 3 as const, canPay: me.id === g.decision.player ? me.spice >= 3 : null, + remaining: g.moritaniExtortion.queue.slice(g.moritaniExtortion.cursor), + completed: g.moritaniExtortion.queue.slice(0, g.moritaniExtortion.cursor), + saphoReordered: !!g.moritaniExtortion.saphoOrder, } : null, }, diff --git a/game/moritani-extortion.ts b/game/moritani-extortion.ts index 8f470fe1..084f9c5d 100644 --- a/game/moritani-extortion.ts +++ b/game/moritani-extortion.ts @@ -1,3 +1,5 @@ +import type { SaphoExtortionOrder } from './sapho-extortion'; + /** Isolated Extortion ledger; authorization and Mentat scheduling belong to the engine. * Source: docs/MORITANI_EXTORTION_RULES.md. No placement-relative priority is encoded. */ @@ -16,6 +18,8 @@ export type ExtortionState = { cursor: number; stage: 'reserved' | 'payment' | 'settled'; payer: string | null; + /** Public ordered-action exception; absent in original storm-order ledgers. */ + saphoOrder?: SaphoExtortionOrder; }; export type ExtortionTransfer = | { from: 'reservedBank'; to: string; amount: 5 } @@ -135,6 +139,7 @@ function clone(state: ExtortionState): ExtortionState { cursor: state.cursor, stage: state.stage, payer: state.payer, + ...(state.saphoOrder ? {saphoOrder: structuredClone(state.saphoOrder)} : {}), }; } @@ -209,7 +214,7 @@ export function answerExtortion( ); requireValid( state.queue[state.cursor] === playerId, - 'Wait for your Extortion turn in storm order.', + 'Wait for your current Extortion payment opportunity.', ); requireValid( typeof pay === 'boolean', diff --git a/game/reference.ts b/game/reference.ts index 720d03c5..cf498ddc 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -499,7 +499,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, provisional first ordering retains an unspent Guild’s native next-boundary take/wait choice; Guild may still act ahead. An already granted current Guild turn cannot be displaced, and a Karama-locked privilege stays locked. Last stays after the Guild even if the Guild later chooses to wait.', 'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.', 'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.', - 'Finish existing shipment, movement, card preparation and pending decisions before changing order. Your hand panel lists only currently available choices. A reserved card or a position you already hold cannot be selected.', + '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.', + 'Finish existing shipment, movement, card preparation and pending 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 and movement-order discards pause after their bidder/turn 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. 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 and cyclic lot duration are provisional conventions. Other phase ordering and partly completed combined-turn intervention remain unfinished. Silent bids retain simultaneous timing and storm tie order.', @@ -541,8 +542,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - '18 October unspent Guild first:7-file97/97, types/lint/build and six original-action/rendered-table programmes pass. Actual cache1/native wait uses49 source actions; real paid completed Richese prefix uses55. TAKE/WAIT preserves non-Guild order and paid Emperor2/Guild1 shipments. Granted-current and actual originally-dealt Karama cancellation both finish without restoring blocked timing. Four profiles make eight legal holder/Guild proposals. Two fixture assumptions were repaired, then all checks passed together. Prior late-aggressor and cyclic evidence remains in the linked feature docs. No browser clicks, visual/deployed, direct combined Semuta claim/capture, actual competing first, partly completed turn, full-mode or final-assurance acceptance', + '18 October Extortion:9-file115/115, types/lint/build and six original-action/rendered-table programmes pass. Public Basic/Advanced cache1, placed/revealed Terror, paid entry1, native Mentat placement-before-bank5 and an earlier decline use120/139 source actions. First/payment3 and last/payment3 or all-decline use2–4 continuations, preserving one payment and native token retirement/rotation. Four profiles make eight legal holder/payer proposals and complete. Pure replay/no-op/recovered reuse cases are separate. One deliberate-invalid-mode test cast was repaired; all checks then passed. Prior Guild, aggressor and cyclic evidence remains in linked feature docs. No browser clicks, visual/deployed, direct combined Semuta claim/capture, complete-mode or final-assurance acceptance', evidence: [ + 'game/sapho-extortion.ts', + 'tests/sapho-extortion.test.ts', + 'tests/fixture-sapho-extortion.ts', + 'tests/sapho-extortion-engine.test.ts', + 'docs/MORITANI_EXTORTION_RULES.md', 'tests/fixture-sapho-guild-first.ts', 'tests/sapho-guild-first-engine.test.ts', 'game/battle-outcome-window.ts', @@ -3180,9 +3186,9 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Revealing Extortion sets aside five spice from the bank in front of Moritani’s shield. Moritani collects it during Mentat Pause; it is not available to spend when the token is revealed.', 'For this table’s selected timing, Moritani’s normal Mentat Terror placement or relocation opportunity resolves first, including any Karama response. Only afterward does Moritani collect the five spice and offer the Extortion payment opportunity. A token recovered afterward cannot be placed again during that same Mentat Pause.', 'If Extortion is revealed during Mentat after placement has already finished, resolve that committed entry and its interruptions first. Then collect the bank award and offer payment before any player can end the turn.', - 'After collection, each non-Moritani player in storm order may pay Moritani three spice or decline. A player without three spice cannot pay but may decline. The first payment permanently removes Extortion from play and ends the offers; it does not undo the bank award.', + 'After collection, each non-Moritani player in storm order may pay Moritani three spice or decline. Sapho may provisionally reorder only unanswered choices; earlier declines cannot repeat. A player without three spice cannot pay but may decline. The first payment permanently removes Extortion from play and ends the offers; it does not undo the bank award.', 'If every eligible player declines, rotate Extortion and return it face down to Moritani’s hidden supply for a future placement. Other revealed Terror tokens have no corresponding recovery provision.', - 'These controls cover Extortion collection, the payment choice and return or removal, not complete Moritani expansion games. Public module starts remain unavailable.', + 'These controls cover Extortion collection, payer ordering, the payment choice and native return/removal. Supported native Moritani starts remain labelled previews; complete-mode acceptance is unfinished.', ], related: ['moritani-terror', 'moritani-terror-effects', 'mentat'], }, diff --git a/game/sapho-extortion.ts b/game/sapho-extortion.ts new file mode 100644 index 00000000..d9cfa162 --- /dev/null +++ b/game/sapho-extortion.ts @@ -0,0 +1,145 @@ +import { reorderOrderedOpportunity } from './ordered-opportunity'; + +export type SaphoExtortionUse = { + event: string; + player: string; + mode: 'first' | 'last'; + cursor: number; +}; +export type SaphoExtortionOrder = { + version: 1; + original: string[]; + uses: SaphoExtortionUse[]; +}; +export type SaphoExtortionContext = { + owner: string; + token: string; + turn: number; + stormOrder: readonly string[]; + queue: readonly string[]; + cursor: number; + order?: SaphoExtortionOrder; +}; +export type SaphoExtortionRequest = { + event: string; + player: string; + mode: 'first' | 'last'; +}; +export class SaphoExtortionError extends Error { + constructor(message: string) { + super(message); + this.name = 'SaphoExtortionError'; + } +} +function requireExtortion(value: unknown, message: string): asserts value { + if (!value) throw new SaphoExtortionError(message); +} +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; +const count = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +const ids = (value: unknown): value is readonly string[] => + Array.isArray(value) && [...value].every(identifier) && new Set(value).size === value.length; +const exact = (value: unknown, keys: string): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) && + [Object.prototype, null].includes(Object.getPrototypeOf(value)) && + Object.keys(value).sort().join(',') === keys; +const eventFor = (token: string, turn: number, cursor: number, revision: number): string => + `saphoExtortion:${JSON.stringify([token, turn, cursor, revision])}`; + +/** Reorder one still-unstarted payer opportunity, never a payment or grant. */ +function applyUse( + original: readonly string[], + queue: readonly string[], + use: SaphoExtortionUse, +): string[] { + const remaining = queue.slice(use.cursor); + requireExtortion(use.mode === 'first' || use.mode === 'last', 'Choose first or last.'); + requireExtortion( + identifier(use.player) && remaining.includes(use.player), + 'Only a pending non-owner Extortion payer can change position.', + ); + requireExtortion( + use.mode === 'first' ? remaining[0] !== use.player : remaining[remaining.length - 1] !== use.player, + 'This payer is already in that Extortion position.', + ); + try { + const next = reorderOrderedOpportunity({ + event: use.event, eligible: original, completed: queue.slice(0, use.cursor), + remaining, current: remaining[0] ?? null, currentStarted: false, + }, { event: use.event, player: use.player, position: use.mode }); + return [...next.completed, ...next.remaining]; + } catch (error) { + throw new SaphoExtortionError(error instanceof Error ? error.message : 'Invalid Extortion payer order.'); + } +} + +function validate(context: SaphoExtortionContext): string[] { + requireExtortion( + context && typeof context === 'object' && identifier(context.owner) && identifier(context.token) && + count(context.turn) && context.turn > 0 && ids(context.stormOrder) && + context.stormOrder.includes(context.owner), + 'Extortion needs its original owner, revealed token, turn and unique storm order.', + ); + const original = context.stormOrder.filter(player => player !== context.owner); + requireExtortion( + ids(context.queue) && context.queue.length === original.length && + context.queue.every(player => original.includes(player)) && + count(context.cursor) && context.cursor <= context.queue.length, + 'Extortion needs its complete public payer queue and a safe current cursor.', + ); + if (context.order === undefined) { + requireExtortion( + JSON.stringify(context.queue) === JSON.stringify(original), + 'An Extortion queue without a Sapho record must retain its original storm order.', + ); + return original; + } + const order = context.order; + requireExtortion( + exact(order, 'original,uses,version') && order.version === 1 && ids(order.original) && + JSON.stringify(order.original) === JSON.stringify(original) && Array.isArray(order.uses), + 'The Sapho Extortion record must retain its exact original payer order.', + ); + let queue = [...original], previousCursor = 0; + for (const [revision, use] of order.uses.entries()) { + requireExtortion( + exact(use, 'cursor,event,mode,player') && count(use.cursor) && + use.cursor >= previousCursor && use.cursor <= context.cursor && + use.event === eventFor(context.token, context.turn, use.cursor, revision), + 'Sapho Extortion history has a stale event, revision or completed payer cursor.', + ); + queue = applyUse(original, queue, use); + previousCursor = use.cursor; + } + requireExtortion( + JSON.stringify(context.queue) === JSON.stringify(queue), + 'The Extortion payer queue does not match its original Sapho history.', + ); + return original; +} + +/** Public order only: no spice, card custody, insolvency or payment inspection. */ +export function validateSaphoExtortion(context: SaphoExtortionContext): void { + validate(context); +} +export function saphoExtortionEvent(context: SaphoExtortionContext): string { + validate(context); + return eventFor(context.token, context.turn, context.cursor, context.order?.uses.length ?? 0); +} +export function reorderSaphoExtortion( + context: SaphoExtortionContext, + request: SaphoExtortionRequest, +): { queue: string[]; order: SaphoExtortionOrder } { + const original = validate(context); + const event = eventFor(context.token, context.turn, context.cursor, context.order?.uses.length ?? 0); + requireExtortion(request && typeof request === 'object' && request.event === event, + 'This Sapho Extortion event has expired.'); + const use: SaphoExtortionUse = { event, player: request.player, mode: request.mode, cursor: context.cursor }; + const queue = applyUse(original, context.queue, use); + return { + queue, + order: { version: 1, original: [...original], + uses: [...(context.order?.uses ?? []).map(previous => ({ ...previous })), use] }, + }; +} diff --git a/tests/fixture-sapho-extortion.ts b/tests/fixture-sapho-extortion.ts new file mode 100644 index 00000000..f952dfaf --- /dev/null +++ b/tests/fixture-sapho-extortion.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { richeseCards } from '../game/richese-cards'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +const sapho = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(sapho); +export const EXTORTION_SAPHO = sapho.id; +export type SaphoExtortionStep = { actor: string; action: Action }; +export const saphoExtortionPlayer = (game: Game, actor = 'emperor') => { + const player = game.players.find(player => player.id === actor); assert.ok(player); return player; +}; +export const saphoExtortionClean = (game: Game) => !game.response && !game.phaseOpening && + !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; +export const saphoExtortionPending = (game: Game) => !game.response && !game.phaseOpening && + !game.truthtrance && !game.pendingTreacheryDiscard && game.decision?.kind === 'moritaniExtortion'; +export function actSaphoExtortion(game: Game, actor: string, action: Action, + actions: SaphoExtortionStep[] = []): Game { + const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action)); + actions.push({ actor, action: structuredClone(action) }); return next; +} +/** Native optional controls are declined; the source programme owns every + * Extortion reveal, placement and payment instead of inventing those choices. */ +export function nextSaphoExtortionStep(game: Game): SaphoExtortionStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + const decision = game.decision; + if (decision?.kind === 'moritaniTerror' || decision?.kind === 'moritaniExtortion') return null; + if (decision?.kind === 'moritaniSetup') return { actor: decision.player, + action: { type: 'decision', territory: 'carthag', sector: 11 } }; + if (decision?.kind === 'moritaniPlacement') return { actor: decision.player, + action: { type: 'decision', decline: true } }; + if (decision?.kind === 'moritaniAssassinate') return { actor: decision.player, + action: { type: 'decision', event: decision.event, decline: true } }; + if (decision?.kind === 'guildTiming') return { actor: decision.player, + action: { type: 'decision', take: false } }; + if (decision?.kind === 'richeseCache') { + const card = game.richeseCache!.find(card => card.id === EXTORTION_SAPHO) ?? game.richeseCache![0]; assert.ok(card); + return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event, + card: card.id, method: 'onceAround', direction: 'clockwise' } }; + } + if (decision?.kind === 'richeseUnbid') return { actor: decision.player, + action: { type: 'decision', event: game.richeseBidding!.event, keep: false } }; + } + const lot = game.richeseAuction; + if (saphoExtortionClean(game) && lot?.cardId === EXTORTION_SAPHO && !lot.outcome && + lot.active === 'emperor' && lot.bid === 0) + return { actor: 'emperor', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } }; + return nextRicheseStrongholdsNativeStep(game); +} +export function advanceSaphoExtortion(game: Game, until: (state: Game) => boolean, + actions: SaphoExtortionStep[] = []): Game { + for (let count = 0; count < 2400; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Extortion programme ended before its next control'); + const step = nextSaphoExtortionStep(game); assert.ok(step, 'Choose the actual owned Extortion control before advancing'); + game = actSaphoExtortion(game, step.actor, step.action, actions); + } + throw Error('The original Sapho/Extortion programme did not reach its finite boundary'); +} +function orderOriginalLand(game: Game) { + const front: Game['spiceDeck'] = []; + for (let count = 0; count < (game.advanced ? 2 : 1); count++) { + const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(index >= 0); + front.push(game.spiceDeck.splice(index, 1)[0]); + } + game.spiceDeck.unshift(...front); +} +export function saphoExtortionAnswer(game: Game, pay: boolean): SaphoExtortionStep { + const decision = game.decision; assert.ok(decision?.kind === 'moritaniExtortion'); + return { actor: decision.player, action: { type: 'decision', event: decision.event, pay } }; +} + +/** Public original setup, actual paid cache1 Sapho, first native Mentat Terror + * placement, paid foreign entry and reveal, then another actual placement + * before bank5. The original seat/join order leaves four native payers; an + * actual Atreides decline leaves the holder inside the unasked suffix. + * Only unopened Spice positions and original scalar lotteries are controlled. + * No dealt card, physical counter, wallet, clock, queue or receipt is installed. */ +export function createSaphoExtortionFixture(options: { advanced?: boolean; priorDecline?: boolean } = {}) { + const advanced = options.advanced ?? false; + let game = createGame('SAPHOEXTORTION', newPlayer('richese', 'Richese', 'richese'), advanced, ['ecaz', 'choam']); + for (const faction of ['atreides', 'guild', 'emperor', 'moritani'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + const actions: SaphoExtortionStep[] = []; + const act = (actor: string, action: Action) => { game = actSaphoExtortion(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSaphoExtortion(game, until, actions); }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + orderOriginalLand(game); + reach(state => saphoExtortionPlayer(state).hand.some(card => card.id === EXTORTION_SAPHO)); + const acquired = structuredClone(game); + reach(state => state.turn === 1 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement'); + const token = game.moritaniTerror!.tokens.find(token => token.kind === 'extortion' && token.status === 'available'); assert.ok(token); + const beforePlacement = structuredClone(game), placement: SaphoExtortionStep = { actor: 'moritani', action: { + type: 'decision', token: token.id, territory: 'habbanya_ridge_sietch', + } }; + act(placement.actor, placement.action); + reach(state => state.turn === 2 && state.phase === 1 && saphoExtortionClean(state)); + const placed = structuredClone(game); + orderOriginalLand(game); + reach(state => state.phase === 5 && state.active === 'emperor' && saphoExtortionClean(state)); + const beforeEntry = structuredClone(game), entry: SaphoExtortionStep = { actor: 'emperor', action: { + type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount: 1, elite: 0, allyPayment: 0, + } }; + act(entry.actor, entry.action); + reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniTerror'); + const offered = structuredClone(game), reveal: SaphoExtortionStep = { actor: 'moritani', + action: { type: 'decision', reveal: true } }; + act(reveal.actor, reveal.action); + reach(saphoExtortionClean); + const revealed = structuredClone(game); + reach(state => state.turn === 2 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement'); + const beforeMentatPlacement = structuredClone(game); + const later = game.moritaniTerror!.tokens.find(token => token.kind === 'sabotage' && token.status === 'available'); assert.ok(later); + const mentatPlacement: SaphoExtortionStep = { actor: 'moritani', action: { + type: 'decision', token: later.id, territory: 'sietch_tabr', + } }; + act(mentatPlacement.actor, mentatPlacement.action); + reach(saphoExtortionPending); + const collected = structuredClone(game), declines: { before: Game; step: SaphoExtortionStep; after: Game }[] = []; + if (options.priorDecline !== false) { + const before = structuredClone(game), step = saphoExtortionAnswer(game, false); + assert.equal(step.actor, 'atreides', 'The original turn-two Storm/join order supplies the completed prefix'); + act(step.actor, step.action); + declines.push({ before, step, after: structuredClone(game) }); + } + const queue = game.moritaniExtortion!; + assert.ok(queue.queue.slice(queue.cursor).includes('emperor')); + assert.notEqual(queue.queue[queue.cursor], 'emperor'); + assert.notEqual(queue.queue.at(-1), 'emperor'); + return { game, lobby, setup, afterSetup, acquired, beforePlacement, placement, placed, beforeEntry, entry, + offered, reveal, revealed, beforeMentatPlacement, mentatPlacement, collected, declines, + owner: 'emperor', moritani: 'moritani', card: EXTORTION_SAPHO, token: token.id, laterToken: later.id, + advanced, priorDecline: options.priorDecline !== false, sourceActionCount: actions.length, actions, + staging: ['Only unopened land Spice positions and original scalar lotteries are selected. Public join order Richese/Atreides/Guild/Emperor/Moritani, original setup, actual cache1, initial native Extortion placement, paid foreign1 entry, revelation and later placement-before-bank5 create the real payer queue. No postdeal custody, counters, wallets, clocks, payer order or receipt is assigned.'] }; +} +export type SaphoExtortionFixture = ReturnType; +export function saphoExtortionAction(game: Game, mode: 'first' | 'last'): Action { + const option = viewGame(game, 'emperor').saphoOptions.find(option => option.scope === 'extortion' && option.mode === mode); + assert.ok(option, `The actual pending unasked suffix needs a meaningful Sapho-${mode} option`); + return { type: 'card', card: EXTORTION_SAPHO, ...option }; +} + +/** Each answer uses the actual current native payer and current event. The + * first real payment ends offers; an all-decline branch returns the real token + * through the native hidden-supply rotation. */ +export function completeSaphoExtortion(fixture: SaphoExtortionFixture, mode: 'first' | 'last', payOwner = false) { + let game = structuredClone(fixture.game); + const actions: SaphoExtortionStep[] = [], answers: { before: Game; step: SaphoExtortionStep; after: Game }[] = []; + const previous = saphoExtortionAnswer(game, false), action = saphoExtortionAction(game, mode); + game = actSaphoExtortion(game, fixture.owner, action, actions); + const ordered = structuredClone(game); + for (let count = 0; game.moritaniExtortion && count < fixture.game.players.length; count++) { + assert.ok(saphoExtortionPending(game)); + const before = structuredClone(game), step = saphoExtortionAnswer(game, payOwner && game.decision!.player === fixture.owner); + game = actSaphoExtortion(game, step.actor, step.action, actions); + answers.push({ before, step, after: structuredClone(game) }); + } + assert.equal(game.moritaniExtortion, undefined, 'The real Extortion obligation settles finitely'); + return { game, action, previous, ordered, answers, actions, mode, payOwner }; +} diff --git a/tests/sapho-extortion-engine.test.ts b/tests/sapho-extortion-engine.test.ts new file mode 100644 index 00000000..5e10620c --- /dev/null +++ b/tests/sapho-extortion-engine.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { + actSaphoExtortion, completeSaphoExtortion, createSaphoExtortionFixture, + saphoExtortionAnswer, saphoExtortionPending, saphoExtortionPlayer as player, + type SaphoExtortionFixture, +} from './fixture-sapho-extortion'; + +function source(f: SaphoExtortionFixture) { + assert.equal(f.game.advanced, f.advanced); + assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'start')); + assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.ok(player(f.acquired).hand.some(card => card.id === f.card)); + assert.equal(player(f.afterSetup, f.moritani).forces['carthag:11'], 6); + assert.equal(f.beforePlacement.turn, 1); + assert.equal(f.beforePlacement.decision?.kind, 'moritaniPlacement'); + assert.equal(f.placed.moritaniTerror!.tokens.find(token => token.id === f.token)?.location, 'habbanya_ridge_sietch'); + assert.equal(player(f.beforeEntry).spice - player(f.revealed).spice, 1); + assert.equal(player(f.beforeEntry).reserves - player(f.revealed).reserves, 1); + assert.equal(player(f.revealed).forces['habbanya_ridge_sietch:17'], 1); + assert.equal(f.offered.decision?.kind, 'moritaniTerror'); + assert.equal(viewGame(f.offered, f.moritani).terrorEntry!.kind, 'extortion'); + assert.equal(player(f.offered, f.moritani).spice, player(f.revealed, f.moritani).spice); + assert.deepEqual(f.revealed.moritaniExtortion!.bank, { reserved: 5, collected: 0 }); + assert.equal(viewGame(f.revealed, f.owner).extortion.deferred, 5); + assert.equal(f.beforeMentatPlacement.decision?.kind, 'moritaniPlacement'); + assert.equal(f.beforeMentatPlacement.phase, 8); + assert.deepEqual(f.beforeMentatPlacement.moritaniExtortion!.bank, { reserved: 5, collected: 0 }); + assert.equal(f.collected.moritaniTerror!.tokens.find(token => token.id === f.laterToken)?.location, 'sietch_tabr'); + assert.equal(player(f.collected, f.moritani).spice - player(f.beforeMentatPlacement, f.moritani).spice, 5); + assert.deepEqual(f.collected.moritaniExtortion!.bank, { reserved: 0, collected: 5 }); + assert.deepEqual(f.collected.moritaniExtortion!.queue, f.collected.order.filter(actor => actor !== f.moritani)); + assert.equal(f.declines.length, 1); + assert.equal(f.declines[0].step.actor, 'atreides'); + assert.equal(f.declines[0].step.action.pay, false); + assert.ok(saphoExtortionPending(f.game)); + assert.equal(f.game.moritaniExtortion!.cursor, 1); + assert.equal(f.sourceActionCount, f.actions.length); +} +function reordered(f: SaphoExtortionFixture, result: ReturnType) { + const before = f.game.moritaniExtortion!, after = result.ordered.moritaniExtortion!; + const rest = before.queue.slice(before.cursor).filter(actor => actor !== f.owner); + assert.equal(result.action.scope, 'extortion'); + assert.deepEqual(after.queue, [...before.queue.slice(0, before.cursor), + ...(result.mode === 'first' ? [f.owner, ...rest] : [...rest, f.owner])]); + assert.equal(after.cursor, before.cursor); + assert.deepEqual(after.bank, before.bank); + assert.equal(after.token, f.token); + assert.equal(after.payer, null); + assert.ok(after.saphoOrder); + assert.deepEqual(result.ordered.order, f.game.order); + assert.equal(result.ordered.moritaniTerror!.placementTurn, f.game.moritaniTerror!.placementTurn); + assert.equal(result.ordered.moritaniTerror!.tokens.find(token => token.id === f.token)?.status, 'extortion'); + assert.equal(player(result.ordered, f.moritani).spice, player(f.game, f.moritani).spice); + assert.equal(result.ordered.discard.filter(card => card.id === f.card).length, 1); + assert.ok(result.ordered.decision?.kind === 'moritaniExtortion'); + assert.notEqual(result.ordered.decision.event, result.previous.action.event); + assert.throws(() => actSaphoExtortion(result.ordered, result.previous.actor, result.previous.action), + /Extortion|event|turn|expired|current|decision/i); + assert.equal(result.answers.some(answer => answer.step.actor === 'atreides'), false, 'The completed real decline is not asked again'); + for (const answer of result.answers) { + assert.equal(answer.step.actor, answer.before.moritaniExtortion!.queue[answer.before.moritaniExtortion!.cursor]); + if (answer.after.moritaniExtortion) assert.deepEqual(answer.after.moritaniExtortion.saphoOrder, after.saphoOrder, + 'The original ledger answer clone retains the applied local order'); + } + assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1); + assert.equal(result.game.moritaniExtortion, undefined); + assert.equal(result.game.decision?.kind === 'moritaniExtortion', false); +} + +for (const advanced of [false, true]) { + const rules = advanced ? 'Advanced' : 'Basic'; + void test(`genuine ${rules} paid Sapho first preserves a real earlier Extortion decline and ends offers with one payment3`, () => { + const f = createSaphoExtortionFixture({ advanced }); source(f); + const result = completeSaphoExtortion(f, 'first', true); reordered(f, result); + assert.equal(result.answers.length, 1); + assert.equal(result.answers[0].step.actor, f.owner); + assert.equal(result.answers[0].step.action.pay, true); + assert.equal(player(result.game).spice, player(f.game).spice - 3); + assert.equal(player(result.game, f.moritani).spice, player(f.game, f.moritani).spice + 3); + assert.equal(player(result.game, f.moritani).spice - player(f.beforeMentatPlacement, f.moritani).spice, 8); + assert.equal(result.game.moritaniTerror!.tokens.find(token => token.id === f.token)?.status, 'removed'); + assert.equal(viewGame(result.game, f.owner).extortion.pending, null); + assert.throws(() => actSaphoExtortion(result.game, f.owner, result.answers[0].step.action)); + }); + void test(`genuine ${rules} Sapho last preserves its completed payer prefix and all declines rotate the recovered Terror supply`, () => { + const f = createSaphoExtortionFixture({ advanced }); source(f); + const result = completeSaphoExtortion(f, 'last'); reordered(f, result); + const native = f.game.moritaniExtortion!; + assert.deepEqual(result.answers.map(answer => answer.step.actor), + [...native.queue.slice(native.cursor).filter(actor => actor !== f.owner), f.owner]); + assert.ok(result.answers.every(answer => answer.step.action.pay === false)); + assert.equal(player(result.game, f.moritani).spice, player(f.game, f.moritani).spice); + for (const actor of native.queue) assert.equal(player(result.game, actor).spice, player(f.game, actor).spice); + assert.equal(result.game.moritaniTerror!.tokens.length, 6); + assert.equal(result.game.moritaniTerror!.tokens.some(token => token.id === f.token), false); + const recovered = result.game.moritaniTerror!.tokens.find(token => token.kind === 'extortion'); assert.ok(recovered); + assert.equal(recovered.status, 'available'); + assert.equal(recovered.location, null); + assert.equal(result.game.moritaniTerror!.tokens.find(token => token.id === f.laterToken)?.location, 'sietch_tabr'); + }); +} + +void test('all four minimal policies use actual Sapho Extortion options and answer the resulting native payer control legally', () => { + const f = createSaphoExtortionFixture({ advanced: true }); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const holder = viewGame(f.game, f.owner); + holder.players.find(player => player.id === f.owner)!.bot = difficulty; + const action = botActions(holder).find(action => action.type === 'card' && action.card === f.card && action.scope === 'extortion'); + assert.ok(action, `${difficulty} retains the real owned Sapho choice during the native payer decision`); + const ordered = actSaphoExtortion(structuredClone(f.game), f.owner, action); + assert.ok(saphoExtortionPending(ordered)); + const payer = ordered.decision!.player, view = viewGame(ordered, payer); + view.players.find(player => player.id === payer)!.bot = difficulty; + const answer = botActions(view).find(action => action.type === 'decision' && typeof action.pay === 'boolean'); + assert.ok(answer, `${difficulty} answers the actual native payer event`); + const next = actSaphoExtortion(ordered, payer, answer); + assert.equal(next.discard.filter(card => card.id === f.card).length, 1); + assert.deepEqual(next.order, f.game.order); + if (next.moritaniExtortion) { + assert.equal(next.moritaniExtortion.cursor, ordered.moritaniExtortion!.cursor + 1); + assert.equal(next.decision?.kind, 'moritaniExtortion'); + const actual = saphoExtortionAnswer(next, false); + assert.equal(actual.actor, next.moritaniExtortion.queue[next.moritaniExtortion.cursor]); + } else assert.equal(answer.pay, true); + } +}); diff --git a/tests/sapho-extortion.test.ts b/tests/sapho-extortion.test.ts new file mode 100644 index 00000000..8da52e0e --- /dev/null +++ b/tests/sapho-extortion.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + saphoExtortionEvent, + validateSaphoExtortion, + reorderSaphoExtortion, + SaphoExtortionError, + type SaphoExtortionContext, + type SaphoExtortionOrder, +} from '../game/sapho-extortion'; + +const initial = (): SaphoExtortionContext => ({ + owner: 'm', token: 'original-extortion-token', turn: 3, + stormOrder: ['a', 'm', 'b', 'c', 'd'], queue: ['a', 'b', 'c', 'd'], cursor: 0, +}); +const move = (context: SaphoExtortionContext, player: string, mode: 'first' | 'last'): SaphoExtortionContext => ({ + ...context, ...reorderSaphoExtortion(context, { event: saphoExtortionEvent(context), player, mode }), +}); +const clone = (value: T): T => structuredClone(value); + +// These are public finite-order inputs. Changing a cursor represents the +// caller's already-completed native opportunity; no payment or card custody +// claim is established by this pure adapter. +void test('Sapho Extortion records the original owner-excluded storm queue and an immutable first-position use', () => { + const context = initial(), before = clone(context); + Object.freeze(context.queue); Object.freeze(context.stormOrder); Object.freeze(context); + assert.equal(saphoExtortionEvent(context), 'saphoExtortion:["original-extortion-token",3,0,0]'); + const result = reorderSaphoExtortion(context, { event: saphoExtortionEvent(context), player: 'd', mode: 'first' }); + assert.deepEqual(result.queue, ['d', 'a', 'b', 'c']); + assert.deepEqual(result.order.original, ['a', 'b', 'c', 'd']); + assert.equal(result.order.version, 1); + assert.deepEqual(result.order.uses, [{ event: saphoExtortionEvent(context), player: 'd', mode: 'first', cursor: 0 }]); + validateSaphoExtortion({ ...context, ...result }); + assert.deepEqual(context, before); + assert.notEqual(result.queue, context.queue); +}); + +void test('later first and last uses preserve completed payers and the relative order of every other pending payer', () => { + const first = move(initial(), 'd', 'first'); + const afterCompleted = { ...first, cursor: 1 }, before = clone(afterCompleted); + const next = move(afterCompleted, 'b', 'first'); + assert.deepEqual(next.queue, ['d', 'b', 'a', 'c']); + assert.equal(next.queue[0], afterCompleted.queue[0]); + assert.deepEqual(next.queue.filter(player => player !== 'b'), afterCompleted.queue.filter(player => player !== 'b')); + const last = move(next, 'a', 'last'); + assert.deepEqual(last.queue, ['d', 'b', 'c', 'a']); + assert.deepEqual(last.queue.filter(player => player !== 'a'), next.queue.filter(player => player !== 'a')); + assert.deepEqual(last.order!.uses.map(use => use.cursor), [0, 1, 1]); + assert.equal(saphoExtortionEvent(last), 'saphoExtortion:["original-extortion-token",3,1,3]'); + validateSaphoExtortion(last); + assert.deepEqual(afterCompleted, before); +}); + +void test('a fresh repeated use by the same pending holder has its own revision while recovered-card custody stays external', () => { + const context = initial(), originalEvent = saphoExtortionEvent(context); + const first = move(context, 'b', 'first'); + assert.deepEqual(first.queue, ['b', 'a', 'c', 'd']); + const last = move(first, 'b', 'last'); + assert.deepEqual(last.queue, ['a', 'c', 'd', 'b']); + assert.equal(last.order!.uses.length, 2); + assert.notEqual(last.order!.uses[0].event, last.order!.uses[1].event); + const reused = move(last, 'b', 'first'); + assert.deepEqual(reused.queue, ['b', 'a', 'c', 'd']); + assert.equal(reused.order!.uses.length, 3); + validateSaphoExtortion(reused); + assert.throws(() => reorderSaphoExtortion(last, { event: originalEvent, player: 'b', mode: 'first' }), /expired/); + reused.order!.uses[0].player = 'c'; + assert.equal(first.order!.uses[0].player, 'b', 'A later result must not alias earlier history records'); +}); + +void test('current-first, already-last, completed, owner and foreign payer requests are unavailable', () => { + const context = { ...initial(), cursor: 1 }, before = clone(context); + for (const [player, mode] of [['b', 'first'], ['d', 'last'], ['a', 'first'], ['m', 'first'], ['outsider', 'last']] as const) + assert.throws(() => reorderSaphoExtortion(context, { event: saphoExtortionEvent(context), player, mode }), SaphoExtortionError); + for (const cursor of [3, 4]) { + const ended = { ...context, cursor }; + validateSaphoExtortion(ended); + for (const mode of ['first', 'last'] as const) + assert.throws(() => reorderSaphoExtortion(ended, { event: saphoExtortionEvent(ended), player: 'd', mode }), SaphoExtortionError); + } + assert.deepEqual(context, before); +}); + +void test('history replay rejects altered original order, queue, event revision and decreasing or future cursors', () => { + const first = move({ ...initial(), cursor: 1 }, 'c', 'first'); + const valid = move({ ...first, cursor: 2 }, 'd', 'first'); + assert.deepEqual(valid.queue, ['a', 'c', 'd', 'b']); + validateSaphoExtortion(valid); + const changes: ((context: SaphoExtortionContext & { order: SaphoExtortionOrder }) => void)[] = [ + context => { context.order.original.reverse(); }, + context => { context.queue = ['a', 'd', 'c', 'b']; }, + context => { context.order.uses[1].event = context.order.uses[0].event; }, + context => { context.order.uses[1].cursor = 0; + context.order.uses[1].event = 'saphoExtortion:["original-extortion-token",3,0,1]'; }, + context => { context.order.uses[1].cursor = 3; + context.order.uses[1].event = 'saphoExtortion:["original-extortion-token",3,3,1]'; }, + context => { context.order.uses[0].player = 'a'; }, + context => { context.order.uses.pop(); }, + context => { context.token = 'another-extortion-token'; }, + context => { context.turn++; }, + context => { Object.assign(context.order.uses[0], { extra: undefined }); }, + context => { Object.assign(context.order, { version: 2 }); }, + ]; + for (const change of changes) { + const invalid = clone(valid) as SaphoExtortionContext & { order: SaphoExtortionOrder }; + change(invalid); + assert.throws(() => validateSaphoExtortion(invalid), SaphoExtortionError); + assert.throws(() => saphoExtortionEvent(invalid), SaphoExtortionError); + } +}); + +void test('unstamped queues retain exact storm order and malformed public membership or cursors reject', () => { + const context = initial(); + for (const invalid of [ + { ...context, owner: 'missing' }, { ...context, token: '' }, + { ...context, turn: 0 }, { ...context, turn: Number.MAX_SAFE_INTEGER + 1 }, + { ...context, stormOrder: ['a', 'm', 'b', 'c', 'c'] }, + { ...context, queue: ['b', 'a', 'c', 'd'] }, + { ...context, queue: ['a', 'b', 'c'] }, { ...context, queue: ['a', 'b', 'c', 'm'] }, + { ...context, queue: ['a', 'b', 'c', 'c'] }, + ...[-1, 0.5, NaN, Infinity, 5, Number.MAX_SAFE_INTEGER + 1].map(cursor => ({ ...context, cursor })), + ]) assert.throws(() => validateSaphoExtortion(invalid), SaphoExtortionError); + assert.throws(() => reorderSaphoExtortion(context, { + event: saphoExtortionEvent(context), player: 'b', mode: 'middle', + } as unknown as Parameters[1]), /first or last/); +}); + +void test('native completion and a different original Extortion token invalidate an earlier public event', () => { + const context = initial(), event = saphoExtortionEvent(context); + const afterCompletion = { ...context, cursor: 1 }; + assert.equal(saphoExtortionEvent(afterCompletion), 'saphoExtortion:["original-extortion-token",3,1,0]'); + assert.throws(() => reorderSaphoExtortion(afterCompletion, { event, player: 'c', mode: 'first' }), /expired/); + const anotherToken = { ...context, token: 'second-original-token' }; + assert.notEqual(saphoExtortionEvent(anotherToken), event); + assert.throws(() => reorderSaphoExtortion(anotherToken, { event, player: 'c', mode: 'first' }), /expired/); + const completed = { ...move(afterCompletion, 'd', 'first'), cursor: 4 }; + validateSaphoExtortion(completed); + assert.equal(completed.queue.length, 4, 'Reordering never creates another payer or payment opportunity'); +});