diff --git a/components/game-table.tsx b/components/game-table.tsx index 4017e0dd..ca28904b 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -213,6 +213,7 @@ import { import { TERROR_DEFINITIONS } from '@/game/moritani-terror'; import { MoritaniRetention } from './moritani-retention'; import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assassinate'; +import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion'; export function GameTable({ game: g, send, @@ -1048,6 +1049,7 @@ export function GameTable({ + @@ -2321,6 +2323,8 @@ export function GameTable({ ? g.decision.source === 'nexus' ? 'Moritani Nexus · after-loss choice' : 'Moritani · allied card retention' : g.decision.kind === 'moritaniTerror' ? 'Moritani · Terror entry reaction' + : g.decision.kind === 'moritaniExtortion' + ? 'Moritani · Extortion payment' : g.decision.kind === 'moritaniSetup' ? 'Moritani · final deployment' : g.decision.kind === 'moritaniPlacement' @@ -2613,6 +2617,8 @@ export function GameTable({ ) : g.decision.kind === 'moritaniPlacement' ? ( + ) : g.decision.kind === 'moritaniExtortion' ? ( + ) : g.decision.kind === 'choamStorm' ? ( ) : g.decision.kind === 'choamMovement' ? ( diff --git a/components/moritani-extortion.tsx b/components/moritani-extortion.tsx new file mode 100644 index 00000000..23f185a6 --- /dev/null +++ b/components/moritani-extortion.tsx @@ -0,0 +1,78 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { Button } from './ui/button'; + +export function MoritaniExtortionNotice({ game }: { game: GameView }) { + const deferred = game.extortion?.deferred ?? 0; + const pending = game.extortion?.pending; + if (!deferred && !pending) return null; + + const payer = pending && game.players.find((player) => player.id === pending.player); + return ( +
+ {deferred > 0 && ( +

+ Moritani Extortion: {deferred} spice from the bank is set aside for + Moritani to collect during Mentat Pause. It is not yet spendable. +

+ )} + {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. +

+ )} +
+ ); +} + +export function MoritaniExtortion({ + game, + act, + busy, +}: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const decision = game.decision; + const pending = game.extortion?.pending; + if ( + decision?.kind !== 'moritaniExtortion' || + decision.player !== game.me || + !pending || + pending.player !== game.me || + pending.event !== decision.event + ) return null; + + const disabled = busy || !!game.response || !!game.truthtrance; + return ( +
+

+ Pay Moritani {pending.amount} spice to remove the Extortion token permanently, + or decline. The five-spice bank award has already been collected. +

+ {!pending.canPay && ( +

You do not have enough spice to pay. You may decline.

+ )} + + +
+ ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index acf46339..533233ac 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,6 +6,18 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work +Moritani Extortion now has a bounded local reveal-to-Mentat continuation: +five bank spice stays reserved until Moritani finishes normal Terror placement +and any Karama response; then non-Moritani seats in storm order may pay three +once or decline. Payment removes the revealed token; all declines rotate it +back into hidden supply. The [placement-first order](MORITANI_EXTORTION_RULES.md) +was selected by the user, not sourced as a publisher priority ruling. Public +notices hide the current payer's affordability from other seats. Owner controls, +legal AI, JSON/SQLite CAS, nested Box/gift interruptions and a genuine late +Mentat Ambassador entry have focused coverage; each still settles before the +turn ends. Atomics/Aftermath and complete expansion starts stay gated. This +local change is not in the last verified live image below. + The live NAS Apps `dune` image is the immutable `ghcr.io/quintine/dune:sha-c9a3625ff2a9ddafdbc08e64de809eca6029db85` from successful container workflow `36358270534`. The public footer's title and diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index c89928d8..1eb93928 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,40 @@ # Dune implementation status +## 28 September 2026 — Moritani Extortion saved Mentat continuation + +The real Terror-entry reveal now reserves five bank spice without crediting +Moritani. Normal Mentat placement/relocation and any Karama response resolve +first by the user's explicitly selected interpretation; bank collection then +opens one saved storm-order offer for each other faction. The first three-spice +payment removes the physical revealed token and ends the queue; if all decline, +the return helper rotates all hidden supply identities. Insolvent and allied +players retain a decline, and neither the bank award nor a payment replays after +JSON reload or an SQLite compare-and-swap race. Public notice and owner-only +payment controls are connected; the minimal AI declines legally. + +Independent review identified three concrete follow-ups, now corrected: the +current payer's affordability is private, Nullentropy Box and Richese gift +continuation frames retain the exact pending payment, and an Extortion +revealed by a real Guild Ambassador shipment after Mentat placement settles +before the turn changes. The focused entry/engine/SQLite/alliance/placement +suite passed 119 cases in eight files. A direct game-engine smoke revealed +Extortion at Moritani spice 12, reached placement before collection to 17, +then two AI payers declined and the revealed token returned under a different +hidden ID. Separately, an offline three-seat Basic Ecaz/Moritani game completed +in 110 accepted actions with two restores and no rejected bot proposals; that +sample did **not** draw the Extortion effect. + +The local rules reference displayed the selected order and late-Mentat +continuation in Chromium; the production UI/room flow remains unverified. +After review corrections, `npm run build`, all 5,677 offline tests and all +54 HTTP integration tests against the local server passed. The required +TypeScript/lint umbrella check still cannot finish because a separate +concurrent untracked Fremen Nexus draft references a nonexistent +`Player.fremenNexusMovementBlockedTurn`; no Extortion diagnostic remains. +Atomics/Aftermath, mixed arrival reaction priority, complete faction games +and public expansion starts remain gated. See +[the publisher sequence and user selection](MORITANI_EXTORTION_RULES.md). + ## 28 September 2026 — all-expansion Homeworld/Nexus samples The offline combined audit setup can also enable genuine Homeworld custody with diff --git a/docs/MORITANI_EXTORTION_RULES.md b/docs/MORITANI_EXTORTION_RULES.md index 432ac21b..11563973 100644 --- a/docs/MORITANI_EXTORTION_RULES.md +++ b/docs/MORITANI_EXTORTION_RULES.md @@ -1,6 +1,6 @@ # Moritani Extortion: sequence and payment -Bounded primary-source audit, 2026-09-06. No runtime edits or live-room actions were performed. +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. ## Confirmed sequence @@ -28,20 +28,23 @@ Current `g.order` is recalculated by storm movement and is not reordered by Guil Do not silently auto-pass an insolvent player merely to shorten the queue if phase-independent effects can raise that player’s available spice before deciding. A visible pass/payment decision and payment-time balance validation avoid that assumption. -## Material Mentat ordering gap +## Mentat ordering: selected interpretation, not a publisher ruling -Moritani may also place or move one Terror token during Mentat. Neither the placement paragraph nor Extortion specifies their relative order. Recovering Extortion first can permit immediately placing it again; placement first cannot. This changes a substantive option and is not harmless UI serialization. No targeted official or written designer clarification was found. Keep this combination explicitly unresolved or apply a user-approved interpretation; do not infer priority from the engine’s current handler order. [GF9 Ecaz & Moritani, printed pp. 5–6](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf) +Moritani may also place or move one Terror token during Mentat. Neither the placement paragraph nor Extortion specifies their relative order. Recovering Extortion first could permit immediately placing it again; placement first cannot. No targeted publisher/designer clarification was found in the audited sources. **The user selected placement first on 28 September 2026.** Resolve the normal placement/relocation opportunity and its Karama response, then collect the deferred five-spice bank award, then offer the storm-order payment decisions. A recovered token cannot be redeployed during the same Mentat Pause. This is a table policy, not a newly discovered rule. [GF9 Ecaz & Moritani, printed pp. 5–6](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf) -There is likewise no retrieved complete priority rule against Mentat bribe collection, CHOAM’s effects or victory checking. The token’s **own** collection-before-payment sequence is explicit. The general placement/Mentat ordering is not. Current `beginPhase(8)` collects bribes and opens Moritani placement, while `finishMoritaniPlacement` may immediately check victory; neither path should bypass an unresolved Extortion obligation. CHOAM has a later end-phase continuation that must also preserve the obligation. +The token's **own** collection-before-payment sequence is explicit. This implementation preserves the existing Mentat bribe collection before placement, settles Extortion before the no-CHOAM victory check, and keeps CHOAM's later market/Mentat continuation after Extortion. Those cross-effect priorities are software compositions, not separately sourced publisher procedures. + +A legal entry may reveal Extortion **during** Mentat after Moritani has already used the placement opportunity (for example, a revived force triggers an Ambassador's free shipment into Terror). Complete the committed entry and its interruptions first, then collect and offer payment before turn advancement. This is the same placement-first table policy applied to a late arrival, not a separate publisher priority ruling. Never let a pending reserve cross a turn boundary. ## Narrow persistence requirements - Store the specific revealed Extortion token ID, owner, triggering turn and pending five-spice credit. Keep the deferred credit distinct from spendable spice before Mentat. A record separate from aggregate bribes makes once-only collection auditable. - On Mentat collection, credit exactly five and mark it settled before any payer response. Reloads, failed transactions and nested Truthtrance must not award it again. - Persist payer order and progress. A valid three-spice payment deducts the payer, credits Moritani and permanently settles non-recovery atomically; later player responses are stale. Declining the final payer returns the exact token once. +- A Nullentropy search or Richese gift can suspend the current payer decision. Validate and restore that exact saved decision through the existing continuation frame without losing the one-time collection or exposing the payer's affordability to other seats. - Use the existing hidden-supply return helper when recovering. Its identity rotation avoids later linking a public Extortion identity to a newly hidden face. Do not restore the public revealed ID unchanged. - Treat current-owner eligibility separately from the original entrant. Moritani may have changed allies since revelation; the original target alone is not the payment queue. -- No generic Karama cancellation of revelation/Extortion is stated by the E3 table. Keep existing Terror effect rules and the unanswered separate Mentat-order issue distinct. +- No generic Karama cancellation of revelation/Extortion is stated by the E3 table. Keep existing Terror effect rules and this explicitly selected Mentat-order interpretation distinct. These are implementation recommendations, not newly asserted publisher procedures. diff --git a/docs/MORITANI_TERROR_RULES.md b/docs/MORITANI_TERROR_RULES.md index eb64f003..865238e6 100644 --- a/docs/MORITANI_TERROR_RULES.md +++ b/docs/MORITANI_TERROR_RULES.md @@ -122,3 +122,9 @@ Ordinary native Assassination and bounded Sneak Attack now join Robbery and Sabo ## Enemy of My Enemy integration checkpoint Acceptance/refusal, cancellation, hidden return custody, former-alliance cleanup, unused escrow refunds and AI/UI decisions are now implemented in an isolated draft. Mandatory unsupported effects still prevent an offer before announcement. `MORITANI_ENEMY_ALLIANCE.md` records source precedence and the pending clarification of Karama-before-reply timing. Full expansion starts remain disabled. The added alliance-formation turn also corrects the base next-turn departure deadline for newly allied co-occupants. Complete validation now includes873 unit and45 persisted/API tests. + +## Extortion Mentat continuation — 28 September 2026 + +The earlier Extortion absence and unresolved-order checkpoints above are historical. The user chose normal Terror placement first (including any Karama response), then Extortion bank collection and storm-order payment choices. Revealing Extortion now reserves five bank spice without making it spendable; the placement opportunity closes before the five is credited. A first non-Moritani player's three-spice payment permanently removes the token. If every player declines, the existing return operation rotates all hidden supply IDs before Extortion can be placed on a later turn. The original triggering entry does not replay on reload, and CHOAM/victory continuations wait until the payment window settles. This placement-relative timing is a user interpretation, not a located publisher ruling; see [the exact source sequence and selected boundary](MORITANI_EXTORTION_RULES.md). Atomics/Aftermath and complete Moritani expansion starts remain gated. + +Independent review repaired current-payer affordability disclosure and continuation custody for Nullentropy Box and Richese gifts. A real Advanced Homeworld Fremen Ghola return during Mentat triggered a Guild Ambassador shipment into Extortion **after** Moritani's placement; the bank award and payment window settled before the next turn. The late path does not justify opening full Homeworld/expansion combination gates. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 4b903ca9..02939de8 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -27,6 +27,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi | Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic and base Advanced active-opportunity shipment promises bind within their documented scope (Advanced excludes Guild and optional modules); prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) | | Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) | | Moritani Advanced assassination preview | After an eligible battle loss, a different opposing-faction Traitor Card may be revealed even if its leader is already dead (zero bounty). Face-up set-aside and one private replacement occur during Mentat; one use per faction. Normal-call duration, uniform hidden-eligibility UX and exceptional combinations remain explicit. | [Runtime/source contract](MORITANI_ASSASSINATE_LEADERS.md) | +| Moritani Extortion at Mentat | The user selected normal Terror placement/relocation first, including any Karama response; only then collect the reserved five bank spice and approach other players in storm order for a possible three-spice payment. Payment removes the token; all declines recover it with rotated hidden supply IDs. The relative placement timing is a user-selected interpretation, not a publisher ruling. | [Publisher sequence and selected timing](MORITANI_EXTORTION_RULES.md#mentat-ordering-selected-interpretation-not-a-publisher-ruling) | | Fremen/Ecaz final-turn victory | Reciprocal allied Ecaz and Fremen with fighters in Sietch Tabr do not block the Fremen special victory. Ecaz alone/non-allied or a third faction still blocks. The Habbanya extension remains unsupported; ordinary winner/prediction and fallback order are preserved. | [Explicit FAQ and runtime](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up) | | Truthtrance recorded knowledge | Questions compare an already recorded BG prediction, active submitted storm dial or actually known native Fremen forecast. Missing knowledge is unknown; only the aggregate answer is published. No future promise or unknown deck peek is created. | [Connected predicates and evidence](TRUTHTRANCE_KNOWLEDGE.md) | | Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) | @@ -58,6 +59,11 @@ These are current observations, not future obligations; no typed distribution is invented when Basic lacks separate starred-counter tracking. See the [force-fact source composition](TRUTHTRANCE_FORCE_FACTS.md). +For future interactions with no publisher ruling discoverable after targeted research, +use the conservative recommended interpretation and document it as an implementation +choice rather than soliciting another preference. Earlier pending questions below +remain recorded until their relevant work is revisited. + ## Pending interpretations: preserve existing questions These are unresolved in the cited records. Do not infer an answer from elapsed time, a fixture, a related card or this index. Check subsequent recorded user/source decisions before proceeding; do not send duplicate questions. diff --git a/game/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts index 2662cdbe..7cbd7c22 100644 --- a/game/admin-seat-ai-shape.ts +++ b/game/admin-seat-ai-shape.ts @@ -45,6 +45,7 @@ const decisions = { moritaniTerror: true, moritaniSetup: true, moritaniPlacement: true, + moritaniExtortion: true, ecazPlacement: true, ecazSpice: true, choamStorm: true, diff --git a/game/bots.ts b/game/bots.ts index 57747cfb..7ee447ec 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1674,6 +1674,11 @@ function policyActions(g: GameView): Action[] { } return [{ type: 'decision', decline: true }]; } + if (d.kind === 'moritaniExtortion') { + return g.extortion.pending?.event === d.event && g.extortion.pending.player === me.id + ? [{ type: 'decision', event: d.event, pay: false }] + : []; + } if (d.kind === 'moritaniAssassinate') { const pending = g.moritaniAssassinate?.pending; if (!pending || pending.event !== d.event) return []; diff --git a/game/engine.ts b/game/engine.ts index c00763a1..ee06b9a5 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -453,9 +453,11 @@ import { projectTerror, revealTerror, returnTerror, + retireExtortion, type TerrorKind, type TerrorState, } from './moritani-terror'; +import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionState, type ExtortionState } from './moritani-extortion'; import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions'; import { cashInCards } from './choam-karama'; import { saleOptions, quoteSale, type ChoamMarket } from './choam-market'; @@ -763,6 +765,7 @@ export type Decision = } | { kind: 'moritaniSetup'; player: string } | { kind: 'moritaniPlacement'; player: string } + | { kind: 'moritaniExtortion'; player: string; event: string } | { kind: 'ecazPlacement'; player: string } | { kind: 'ecazSpice'; player: string } | { @@ -1364,6 +1367,7 @@ export type Game = { ambassadorEvent?: string; } | null; moritaniTerror?: TerrorState; + moritaniExtortion?: ExtortionState; grummanCollection?: GrummanCollection; pendingMoritaniPlacement?: { token: string; @@ -6862,7 +6866,74 @@ function setupComplete(g: Game) { !g.players.some((p) => p.faction === 'moritani' && p.reserves === 20) ); } +function extortionEvent(state: ExtortionState): string { + return JSON.stringify(['moritaniExtortion', state.turn, state.token, state.cursor]); +} +function extortionContext(g: Game) { + return { turn: g.turn, phase: g.phase, players: g.players.map(player => ({ id: player.id, spice: player.spice })) }; +} +function extortionRule(apply: () => T): T { + try { return apply(); } + catch (error) { + if (error instanceof Error) throw new RuleError(error.message); + throw error; + } +} +function moritaniExtortionIntegrity(g: Game) { + const pending = g.moritaniExtortion; + const revealed = g.moritaniTerror?.tokens.filter(token => token.status === 'extortion') ?? []; + requireRule(revealed.length === (pending ? 1 : 0) && + (!pending || (revealed[0].id === pending.token && revealed[0].kind === 'extortion' && + byFaction(g, 'moritani')?.id === pending.owner && g.status === 'playing')), + '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; + const savedDecisions = pending.stage === 'payment' + ? homeworldSavedDecisions(g).filter(decision => decision.kind === 'moritaniExtortion') + : []; + requireRule(pending.stage === 'reserved' + ? g.phase <= 8 + : pending.stage === 'payment' && g.phase === 8 && + 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.'); +} +function settleMoritaniExtortion(g: Game, state: ExtortionState, recover: boolean) { + requireRule(g.moritaniTerror && state.stage === 'settled', + 'Extortion must settle with its revealed Terror token.'); + g.moritaniTerror = recover + ? returnTerror(g.moritaniTerror, state.token, random) + : retireExtortion(g.moritaniTerror, state.token); + delete g.moritaniExtortion; + log(g, recover + ? 'No one paid Extortion. Its token returned to the hidden Terror supply.' + : 'Extortion was paid. Its revealed Terror token was removed from the game.'); + finishMoritaniPlacement(g); +} function finishMoritaniPlacement(g: Game) { + const obligation = g.moritaniExtortion; + if (obligation?.stage === 'reserved') { + requireRule(g.phase === 8 && g.moritaniTerror?.placementTurn === g.turn, + 'Finish the Terror placement before collecting Extortion.'); + const result = extortionRule(() => collectExtortion(obligation, { + ...extortionContext(g), stormOrder: g.order, + })); + getPlayer(g, obligation.owner).spice += 5; + g.moritaniExtortion = result.state; + log(g, 'Moritani collected five reserved spice from the bank for Extortion.'); + if (result.state.stage === 'payment') { + const payer = result.state.queue[result.state.cursor]; + g.decision = { kind: 'moritaniExtortion', player: payer, event: extortionEvent(result.state) }; + } else settleMoritaniExtortion(g, result.state, true); + return; + } + requireRule(!obligation, 'Finish the pending Extortion payments first.'); if (!byFaction(g, 'choam')) victory(g); } function finishSetup(g: Game) { @@ -12799,7 +12870,7 @@ function terrorRevealBlocked( ): string | null { const homeworldBlock = homeworldTerrorEntryBlock(g, entry.amount); if (homeworldBlock) return homeworldBlock; - if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack') + if (kind === 'robbery' || kind === 'sabotage' || kind === 'sneakAttack' || kind === 'extortion') return null; if (kind !== 'assassination') return 'This Terror effect is still being implemented.'; @@ -13029,6 +13100,13 @@ function decideTerror(g: Game, p: Player, action: Action) { } else if (token.kind === 'sneakAttack') { entry.stage = 'sneakAttack'; reopen(); + } else if (token.kind === 'extortion') { + requireRule(!g.moritaniExtortion, 'An Extortion obligation is already pending.'); + g.moritaniExtortion = extortionRule(() => reserveExtortion({ + owner: owner.id, token: token.id, turn: g.turn, + }, extortionContext(g))).state; + log(g, 'Five bank spice is reserved for Moritani until Mentat Pause; it is not yet spendable.'); + finishTerrorEntry(g); } else { const victimCard = shuffle(entrant.hand)[0]; if (victimCard) { @@ -13910,6 +13988,8 @@ function decideBiddingEnd(g: Game, p: Player, action: Action) { } } function advancePhase(g: Game) { + if (g.phase === 8) + requireRule(!g.moritaniExtortion, 'Settle Extortion before leaving Mentat Pause.'); if (g.phase === 6) boardResolution(() => quoteBattlePhaseAdvance(g)); commitPhaseResources(g, currentPhaseResources(g)); g.ready = []; @@ -13970,6 +14050,8 @@ function completePhase(g: Game) { g.grummanCollection.outcome = 'expired'; g.grummanCollection.signature = grummanCollectionSignature(g.grummanCollection); } + if (g.phase === 8) + requireRule(!g.moritaniExtortion, 'Settle Extortion before ending the turn.'); if (g.phase === 8) settleStrongholdOwnership(g); g.phase++; if (g.phase === 9) { @@ -14068,7 +14150,7 @@ function beginPhase(g: Game) { g.moritaniTerror.placementTurn !== g.turn ) { g.decision = { kind: 'moritaniPlacement', player: moritani.id }; - } else if (!byFaction(g, 'choam')) victory(g); + } else finishMoritaniPlacement(g); } if (g.phase === 0 && g.turn > 1) { if (g.discoveryEnabled && g.discoveries) { @@ -19087,7 +19169,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) ); } g.pendingMoritaniPlacement = null; - if (placementCancellation?.finalVictory) + if (placementCancellation?.finalVictory && !g.moritaniExtortion) victory(g, placementCancellation.finalVictory); else finishMoritaniPlacement(g); } else if (response.kind === 'choamWorthless') { @@ -22096,6 +22178,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { ecazTreacheryIntegrity(state); ixRicheseTechnologyIntegrity(state); leaderSkillsIntegrity(state); + moritaniExtortionIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); @@ -22277,6 +22360,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { homeworldShipmentIntegrity(g); ixRicheseTechnologyIntegrity(g); harkonnenExchangeIntegrity(g); + moritaniExtortionIntegrity(g); return g; } function finishActionContinuations(g: Game) { @@ -22331,6 +22415,12 @@ function finishActionContinuations(g: Game) { ) finishSetup(g); settleAdvisors(g); + if (g.status === 'playing' && g.phase === 8 && + g.moritaniExtortion?.stage === 'reserved' && + g.moritaniTerror?.placementTurn === g.turn && + !g.decision && !g.response && !g.truthtrance && !g.phaseOpening && + !g.pendingKarama && !g.pendingTerrorEntry && !g.pendingAmbassador) + finishMoritaniPlacement(g); } function settleAutomaticContinuations(g: Game) { if (pendingNexusTraitors(g)) return; @@ -22378,6 +22468,7 @@ export function normalizeAutomaticGame(state: Game): Game { ecazTreacheryIntegrity(state); ixRicheseTechnologyIntegrity(state); leaderSkillsIntegrity(state); + moritaniExtortionIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); nexusChoamTradeIntegrity(state); @@ -22431,6 +22522,7 @@ export function normalizeAutomaticGame(state: Game): Game { homeworldShipmentIntegrity(g); ixRicheseTechnologyIntegrity(g); harkonnenExchangeIntegrity(g); + moritaniExtortionIntegrity(g); return g; } @@ -22859,6 +22951,29 @@ function applyActionInner( g.decision = null; requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.'); if (decision.kind === 'moritaniAssassinate') {actMoritaniAssassinate(g,decision,action);return g;} + if (decision.kind === 'moritaniExtortion') { + const obligation = g.moritaniExtortion; + const pay = action.pay; + requireRule(g.phase === 8 && obligation?.stage === 'payment' && + obligation.queue[obligation.cursor] === id && + decision.event === extortionEvent(obligation) && + action.event === decision.event && typeof pay === 'boolean' && + Object.keys(action).every(key => ['type', 'event', 'pay'].includes(key)), + 'Choose the current Extortion opportunity in storm order.'); + const result = extortionRule(() => answerExtortion(obligation, id, pay, extortionContext(g))); + if (result.transfer?.from === 'player') { + p.spice -= result.transfer.amount; + getPlayer(g, result.transfer.to).spice += result.transfer.amount; + log(g, `${p.name} paid Moritani three spice to prevent Extortion recovery.`); + } else log(g, `${p.name} declined the Extortion payment.`); + g.moritaniExtortion = result.state; + if (result.recover !== null) settleMoritaniExtortion(g, result.state, result.recover); + else { + const next = result.state.queue[result.state.cursor]; + g.decision = { kind: 'moritaniExtortion', player: next, event: extortionEvent(result.state) }; + } + return g; + } if (decision.kind === 'mentatQuestion') { actMentatQuestion(g, decision, action); return g; @@ -26215,6 +26330,7 @@ export function viewGame(state: Game, id: string) { ecazTreacheryIntegrity(state); ixRicheseTechnologyIntegrity(state); leaderSkillsIntegrity(state); + moritaniExtortionIntegrity(state); discoveryFlightIntegrity(state); discoveryIntegrity(state); greatMakerIntegrity(state); @@ -26342,6 +26458,18 @@ export function viewGame(state: Game, id: string) { moritaniTerror: g.moritaniTerror ? projectTerror(g.moritaniTerror, me.faction === 'moritani') : null, + extortion: { + deferred: g.moritaniExtortion?.stage === 'reserved' ? 5 : 0, + pending: g.moritaniExtortion?.stage === 'payment' && g.decision?.kind === 'moritaniExtortion' + ? { + event: g.decision.event, + owner: g.moritaniExtortion.owner, + player: g.decision.player, + amount: 3 as const, + canPay: me.id === g.decision.player ? me.spice >= 3 : null, + } + : null, + }, moritaniPendingPlacement: me.faction === 'moritani' ? (g.pendingMoritaniPlacement ?? null) : null, grummanCollection: projectedGrummanCollection(g, id), @@ -26734,6 +26862,7 @@ export function viewGame(state: Game, id: string) { g.status === 'playing' && (!!byFaction(g, 'choam') || g.decision?.kind === 'moritaniPlacement' || + g.decision?.kind === 'moritaniExtortion' || g.response?.kind === 'moritaniPlacement'), choamMovementBonus: g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0, diff --git a/game/moritani-extortion.ts b/game/moritani-extortion.ts index cad8a9ed..8f470fe1 100644 --- a/game/moritani-extortion.ts +++ b/game/moritani-extortion.ts @@ -119,6 +119,12 @@ function validate(state: ExtortionState, context: ExtortionContext) { } return players; } + +/** Check an unfinished saved obligation against its original turn and seat roster. */ +export function validateExtortionState(state: ExtortionState, context: ExtortionContext): void { + validate(state, context); +} + function clone(state: ExtortionState): ExtortionState { return { owner: state.owner, diff --git a/game/moritani-terror.ts b/game/moritani-terror.ts index 61ab3631..f05c6a91 100644 --- a/game/moritani-terror.ts +++ b/game/moritani-terror.ts @@ -155,6 +155,19 @@ export function revealTerror(state: TerrorState, tokenId: string): TerrorState { }; } +/** A paid Extortion cannot return to supply; the revealed physical token is spent. */ +export function retireExtortion(state: TerrorState, tokenId: string): TerrorState { + const token = state.tokens.find(candidate => candidate.id === tokenId); + if (token?.kind !== 'extortion' || token.status !== 'extortion') + throw new Error('Only the revealed Extortion token can be removed.'); + return { + ...state, + tokens: state.tokens.map(candidate => candidate.id === tokenId + ? { ...candidate, status: 'removed' as const } + : candidate), + }; +} + /** * Return an unrevealed token or recover Extortion without resolving its rule effect. * Every available ID is replaced, and the supply is rebuilt in a fresh random order. diff --git a/game/reference.ts b/game/reference.ts index 6c84d610..b545f9a0 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2829,7 +2829,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Random assassination, territorial destruction, deferred income, theft, sabotage and reserve entry.', steps: [ - 'Robbery, Sabotage, Sneak Attack and ordinary native-leader Assassination have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Atomics, Extortion and complete Moritani games remain unfinished.', + 'Robbery, Sabotage, Sneak Attack, ordinary native-leader Assassination and Extortion have controls for supported entry reactions. After a Robbery draw, finish any required discard; after Sabotage, give one of your own cards or decline. Enemy of My Enemy has partial alliance controls. Atomics and complete Moritani games remain unfinished.', 'Assassination: randomly select one of the entrant’s leaders and send it to the Tanks. Moritani collects spice for that leader’s value; Zoal pays three. Revealing the token resolves the random selection without letting Moritani choose a victim. This token needs neither a battle loss nor a matching Traitor Card. Current controls support ordinary native leaders; unresolved captured, foreign or special-leader pools block revelation and show Moritani the reason privately.', 'Atomics: send every faction’s forces in the territory to the Tanks and place Atomics Aftermath there. Its lasting shipment prohibition and hand-limit penalty are explained in the Aftermath topic.', 'Extortion: set aside five spice from the bank for collection during Mentat Pause. After collection, recover this token unless one player pays you three spice in the storm-order opportunity. The Extortion topic explains the two separate payments.', @@ -2867,15 +2867,16 @@ export const RULE_TOPICS: RuleTopic[] = [ id: 'moritani-extortion', title: 'Moritani Extortion: collection and recovery', category: 'Advanced & expansions', - coverage: 'Planned', + coverage: 'Partial', summary: 'Five deferred bank spice and a separate opportunity to prevent the token’s return.', steps: [ - 'Revealing Extortion sets aside five spice from the bank in front of Moritani’s shield. This is collected during Mentat Pause; it is not immediately available to spend.', - 'After collecting that spice, Moritani regains Extortion unless a player pays Moritani three spice. Offer the payment opportunity in storm order. One payment is sufficient to prevent recovery.', - 'The three-spice payment is additional to the five-spice bank award. It prevents the token returning to supply; it does not cancel the bank income.', - 'If nobody pays, return Extortion to Moritani’s supply. Other revealed Terror tokens have no corresponding recovery provision.', - 'The ordering of this recovery against Moritani’s placement opportunity and other Mentat actions is not settled here. Do not assume that a recovered token can always be placed again in the same Mentat Pause.', + '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.', + '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.', ], related: ['moritani-terror', 'moritani-terror-effects', 'mentat'], }, @@ -4049,7 +4050,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.', 'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.', 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.', - 'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules; this does not implement Atomics, Extortion or complete Moritani games.', + 'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.', 'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.', ], related: ['ecaz-modules', 'spice-blow', 'alliance-funding'], diff --git a/game/richese-cancellation.ts b/game/richese-cancellation.ts index fd882646..cc567535 100644 --- a/game/richese-cancellation.ts +++ b/game/richese-cancellation.ts @@ -28,6 +28,7 @@ const DECISIONS = { moritaniTerror: true, moritaniSetup: true, moritaniPlacement: true, + moritaniExtortion: true, ecazPlacement: true, ecazSpice: true, homeworldRevivalDeployment: true, diff --git a/tests/grumman-placement-cancellation.test.ts b/tests/grumman-placement-cancellation.test.ts index 28f72fee..c77e6310 100644 --- a/tests/grumman-placement-cancellation.test.ts +++ b/tests/grumman-placement-cancellation.test.ts @@ -123,14 +123,6 @@ void test('denial tolerates changed placement feasibility but rejects malformed grummanToken(bad, 'assassination').status = 'removed'; }, ], - [ - 'extortion source', - (bad) => { - const token = grummanToken(bad, 'extortion'); - token.status = 'extortion'; - bad.pendingMoritaniPlacement!.token = token.id; - }, - ], [ 'same source and target', (bad) => { diff --git a/tests/moritani-alliance.test.ts b/tests/moritani-alliance.test.ts index b026cf22..eaa81121 100644 --- a/tests/moritani-alliance.test.ts +++ b/tests/moritani-alliance.test.ts @@ -281,22 +281,26 @@ void test('Karama cancels the alliance offer while preserving reveal or decline assert.equal(revealed.pendingTerrorEntry?.stage, 'robbery'); }); -void test('Ecaz and presently unsupported refusal effects cannot receive an alliance offer', () => { - for (const initial of [ - fixture('robbery', 'ecaz'), - fixture('atomics'), - fixture('extortion'), - ]) { - const g = enter(initial); - assert.equal(viewGame(g, 'm').terrorEntry!.canOfferAlliance, false); - assert.ok(viewGame(g, 'm').terrorEntry!.allianceBlockedReason); - assert.equal( - 'allianceBlockedReason' in viewGame(g, 'e').terrorEntry!, - false, - ); - rejected(g, 'm', { type: 'decision', alliance: true }); - assert.equal(decision(g, 'm', { decline: true }).pendingTerrorEntry, null); - } +void test('Ecaz entrants cannot be offered Enemy of My Enemy', () => { + const g = enter(fixture('robbery', 'ecaz')); + assert.equal(viewGame(g, 'm').terrorEntry!.canOfferAlliance, false); + rejected(g, 'm', { type: 'decision', alliance: true }); + assert.equal(decision(g, 'm', { decline: true }).pendingTerrorEntry, null); +}); + +void test('Extortion refusal reserves the award while acceptance returns the token without collecting it', () => { + const entered = enter(fixture('extortion')); + assert.equal(viewGame(entered, 'm').terrorEntry!.canOfferAlliance, true); + const declined = decision(reload(allow(offer(entered))), 'e', { accept: false }); + assert.equal(declined.moritaniExtortion?.bank.reserved, 5); + assert.equal(player(declined, 'm').spice, player(entered, 'm').spice); + assert.equal(declined.moritaniTerror!.tokens.find(token => token.kind === 'extortion')?.status, 'extortion'); + + const accepted = decision(reload(allow(offer(entered))), 'e', { accept: true }); + assert.equal(accepted.moritaniExtortion, undefined); + assert.equal(player(accepted, 'm').spice, player(entered, 'm').spice); + assert.equal(accepted.moritaniTerror!.tokens.find(token => token.kind === 'extortion')?.status, 'available'); + assert.equal(viewGame(accepted, 'e').extortion.deferred, 0); }); void test('alliance declarations and replies do not expose different hidden Terror faces', () => { diff --git a/tests/moritani-entry.test.ts b/tests/moritani-entry.test.ts index cd77fbf1..748dba8e 100644 --- a/tests/moritani-entry.test.ts +++ b/tests/moritani-entry.test.ts @@ -104,21 +104,6 @@ void test('shipment opens a private Terror choice after settlement and decline s rejected(declined, 'e', shipment); }); -void test('unsupported hidden faces can be declined but cannot be revealed or leaked', () => { - for (const kind of ['atomics', 'extortion'] as const) { - const g = enter(fixture(kind)); - assert.equal(viewGame(g, 'm').terrorEntry!.canReveal, false); - assert.equal('kind' in viewGame(g, 'e').terrorEntry!, false); - rejected(g, 'm', { type: 'decision', reveal: true }); - const done = decide(g, { decline: true }); - assert.equal(done.decision, null); - assert.equal( - done.moritaniTerror!.tokens.find((token) => token.kind === kind)!.status, - 'placed', - ); - } -}); - void test('Moritani and its ally do not trigger Terror when shipping', () => { const allied = fixture(); player(allied, 'm').ally = 'e'; diff --git a/tests/moritani-extortion-engine.test.ts b/tests/moritani-extortion-engine.test.ts new file mode 100644 index 00000000..13fb2444 --- /dev/null +++ b/tests/moritani-extortion-engine.test.ts @@ -0,0 +1,369 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + newPlayer, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { createTerrorState, placeTerror } from '../game/moritani-terror'; +import { baseDeck } from '../game/cards'; +import { botActions } from '../game/bots'; +import { richeseCards } from '../game/richese-cards'; +import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors'; +import { homeworldRevivalFixture, holdRevivalCard } from './fixture-homeworld-revival'; + +const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +const extortionToken = (g: Game) => + g.moritaniTerror!.tokens.find((t) => t.kind === 'extortion')!; +const payment = (g: Game, pay: boolean): Action => ({ + type: 'decision', + event: (g.decision as Extract, { kind: 'moritaniExtortion' }>).event, + pay, +}); +function reject(g: Game, id: string, action: Action) { + const before = structuredClone(g); + assert.throws(() => applyAction(g, id, action)); + assert.deepEqual(g, before); +} +function fixture(choam = false) { + const g = createGame('EXTORT01', newPlayer('m', 'Moritani', 'moritani')); + g.players.push( + newPlayer('e', 'Emperor', 'emperor'), + newPlayer('a', 'Atreides', 'atreides'), + newPlayer('b', 'Harkonnen', 'harkonnen'), + ); + if (choam) g.players.push(newPlayer('c', 'CHOAM', 'choam')); + g.status = 'playing'; + g.phase = 5; + g.turn = 2; + g.storm = 18; + g.active = 'e'; + g.order = g.players.map((p) => p.id); + g.movementRemaining = [...g.order]; + for (const p of g.players) { + p.forces = {}; + p.reserves = 20; + p.spice = 12; + p.hand = []; + } + g.moritaniTerror = createTerrorState(() => 0); + const token = extortionToken(g); + g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1); + return g; +} +function reveal(initial = fixture()) { + const offered = applyAction(initial, 'e', { + type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, + }); + assert.equal(offered.decision?.kind, 'moritaniTerror'); + assert.equal(viewGame(offered, 'm').terrorEntry!.kind, 'extortion'); + assert.equal(viewGame(offered, 'm').terrorEntry!.canReveal, true); + const before = player(offered, 'm').spice; + const g = applyAction(offered, 'm', { type: 'decision', reveal: true }); + assert.equal(player(g, 'm').spice, before, 'bank spice remains unspendable until Mentat'); + assert.equal(extortionToken(g).status, 'extortion'); + assert.equal(extortionToken(g).location, null); + assert.equal(viewGame(g, 'e').extortion.deferred, 5); + assert.equal(viewGame(g, 'e').extortion.pending, null); + return g; +} +function mentat(input: Game) { + const g = structuredClone(input); + g.phase = 7; + g.ready = []; + let next = g; + for (const p of g.players) next = applyAction(next, p.id, { type: 'ready' }); + if (next.decision?.kind === 'choamMarket') + next = applyAction(next, 'c', { type: 'decision', done: true }); + assert.equal(next.phase, 8); + assert.equal(next.decision?.kind, 'moritaniPlacement'); + return next; +} +function collect(g: Game) { + assert.equal(g.decision?.kind, 'moritaniPlacement'); + const before = player(g, 'm').spice; + const next = applyAction(g, 'm', { type: 'decision', decline: true }); + assert.equal(player(next, 'm').spice, before + 5); + assert.equal(viewGame(next, 'e').extortion.deferred, 0); + assert.equal(next.decision?.kind, 'moritaniExtortion'); + assert.equal(extortionToken(next).status, 'extortion'); + return next; +} + +void test('real entry reserves five, then placement precedes one Mentat award and storm-order payer choices', () => { + const revealed = reveal(); + reject(revealed, 'e', { type: 'decision', event: 'old', pay: true }); + let g = mentat(reload(revealed)); + assert.equal(player(g, 'm').spice, player(revealed, 'm').spice); + reject(g, 'e', { type: 'decision', event: 'old', pay: true }); + g = collect(g); + const first = g.decision; + assert.ok(first?.kind === 'moritaniExtortion'); + assert.equal(first.player, g.order.filter((id) => id !== 'm')[0]); + assert.equal(viewGame(g, first.player).extortion.pending?.amount, 3); + assert.equal(viewGame(g, first.player).extortion.pending?.canPay, true); + assert.equal(viewGame(g, 'm').extortion.pending?.event, first.event); + for (const observer of g.players.filter(seat => seat.id !== first.player)) + assert.equal(viewGame(g, observer.id).extortion.pending?.canPay, null); + reject(g, 'm', payment(g, true)); + reject(g, first.player, { type: 'decision', event: 'stale', pay: false }); + const declined = applyAction(g, first.player, payment(g, false)); + assert.equal(player(declined, 'm').spice, player(g, 'm').spice); + assert.equal(extortionToken(declined).status, 'extortion'); + const second = declined.decision; + assert.ok(second?.kind === 'moritaniExtortion'); + assert.equal(second.player, g.order.filter((id) => id !== 'm')[1]); + assert.notEqual(second.event, first.event); + reject(declined, second.player, { type: 'decision', event: first.event, pay: true }); + const ownerBefore = player(declined, 'm').spice; + const payerBefore = player(declined, second.player).spice; + const paid = applyAction(declined, second.player, payment(declined, true)); + assert.equal(player(paid, second.player).spice, payerBefore - 3); + assert.equal(player(paid, 'm').spice, ownerBefore + 3); + assert.equal(extortionToken(paid).status, 'removed'); + assert.equal(extortionToken(paid).location, null); + assert.equal(paid.decision?.kind === 'moritaniExtortion', false); + assert.equal(viewGame(paid, 'a').extortion.pending, null); + reject(paid, second.player, payment(declined, true)); + assert.equal(paid.moritaniTerror!.tokens.filter((t) => t.kind === 'extortion').length, 1); +}); + +void test('a declared placement response settles before deferred Extortion collection, without a second same-turn placement', () => { + let g = mentat(reveal()); + const before = player(g, 'm').spice; + const supplyToken = g.moritaniTerror!.tokens.find((t) => t.status === 'available')!; + player(g, 'a').hand = [baseDeck().find(card => card.effect === 'karama')!]; + g = applyAction(g, 'm', { + type: 'decision', token: supplyToken.id, territory: 'carthag', + }); + assert.equal(g.response?.kind, 'moritaniPlacement'); + assert.equal(player(g, 'm').spice, before); + assert.equal(viewGame(g, 'a').extortion.deferred, 5); + for (let i = 0; g.response && i < g.players.length; i++) { + const responder = g.players.find((p) => !g.response!.passed.includes(p.id))!; + g = applyAction(g, responder.id, { type: 'passResponse' }); + } + assert.equal(g.response, null); + assert.equal(g.decision?.kind, 'moritaniExtortion'); + assert.equal(player(g, 'm').spice, before + 5); + assert.equal(g.moritaniTerror!.tokens.find((t) => t.id === supplyToken.id)?.location, 'carthag'); + assert.equal(extortionToken(g).status, 'extortion'); + reject(g, 'm', { type: 'decision', token: supplyToken.id, territory: 'tueks_sietch' }); +}); + +void test('Karama cancellation consumes placement before the reserved award and Extortion choices', () => { + let g = mentat(reveal()); + const before = player(g, 'm').spice; + const supplyToken = g.moritaniTerror!.tokens.find(token => token.status === 'available')!; + const karama = baseDeck().find(card => card.effect === 'karama')!; + player(g, 'a').hand = [karama]; + g = applyAction(g, 'm', { type: 'decision', token: supplyToken.id, territory: 'carthag' }); + assert.equal(g.response?.kind, 'moritaniPlacement'); + g = applyAction(reload(g), 'a', { type: 'card', mode: 'cancel', card: karama.id }); + assert.equal(g.moritaniTerror!.tokens.find(token => token.id === supplyToken.id)?.status, 'available'); + assert.equal(g.moritaniTerror!.placementTurn, g.turn); + assert.equal(player(g, 'm').spice, before + 5); + assert.equal(g.decision?.kind, 'moritaniExtortion'); + assert.equal(viewGame(g, 'e').extortion.pending?.player, 'e'); +}); + +void test('all players including an ally and an insolvent payer get a decline; all pass returns and rotates hidden supply', () => { + const initial = fixture(); + player(initial, 'm').ally = 'a'; + player(initial, 'a').ally = 'm'; + player(initial, 'b').spice = 0; + let g = collect(mentat(reveal(initial))); + const oldSupply = new Set(g.moritaniTerror!.tokens.map((t) => t.id)); + const formerToken = extortionToken(g).id; + const expected = g.order.filter((id) => id !== 'm'); + for (const id of expected) { + assert.ok(g.decision?.kind === 'moritaniExtortion'); + assert.equal(g.decision.player, id); + const projection = viewGame(g, id).extortion.pending!; + assert.equal(projection.player, id); + assert.equal(projection.canPay, player(g, id).spice >= 3); + if (id === 'b') reject(g, id, payment(g, true)); + const before = player(g, 'm').spice; + g = applyAction(reload(g), id, payment(g, false)); + assert.equal(player(g, 'm').spice, before); + } + assert.equal(g.decision?.kind === 'moritaniExtortion', false); + assert.equal(extortionToken(g).status, 'available'); + assert.equal(extortionToken(g).location, null); + assert.equal(g.moritaniTerror!.tokens.some((t) => t.id === formerToken), false); + assert.ok(g.moritaniTerror!.tokens.every((t) => !oldSupply.has(t.id))); + assert.equal(g.moritaniTerror!.tokens.length, 6); + assert.equal(viewGame(g, 'e').moritaniTerror!.tokens.length, 0); + assert.equal(viewGame(g, 'e').extortion.pending, null); + reject(g, expected.at(-1)!, { type: 'decision', event: 'expired', pay: false }); +}); + +void test('reloaded reveal and payer preserve private identity, event, and exactly one bank collection', () => { + const revealed = reveal(); + const publicBefore = viewGame(revealed, 'a'); + assert.equal(JSON.stringify(publicBefore).includes(extortionToken(revealed).id), true); + assert.equal(JSON.stringify(publicBefore).includes('terror-supply-'), false); + const opened = mentat(reload(revealed)); + reject(opened, 'e', { type: 'decision', event: 'unissued', pay: false }); + const pending = collect(reload(opened)); + const event = pending.decision?.kind === 'moritaniExtortion' ? pending.decision.event : ''; + assert.ok(event); + const restored = reload(pending); + assert.ok(restored.decision?.kind === 'moritaniExtortion'); + assert.equal(restored.decision.event, event); + assert.equal(player(restored, 'm').spice, player(revealed, 'm').spice + 5); + const next = applyAction(restored, restored.decision.player, payment(restored, false)); + assert.equal(player(next, 'm').spice, player(restored, 'm').spice); + reject(next, restored.decision.player, payment(restored, true)); +}); + +void test('Extortion choices delay an otherwise won table; CHOAM keeps its later market', () => { + for (const choam of [false, true]) { + const initial = fixture(choam); + player(initial, 'a').forces = { + 'carthag:11': 1, 'sietch_tabr:14': 1, 'tueks_sietch:5': 1, + }; + player(initial, 'a').reserves = 17; + let g = collect(mentat(reveal(initial))); + assert.equal(g.status, 'playing'); + assert.deepEqual(g.winner, []); + assert.equal(viewGame(g, 'a').mentatVictoryPending, true); + assert.ok(g.decision?.kind === 'moritaniExtortion'); + g = applyAction(g, g.decision.player, payment(g, true)); + if (choam) { + assert.equal(g.status, 'playing'); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + assert.equal(g.decision?.kind, 'choamMarket'); + } else { + assert.equal(g.status, 'finished'); + assert.deepEqual(g.winner, ['a']); + } + } +}); + +void test('bot payers can finish saved Extortion choices without stalling or leaking hidden supply', () => { + let g = collect(mentat(reveal())); + const originalId = extortionToken(g).id; + for (const payer of g.players) if (payer.id !== 'm') payer.bot = 'Easy'; + for (let turn = 0; g.decision?.kind === 'moritaniExtortion' && turn < g.players.length; turn++) { + const payer = g.decision.player; + const action = botActions(viewGame(reload(g), payer))[0]; + assert.ok(action, `${payer} must have a legal AI payment decision`); + g = applyAction(g, payer, action); + } + assert.notEqual(g.decision?.kind, 'moritaniExtortion'); + assert.equal(g.moritaniTerror!.tokens.some(token => token.id === originalId), false); + assert.equal(extortionToken(g).status, 'available'); +}); + +void test('a paid Nullentropy search suspends and restores the payer without losing Extortion or a second bank award', () => { + const initial = fixture(); + initial.expansions = ['choam']; + initial.deck = baseDeck(); + initial.discard = initial.deck.splice(0, 3); + const box = richeseCards().find(card => card.id === 'richese-nullentropy-box')!; + player(initial, 'e').hand.push(box); + let g = collect(mentat(reveal(initial))); + const ownerSpice = player(g, 'm').spice; + assert.equal(g.decision?.kind, 'moritaniExtortion'); + const event = g.decision.event; + g = applyAction(g, 'e', { type: 'card', card: box.id }); + assert.equal(g.decision?.kind, 'nullentropy'); + assert.equal(g.pendingNullentropy?.resume.decision?.kind, 'moritaniExtortion'); + assert.doesNotThrow(() => viewGame(reload(g), 'e')); + const selected = g.discard[0].id; + g = applyAction(reload(g), 'e', { type: 'decision', event: g.pendingNullentropy!.event, card: selected }); + assert.equal(g.decision?.kind, 'moritaniExtortion'); + assert.equal(g.decision.event, event); + g = applyAction(reload(g), 'e', payment(g, false)); + assert.equal(player(g, 'm').spice, ownerSpice); + assert.equal(g.decision?.kind, 'moritaniExtortion'); +}); + +void test('a Richese gift response suspends its payer and restores the same saved offer', () => { + const initial = fixture(); + initial.expansions = ['choam']; + initial.deck = baseDeck(); + initial.players[3] = newPlayer('b', 'Richese', 'richese'); + player(initial, 'b').spice = 12; + player(initial, 'b').forces = {}; + player(initial, 'b').reserves = 20; + player(initial, 'a').ally = 'b'; + player(initial, 'b').ally = 'a'; + const gift = richeseCards().find(card => card.id === 'richese-karama')!; + player(initial, 'b').hand.push(gift); + initial.richeseCache = richeseCards().filter(card => card.id !== gift.id); + const karama = initial.deck.splice(initial.deck.findIndex(card => card.effect === 'karama'), 1)[0]; + player(initial, 'e').hand.push(karama); + let g = collect(mentat(reveal(initial))); + for (const payer of ['e', 'a']) g = applyAction(g, payer, payment(g, false)); + assert.equal(g.decision?.kind, 'moritaniExtortion'); + assert.equal(g.decision.player, 'b'); + const event = g.decision.event; + g = applyAction(g, 'b', { type: 'richeseGift', card: gift.id }); + assert.equal(g.response?.kind, 'richeseGift'); + assert.equal(g.pendingRicheseGift?.resume.decision?.kind, 'moritaniExtortion'); + assert.doesNotThrow(() => viewGame(reload(g), 'e')); + g = applyAction(reload(g), 'e', { type: 'passResponse' }); + assert.equal(g.decision?.kind, 'moritaniExtortion'); + assert.equal(g.decision.event, event); + g = applyAction(g, 'b', payment(g, true)); + assert.equal(extortionToken(g).status, 'removed'); +}); + +void test('a genuine late Mentat Ambassador entry settles Extortion after placement before the next turn', () => { + let g = homeworldRevivalFixture({ advanced: true, extraSeats: [ + { id: 'ec', faction: 'ecaz' }, + { id: 'm', faction: 'moritani' }, + { id: 'a', faction: 'atreides' }, + ] }); + g.expansions.push('ecaz'); + g.phase = 8; + g.ready = []; + g.phaseOpening = null; + g.decision = null; + g.response = null; + g.moritaniTerror ??= createTerrorState(() => 0); + const token = extortionToken(g); + g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'carthag', 1); + g.moritaniTerror.placementTurn = g.turn; + const supply = createAmbassadors(() => 0); + const guild = supply.tokens.find(candidate => candidate.effect === 'guild')!; + supply.cohort = [guild.id, ...supply.tokens.filter(candidate => + candidate.effect !== 'ecaz' && candidate.id !== guild.id).slice(0, 4).map(candidate => candidate.id)]; + for (const candidate of supply.tokens) { + candidate.zone = candidate.effect === 'ecaz' || supply.cohort.includes(candidate.id) ? 'supply' : 'pool'; + candidate.location = null; + } + g.ecazAmbassadors = placeAmbassador(supply, guild.id, { turn: 1, availableSpice: 20, + destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true }, + }).state; + const ghola = holdRevivalCard(g, 'f', 'ghola'); + g = applyAction(g, 'f', { type: 'card', card: ghola, amount: 1, elite: 1 }); + assert.equal(g.decision?.kind, 'homeworldRevivalDeployment'); + g = applyAction(reload(g), 'f', { type: 'decision', event: g.decision.event, + destination: 'arrakeen:10', amount: 1 }); + assert.equal(g.pendingAmbassador?.stage, 'offer'); + g = applyAction(g, 'ec', { type: 'decision', event: g.pendingAmbassador!.event, + trigger: true, beneficiary: 'ec' }); + g = applyAction(reload(g), 'ec', { type: 'decision', event: g.pendingAmbassador!.event, + amount: 2, territory: 'carthag', sector: 11 }); + assert.equal(g.pendingTerrorEntry?.stage, 'offer'); + const before = player(g, 'm').spice; + g = applyAction(reload(g), 'm', { type: 'decision', reveal: true }); + assert.equal(g.phase, 8); + assert.equal(g.homeworldRevivalReturn?.stage, 'complete'); + assert.equal(g.pendingAmbassador, null); + assert.equal(g.decision?.kind, 'moritaniExtortion'); + assert.equal(player(g, 'm').spice, before + 5); + assert.equal(viewGame(g, 'ec').extortion.deferred, 0); + g = applyAction(reload(g), g.decision.player, payment(g, true)); + assert.equal(extortionToken(g).status, 'removed'); + for (const seat of g.players) g = applyAction(g, seat.id, { type: 'ready' }); + assert.equal(g.turn, 3); + assert.equal(g.moritaniExtortion, undefined); +}); diff --git a/tests/moritani-extortion-recovery.test.ts b/tests/moritani-extortion-recovery.test.ts new file mode 100644 index 00000000..3c8a1eb0 --- /dev/null +++ b/tests/moritani-extortion-recovery.test.ts @@ -0,0 +1,194 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { webcrypto } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { runInNewContext } from 'node:vm'; +import ts from 'typescript'; +import * as engine from '../game/engine'; +import * as bots from '../game/bots'; +import * as seatAiDelegation from '../lib/seat-ai-delegation'; +import type * as Rooms from '../db/rooms'; +import { createTerrorState, placeTerror } from '../game/moritani-terror'; + +const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} }; + +/** Real room module and migrations, with a barrier immediately before the room CAS. */ +function unitStore() { + const sqlite = new DatabaseSync(':memory:'); + const hooks: { beforeWrite?: () => Promise } = {}; + const writes: { expected: number; changes: number }[] = []; + for (const file of readdirSync(new URL('../drizzle/', import.meta.url)) + .filter((name) => name.endsWith('.sql')) + .sort()) + sqlite.exec(readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8')); + class Statement { + values: (string | number | null)[] = []; + constructor(readonly sql: string) {} + bind(...values: (string | number | null)[]) { + this.values = values; + return this; + } + async first() { + return sqlite.prepare(this.sql).get(...this.values) ?? null; + } + async run() { + const continuation = this.sql.startsWith('UPDATE rooms SET state'); + if (continuation) await hooks.beforeWrite?.(); + const changes = Number(sqlite.prepare(this.sql).run(...this.values).changes); + if (continuation) writes.push({ expected: Number(this.values[3]), changes }); + return { meta: { changes } }; + } + } + const database = { + prepare: (sql: string) => new Statement(sql), + batch: async (statements: Statement[]) => { + sqlite.exec('BEGIN'); + try { + const results = []; + for (const statement of statements) results.push(await statement.run()); + sqlite.exec('COMMIT'); + return results; + } catch (error) { + sqlite.exec('ROLLBACK'); + throw error; + } + }, + }; + function loadRooms() { + const exports = {}; + runInNewContext( + ts.transpileModule(readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, + { + exports, + crypto: webcrypto, + TextEncoder, + structuredClone, + JSON, + require: (name: string) => { + if (name === 'cloudflare:workers') return { env: { DB: database } }; + if (name === '@/game/engine') return engine; + if (name === '@/game/bots') return bots; + if (name === '@/lib/seat-ai-delegation') return seatAiDelegation; + throw new Error('Unexpected module ' + name); + }, + }, + ); + return exports as typeof Rooms; + } + return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes }; +} + +async function fixture() { + const store = unitStore(); + const created = await store.rooms.createRoom('Extortion seat', 'moritani', false, []); + const code = created.view.code; + const joined = await store.rooms.joinRoom(code, 'Entrant', 'emperor'); + const witness = await store.rooms.joinRoom(code, 'Witness', 'atreides'); + const tokens = [created.token, joined.token!, witness.token!]; + const seats = await Promise.all(tokens.map((token) => store.restart().authenticate(code, token))); + const ids = seats.map((seat) => seat.playerId); + let g = await store.restart().readRoom(code); + g.players = [ + engine.newPlayer(ids[0], 'Moritani', 'moritani'), + engine.newPlayer(ids[1], 'Entrant', 'emperor'), + engine.newPlayer(ids[2], 'Witness', 'atreides'), + ]; + Object.assign(g, { + status: 'playing', phase: 5, turn: 2, storm: 18, active: ids[1], + order: [ids[1], ids[0], ids[2]], movementRemaining: [ids[1], ids[0], ids[2]], + decision: null, response: null, phaseOpening: null, ready: [], + }); + for (const p of g.players) { + p.forces = {}; + p.reserves = 20; + p.spice = 12; + p.hand = []; + } + g.moritaniTerror = createTerrorState(() => 0); + const token = g.moritaniTerror.tokens.find((item) => item.kind === 'extortion')!; + g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1); + store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?') + .run(JSON.stringify(g), g.version, code); + store.writes.length = 0; + async function act(index: number, action: engine.Action) { + const current = await store.restart().readRoom(code); + await store.restart().act(code, seats[index], current.version, action, clock); + return store.restart().readRoom(code); + } + g = await act(1, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 }); + assert.equal(g.decision?.kind, 'moritaniTerror'); + g = await act(0, { type: 'decision', reveal: true }); + assert.equal(g.moritaniTerror!.tokens.find((item) => item.kind === 'extortion')!.status, 'extortion'); + assert.equal(g.players[0].spice, 12); + assert.equal((await store.restart().readSeatView(code, seats[1])).extortion.deferred, 5); + return { ...store, code, tokens, seats, ids, act, revealed: g, token: token.id }; +} + +void test('SQLite reconnect preserves reserved bank award, payer privacy, and races one payment commit', async () => { + const f = await fixture(); + try { + let g = await f.restart().readRoom(f.code); + g.phase = 7; + g.ready = []; + f.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?') + .run(JSON.stringify(g), g.version, f.code); + f.writes.length = 0; + for (const [i] of f.seats.entries()) g = await f.act(i, { type: 'ready' }); + assert.equal(g.decision?.kind, 'moritaniPlacement'); + assert.equal(g.players[0].spice, 12); + g = await f.act(0, { type: 'decision', decline: true }); + assert.equal(g.players[0].spice, 17); + assert.ok(g.decision?.kind === 'moritaniExtortion'); + const event = g.decision.event; + const payer = g.decision.player; + const payerIndex = f.ids.indexOf(payer); + assert.ok(payerIndex >= 0); + for (const [i, seat] of f.seats.entries()) { + const seatView = await f.restart().readSeatView(f.code, seat); + assert.equal(seatView.extortion.deferred, 0); + assert.equal(seatView.extortion.pending?.player, payer); + assert.equal(seatView.extortion.pending?.canPay, i === payerIndex ? true : null); + if (i !== 0) + assert.equal(seatView.moritaniTerror!.tokens.some((item) => item.id !== f.token), false); + } + const before = structuredClone(g); + await assert.rejects(f.restart().act(f.code, f.seats[0], g.version, + { type: 'decision', event, pay: true }, clock)); + await assert.rejects(f.restart().act(f.code, f.seats[payerIndex], g.version, + { type: 'decision', event: 'obsolete', pay: true }, clock)); + assert.deepEqual(await f.restart().readRoom(f.code), before); + assert.equal(f.writes.length, f.seats.length + 1); + let arrivals = 0; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + f.hooks.beforeWrite = async () => { + if (++arrivals === 2) release(); + await gate; + }; + let results: PromiseSettledResult[]; + try { + results = await Promise.allSettled([0, 1].map(() => f.restart().act( + f.code, f.seats[payerIndex], g.version, + { type: 'decision', event, pay: true }, clock, + ))); + } finally { + delete f.hooks.beforeWrite; + } + assert.equal(arrivals, 2); + assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1); + assert.deepEqual(f.writes.slice(-2).map((write) => write.changes).sort((a, b) => a - b), [0, 1]); + const done = await f.restart().readRoom(f.code); + assert.equal(done.version, g.version + 1); + assert.equal(done.players[0].spice, 20); + assert.equal(done.players[payerIndex].spice, g.players[payerIndex].spice - 3); + assert.equal(done.moritaniTerror!.tokens.find((item) => item.kind === 'extortion')!.status, 'removed'); + await assert.rejects(f.restart().act(f.code, f.seats[payerIndex], g.version, + { type: 'decision', event, pay: true }, clock), /table changed/i); + assert.deepEqual(await f.restart().readRoom(f.code), done); + } finally { + f.sqlite.close(); + } +});