From 8a2df97be8a2c5ead08967c140f5551324a4e9a2 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:47:32 +1100 Subject: [PATCH] Resume printed native specials through Semuta cost and cashout stages --- docs/CURRENT_STATUS.md | 20 ++ docs/IMPLEMENTATION_STATUS.md | 51 +++++ docs/RULE_DECISIONS.md | 1 + docs/SEMUTA_DISCARD_CONTINUATIONS.md | 27 +++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 55 ++++++ game/engine.ts | 157 ++++++++++++--- game/reference.ts | 12 +- tests/fixture-semuta-emperor-income.ts | 3 +- tests/fixture-semuta-emperor-share.ts | 20 ++ ...fixture-semuta-homeworld-emperor-income.ts | 3 +- tests/fixture-semuta-special-karama.ts | 180 ++++++++++++++++++ tests/karama-intents.test.ts | 4 +- tests/semuta-shipment-price.test.ts | 4 + tests/semuta-special-karama.test.ts | 154 +++++++++++++++ 14 files changed, 656 insertions(+), 35 deletions(-) create mode 100644 tests/fixture-semuta-special-karama.ts create mode 100644 tests/semuta-special-karama.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 5cfe88f4..71403001 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Semuta preserves native special effects and separate CHOAM cashout — 16 October.** +Printed special costs now pause at their native disposal point, then resume +one original effect with the committed card even after recovery. Emperor's +existing post-revival disposal remains post-revival and cannot return the +same counters again. CHOAM's later selected hand cards are a separate private +simultaneous batch after one earned credit; only one fresh card is recoverable. + +Affected sixteen-file224/224 passes, including thirteen new cases. +Twenty-six runtime/policy paths pass: eight original-action/eighteen +controlled. Four actual390px journeys pass: two original Guild source +claim/decline/prevention and two controlled CHOAM/Emperor sources, with real +movement, private selection and public force inspectors. No overflow/browser/ +application error. No worker/D1/real room, human server, deployment or gate +changed. Advanced E3 Semuta entries, converted costs and final assurance +remain separate. +[Canonical stages, evidence and scope](SEMUTA_SOURCE_UPDATE_20260907.md#printed-native-special-costs-and-choam-cashout--16-october-2026). +Final types/lint and production build pass with existing warnings. +The two affected CHOAM paths pass again after a type-only repair; current +links and all five still-partial Semuta evidence facets resolve. + **Semuta keeps the purchased price, not a duplicated refund — 16 October.** Printed shipment-price costs now offer their exact fresh card before the same original price activates. Recovery changes physical custody, not the diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 0584b9cc..6dff79bf 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,56 @@ # Dune implementation status +## 16 October 2026 — Semuta resumes printed native specials and private CHOAM cashout + +Separated validated native activation cost from the single native effect +body. Original public intent validation and Kull callers retain their +contract. Eleven pre-effect disposal sites use the committed-cost path; +Emperor's existing post-revival disposal remains afterward. Joint Richese/ +Nexus receipt/card ordering remains before the actual Treachery cost. +No native cost or effect is duplicated, and recovery does not require the +discarded card to remain in its original hand. + +`specialKaramaDiscard` binds the original intent, controls, phase, stock, +books, hand/custody and once-use. Its `effect` stage resumes the original +native suffix; `complete` retires an already-earned Emperor return. +CHOAM's selected cards now form a distinct private `cashout` batch after +one three-spice-each credit. Commit before inspection, select one fresh +physical card; the old activation is excluded and no payout repeats. + +Affected sixteen-file **224/224** passes, including thirteen new behavior +cases. Twenty-six runtime/policy paths pass: eight original-action Guild +paths including four existing policies, and eighteen explicitly controlled +native-effect paths. Controlled cases cover Emperor forces/leader, CHOAM, +Richese, Ixians, Tleilaxu, Fremen, Atreides and Harkonnen. Ordinary original +Guild proof uses actual paid cache/normal auctions and native shipping. +Controlled acquisitions/stocks/phase are explicitly disclosed, not original +acquisition or full-game evidence. + +Four actual styled390×844 GameTable journeys pass: original reserve stop +cost claim plus real Atreides movement; original typed stop cost all-seat +decline; controlled CHOAM initial cost decline then private Kulon claim +after20→26 credit; controlled Emperor cost claim after native17→19 reserves/ +3→1 Tanks, with no second return. Public inspectors show native pools. +Two scenes are original-action and two controlled. Viewport/scroll width390, +no browser/application errors. Existing income and refund fixtures now +perform actual neutral cost passes. Removed a crypto-call-count assertion +while retaining the real private-identity and hand-transfer outcomes; +no implementation/tactical pin was added. + +Only an owned memory-only host started/stopped. No worker, D1, authenticated +saved room, human server, deployed revision or gate was exercised or changed. +Outside-Git source/API/table proof and four initial scenes remain under +`/tmp/dune-semuta-special-*20261016*`. Advanced Ecaz/Moritani Semuta entries +retain their original gates; converted costs, wider combinations and final +assurance remain unfinished. The full goal remains active. +[Source/stages and scoped evidence](SEMUTA_SOURCE_UPDATE_20260907.md#printed-native-special-costs-and-choam-cashout--16-october-2026), +[continuations](SEMUTA_DISCARD_CONTINUATIONS.md#printed-special-activation-and-cashout-stages--16-october-2026). +Final `npm run check:quick` and production build pass, retaining existing +module-register, large-chunk and Unknown-route warnings. Both affected +CHOAM paths pass again after a type-only mutable collection annotation +repair. Six current local links and53 Semuta evidence paths resolve; +all five facets remain Partial without final/deployed assurance. + ## 16 October 2026 — Semuta preserves purchased price and claimed-cost custody Added `karamaShipmentPriceDiscard` for a printed ordinary Karama's original diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 6ae065d2..e5e084da 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -675,6 +675,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Semuta on native Guild cancellation costs | A printed permission/rate cancellation cost is a fresh public discard while its original reserve, cross/return or typed Homeworld parent remains unpaid. Claim/all-seat decline restores and applies that same cancellation once. Recovering the cost cannot restore denied transport, keep the native discount, finance an unfunded group or replay shipment. Existing denial/affordability policy is unchanged; shipment-price activation uses the distinct continuation below. | [Printed-source continuation and scoped proof](SEMUTA_SOURCE_UPDATE_20260907.md#native-guild-permission-and-rate-costs--16-october-2026) | | Purchased Karama typed price | Supplied authorized Advanced PDF physical16 says any player’s Force Shipment may be half normal, paid to bank. Original typed routes use that independent tariff without new route rights or Nexus stacking; the entire approved invoice is bank-only. Completion consumes only the original grant. Special-stop retains the existing provisional refund only for an unclaimed original cost; Semuta recovery preserves the price but ends that refund eligibility, even after rediscard. Immune Junction keeps its separate source choice. | [Connected typed routes](GUILD_HOMEWORLD_TRANSPORT_RULES.md#separate-permission-and-purchased-price--15-october-2026), [claimed-cost composition](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026) | | Semuta on shipment-price activation | Claim/decline of the exact printed activation cost creates the same original half-normal bank-paid grant once. Physical recovery does not change its beneficiary/creator. Under the selected nonduplication extension to provisional stop refunds, a claimed cost is not refundable even if the new holder rediscarded it; Guild may use that same card for its actual stop without returning it to the former owner. Unclaimed refunds remain. This is product composition, not a publisher worked example. | [Source, policy and scoped proof](SEMUTA_SOURCE_UPDATE_20260907.md#shipment-price-cost-and-claimed-card-refund--16-october-2026) | +| Semuta on printed native special costs | Preserve each native disposal point and original intent. Most printed costs precede their effect; native Emperor's completed revival precedes its cost disposal and is not repeated. CHOAM's selected hand cards form a separate private simultaneous batch after one earned credit; one fresh other-player card may be selected after commitment, never the earlier activation. Existing once-use, joint costs, native permission/typed custody and E3 entry gates remain unchanged. This composes the printed Semuta rule with existing native order, not a new publisher timing FAQ. | [Source/stages and original versus controlled proof](SEMUTA_SOURCE_UPDATE_20260907.md#printed-native-special-costs-and-choam-cashout--16-october-2026) | | Purchased price with immune Junction | Keep the original offered half/full tariff and normal payees; no quarter price or bank-only overlay. Under the standing choose-recommendation instruction, accepting this source with a purchased grant requires explicit acknowledgment that it ends unused, with no card refund. Consume only upon actual delivery, not failed, stale, unfunded or deferred acceptance; the ordinary purchased route remains available. This is a conservative product composition, not a publisher worked example. | [Source immunity and selected choice](JUNCTION_TRANSPORT_RULES.md#purchased-price-and-immune-offer--15-october-2026), [original versus controlled proof](JUNCTION_TRANSPORT_RUNTIME.md#purchased-price-source-choice--15-october-2026) | | Deferred shipment arrival | Quote unsupported ordinary arrivals before saving Guild interception. A validated legacy ordinary declaration may return unspent on the typed arrival error; corrupted declarations and special parent/card receipts are not silently cleared. This repairs continuation without deciding simultaneous reaction priority. | [Reproduction and bounded recovery](DEFERRED_SHIPMENT_ARRIVAL.md) | | Native Ambassador own-arrival continuation | Under the standing choose-recommended-option instruction, reuse committed entry after its own Guild income, BG Intrusion and accompaniment controls. Existing callbacks put a legitimate accompanying BG fighter child before the deferred original entrant; each sees the then-live token, so declining the child preserves a later original offer and consuming it does not recreate a token. Advisors remain exempt. This child-first/live-token composition is provisional, not a located publisher priority ruling. Actual busy controls, unsupported paired/module guards and separate native rate-window admission remain distinct. | [Native original-action evidence and boundary](DEFERRED_SHIPMENT_ARRIVAL.md#native-own-arrival-continuations--15-october-2026) | diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 73464774..1fc4f200 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -2,6 +2,33 @@ Read-only engine audit, 2026-09-07. Read the complete `docs/SEMUTA_DRUG_ENGINE_AUDIT.md`, then inspected every current `discard(...)` call, direct Treachery-discard mutation and `draw(g)` caller in `game/engine.ts`, plus the Truthtrance discard callback and pure Box/Distrans results. Names below are integration anchors; approximate current lines are navigation aids only. This report adopts no additional source ruling and changes no project file. +## Printed special activation and cashout stages — 16 October 2026 + +`specialKaramaDiscard` retains the validated original native intent, cost, +controls and signature. The original activation pays its cost once; the +authenticated saved discard can invoke the single native effect body with +that committed canonical card even after it moves to the claimant. + +- `effect`: the native pre-effect printed cost is fresh; resume its original + selection/permission/prevention/purchase/movement once. +- `complete`: native Emperor revival and its original ledger already + committed before disposal. Retire the cost event without another return. +- `cashout`: CHOAM's selected hand cards are one private simultaneous batch + after the original credit. Commit before inspection, choose one fresh + other-player card, and never repeat credit or include the earlier cost. + +Original joint Richese/Nexus cost ordering, once-use, original typed source, +refund and queued parent remain native. The source signature binds outcomes +and obligations, not a stale replacement Game. Existing paid purchase-income +fixtures and price-stop consumers now issue actual neutral passes before +their native suffix rather than assuming direct completion. + +[Scoped source/runtime proof](SEMUTA_SOURCE_UPDATE_20260907.md#printed-native-special-costs-and-choam-cashout--16-october-2026) +distinguishes eight original and eighteen controlled paths plus two original/ +two controlled mobile journeys. Advanced E3 entry gates, converted costs +and full assurance remain separate. + + ## Shipment-price activation cost — 16 October 2026 `karamaShipmentPriceDiscard` offers the exact public printed cost before diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index d214585e..9ec6bf55 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -2,6 +2,61 @@ 7 September 2026. Read `docs/SEMUTA_DRUG_ENGINE_AUDIT.md` in full, inspected the physical card again, checked current capacity/recovery code, and freshly searched publisher sources. No project runtime edits. +## Printed native special costs and CHOAM cashout — 16 October 2026 + +`specialKaramaDiscard` now offers a printed special-Karama activation cost +at its native disposal point. A single native effect implementation serves +direct activation and the authenticated committed-cost continuation; recovery +does not require the cost to remain in its former owner's hand. Original +source controls, resources, selections, once-use and pending obligations stay +bound, not restored from a replacement Game. + +For the native pre-effect costs, claim/pass resumes the original effect once: +Guild prevention (reserve/typed), Richese acquisition, Ix movement, Tleilaxu +prevention, Fremen worm, Atreides inspection and Harkonnen draw/return retain +their sources. Original Emperor force/leader revival already precedes its +cost disposal; its `complete` stage offers recovery **after** that earned +return and never revives again. Existing joint Richese/Nexus cost ordering +remains before Treachery disposal; its native contract is unchanged. + +CHOAM has two different events. Its public activation cost precedes cashout. +The selected ordinary hand cards then form one **private simultaneous batch** +after their existing three-spice-each credit. Declining the cost may retain +Semuta to commit and inspect that later batch; select only one fresh card, +never the old activation or one per discarded card. Claim/pass cannot credit +again. These are the existing printed/native orders, not a newly found +publisher timing FAQ. + +Affected sixteen-file **224/224** passes, including thirteen new behavior +cases. Twenty-six runtime/policy paths pass: eight original-action Guild +paths (including four existing profiles) and eighteen explicitly controlled +native-effect paths across the nine other admitted kind variants. Original +Guild/Atreides cards are bought through real normal/cache auctions. +Controlled sources disclose conserved later hands/armies/wallets/phase, +then use actual native declarations and reactions; they are not original +acquisition or full-game proof. + +Four actual styled390×844 GameTable journeys pass: original Guild reserve +cost claim/prevention plus ordinary movement; original typed cost all-seat +decline/prevention; controlled CHOAM cost decline then private Kulon selection +after credit20→26; controlled Emperor cost claim after reserves17→19/Tanks3→1, +with the same19/1 afterward. Public force inspectors show the native pools. +Two journeys are original-action and two controlled. Viewport/scroll width390, +no browser/application errors. Only an owned memory-only loopback host ran; +no worker, D1, authenticated saved room, human server, deployment or mode +gate changed. Proof/scenes remain outside Git under +`/tmp/dune-semuta-special-*20261016*`. + +The existing Advanced Ecaz/Moritani Semuta entry gates remain unchanged; +this is not original combined E3 acceptance. Converted Worthless/Shrine +costs, wider combinations and final assurance remain unfinished. +[Continuation stages](SEMUTA_DISCARD_CONTINUATIONS.md#printed-special-activation-and-cashout-stages--16-october-2026). +Final `npm run check:quick` and production build pass with existing +module-register, large-chunk and Unknown-route warnings. Both affected +CHOAM paths pass again after a collection type-only repair. Six current +local links and53 Semuta evidence paths resolve; all five facets stay Partial. + + ## Shipment-price cost and claimed-card refund — 16 October 2026 An ordinary **printed Karama** spent for the active player's shipment price diff --git a/game/engine.ts b/game/engine.ts index c6662450..13766951 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1416,6 +1416,15 @@ export type Game = { resume: NonNullable['resume']; stateSignature: string; } + | { + kind: 'specialKaramaDiscard'; + player: string; + card: string; + intent: SpecialKaramaIntent; + stage: 'effect' | 'complete' | 'cashout'; + resume: NonNullable['resume']; + stateSignature: string; + } | { kind: 'choamSaleDiscard'; owner: string; @@ -5555,6 +5564,64 @@ function karamaShipmentPriceDiscardSignature(g: Game, c: KaramaShipmentPriceDisc opportunity: karamaOpportunitySignature(g, { owner: c.player, use: c.use }), }); } +type SpecialKaramaDiscardContinuation = Extract< + NonNullable['continuation'], + { kind: 'specialKaramaDiscard' } +>; +function specialKaramaDiscardSignature(g: Game, c: SpecialKaramaDiscardContinuation) { + return JSON.stringify({ + player: c.player, card: c.card, intent: c.intent, stage: c.stage, + parent: nullentropyParentSignature(g, c.resume, `specialKarama:${c.intent.kind}:${c.card}`), + status: g.status, turn: g.turn, phase: g.phase, advanced: g.advanced, + active: g.active, order: g.order, ready: g.ready, + movementRemaining: g.movementRemaining, battle: g.battle, + lastBattle: g.lastBattleContext, homeworlds: g.homeworlds, + occupation: g.homeworldOccupationHistory, mobile: g.mobileStronghold, + tech: g.techTokens, skills: g.leaderSkills, strongholds: g.strongholdCards, + price: g.karamaShipping, spice: g.spice, storm: g.storm, + stormResolution: g.stormResolution, spiceWindow: g.spiceWindow, + spiceResolution: g.spiceResolution, summoned: g.summonedWorm, + rides: g.wormRides, nexus: g.nexusCards, + emperorPurchases: g.nexusEmperorPurchaseHistory, + auction: g.auction, sale: g.currentAuctionSale, + richeseAuction: g.richeseAuction, richeseBidding: g.richeseBidding, + cache: g.richeseCache?.map(card => card.id), + deck: g.deck.map(card => card.id), revivalPrevention: g.revivalPrevention, + players: g.players.map(p => ({ + id: p.id, faction: p.faction, ally: p.ally, + hand: p.hand.map(card => card.id), spice: p.spice, + forces: p.forces, reserves: p.reserves, tanks: p.tanks, + elites: p.elites, leaders: p.leaders, noField: p.noField, + noFieldEvent: p.noFieldEvent, advisors: p.advisors, + shipped: p.shipped, moved: p.moved, revived: p.revived, + specialKaramaUsed: p.specialKaramaUsed, + })), + }); +} +function stageSpecialKaramaCost( + g: Game, intent: SpecialKaramaIntent, card: Card, complete: boolean, +): boolean { + if (!g.semutaPreview || card.effect !== 'karama') return false; + return stageSpecialKaramaDiscard(g, intent, + [{ card, discardedBy: intent.owner, publicFace: true }], complete ? 'complete' : 'effect'); +} +function stageSpecialKaramaDiscard( + g: Game, intent: SpecialKaramaIntent, entries: FreshDiscardBatch['entries'], + stage: SpecialKaramaDiscardContinuation['stage'], +): boolean { + const continuation: SpecialKaramaDiscardContinuation = { + kind: 'specialKaramaDiscard', player: intent.owner, card: intent.card, intent, stage, + resume: { response: g.response, decision: g.decision, + pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, + stateSignature: '', + }; + if (!semutaOfferSupported(g, continuation, entries)) return false; + g.response = null; g.decision = null; g.pendingKarama = null; g.phaseOpening = null; + continuation.stateSignature = specialKaramaDiscardSignature(g, continuation); + stageTreacheryDiscard(g, stage === 'cashout' ? 'specialKarama:choamCashout' + : `specialKarama:${intent.kind}`, entries, continuation); + return true; +} type ChoamSaleDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'choamSaleDiscard' } @@ -6386,6 +6453,28 @@ function treacheryDiscardIntegrity(g: Game) { throw error; } } + } else if (continuation?.kind === 'specialKaramaDiscard') { + const c = continuation, entry = batch.entries[0], owner = getPlayer(g, c.player); + const emperor = c.intent.kind === 'emperorLeader' || c.intent.kind === 'emperorForces'; + const faction = emperor ? 'emperor' : c.intent.kind === 'guildHomeworld' ? 'guild' : c.intent.kind; + const cashout = c.stage === 'cashout' && c.intent.kind === 'choam' ? c.intent.cards : null; + requireRule( + g.advanced && !g.response && !g.decision && !g.pendingKarama && !g.phaseOpening && + c.player === c.intent.owner && c.card === c.intent.card && + c.intent.turn === g.turn && c.intent.phase === g.phase && + owner.faction === faction && + (c.stage === 'complete' ? emperor && owner.specialKaramaUsed === true + : cashout ? owner.specialKaramaUsed === true : c.stage === 'effect' && !emperor && !owner.specialKaramaUsed) && + (cashout + ? batch.cause === 'specialKarama:choamCashout' && batch.entries.length === cashout.length && + batch.entries.every((entry, index) => !entry.publicFace && entry.discardedBy === c.player && + entry.card.id === cashout[index] && entry.card.id !== c.card) + : batch.cause === `specialKarama:${c.intent.kind}` && batch.entries.length === 1 && + entry.card.id === c.card && entry.card.effect === 'karama' && + entry.publicFace && entry.discardedBy === c.player) && + c.stateSignature === specialKaramaDiscardSignature(g, c), + 'The spent special Karama no longer matches its original native activation.', + ); } else if (continuation?.kind === 'karamaShipmentPriceDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( @@ -7223,6 +7312,7 @@ function semutaOfferSupported( continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'karamaShipmentDiscard' && continuation.kind !== 'karamaShipmentPriceDiscard' && + continuation.kind !== 'specialKaramaDiscard' && continuation.kind !== 'kaitainDiscard' && continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'karamaPurchaseDiscard' && @@ -7257,6 +7347,7 @@ function semutaOfferSupported( continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'karamaShipmentDiscard' && continuation.kind !== 'karamaShipmentPriceDiscard' && + continuation.kind !== 'specialKaramaDiscard' && continuation.kind !== 'ordinaryCardDiscard' && continuation.kind !== 'nullentropyDiscard' && continuation.kind !== 'distransDiscard' && @@ -7325,7 +7416,8 @@ function semutaOfferSupported( return g.semutaPreview === true && g.status === 'playing' && g.players.some(p => p.faction === 'richese') && entries.length > 0 && - (continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' + (continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' || + (continuation.kind === 'specialKaramaDiscard' && continuation.stage === 'cashout') ? entries.every(entry => !entry.publicFace) : continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' || continuation.kind === 'nexusChoamBetrayal' || choamCunningDiscard(continuation) @@ -7359,6 +7451,7 @@ function semutaOfferSupported( (!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response)) : true) && (continuation.kind === 'battleCleanup' || + continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved' || continuation.kind === 'kaitainDiscard' || @@ -7386,8 +7479,9 @@ function semutaOfferSupported( (continuation.kind === 'amalDiscard' ? continuation.resume.phaseOpening?.passed.length === 0 : !continuation.resume.phaseOpening)))) && - (continuation.kind === 'karamaShipmentDiscard' || !g.karamaShipping) && - (continuation.kind === 'ixAllyCard' + (continuation.kind === 'karamaShipmentDiscard' || + continuation.kind === 'specialKaramaDiscard' || !g.karamaShipping) && + (continuation.kind === 'specialKaramaDiscard' || (continuation.kind === 'ixAllyCard' ? !!g.currentAuctionSale && continuation.sale.origin === g.currentAuctionSale.origin && (continuation.sale.origin === 'normal' ? !!g.auction && !g.richeseAuction @@ -7400,12 +7494,13 @@ function semutaOfferSupported( continuation.use.response.kind === 'emperorIncome' ? !!g.currentAuctionSale && (g.currentAuctionSale.origin === 'normal' ? !!g.auction && !g.richeseAuction : !!g.richeseAuction && !g.auction) - : !g.auction) && - (continuation.kind === 'saphoAuctionDiscard' || + : !g.auction)) && + (continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'saphoAuctionDiscard' || continuation.kind === 'ixAllyCard' || (continuation.kind === 'karamaIncomeDiscard' && continuation.use.response.kind === 'emperorIncome') || !g.richeseAuction) && (continuation.kind === 'saphoAggressorDiscard' || + continuation.kind === 'specialKaramaDiscard' || continuation.kind === 'residualPoisonDiscard' || continuation.kind === 'truthtranceDiscard' || continuation.kind === 'nullentropyDiscard' || @@ -7429,6 +7524,7 @@ function semutaOfferSupported( !g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && !g.pendingAmbassador && !g.pendingTerrorEntry && (continuation.kind === 'karamaShipmentDiscard' || + continuation.kind === 'specialKaramaDiscard' || (!g.pendingShipment && !g.pendingHomeworldShipment)) && !g.pendingRicheseGift && ((continuation.kind === 'karamaIncomeDiscard' && @@ -7656,6 +7752,13 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string const battle = g.battle!; if (battle.preLeader && !battle.preLeader.closed) battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player); + } else if (next.kind === 'specialKaramaDiscard') { + g.response = next.resume.response; + g.decision = next.resume.decision; + g.pendingKarama = next.resume.pendingKarama; + g.phaseOpening = next.resume.phaseOpening; + if (next.stage === 'effect') + resolveSpecialKaramaEffect(g, getPlayer(g, next.player), pending.batch.entries[0].card, next.intent); } else if (next.kind === 'karamaShipmentPriceDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -28913,15 +29016,30 @@ export function executeSpecialKaramaIntent( g, `${p.name} used the physical Truthtrance as Karama for their special faction power.`, ); - if (intent.kind === 'ecaz') { + if (intent.kind !== 'emperorLeader' && intent.kind !== 'emperorForces') { + if (intent.kind === 'richese' && intent.nexusEvent) { + const receipt = nexusRule(() => createEmperorNexusSpecialPurchase(g, p.id, card.id, intent.acquire)); + requireRule(receipt.event === intent.nexusEvent, + 'The original joint special-purchase source changed before disposal.'); + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + (g.nexusEmperorPurchaseHistory ??= []).push(receipt); + (g.nexusEmperorPurchaseEvents ??= []).push(receipt.event); + } discard(g, p, card.id); + if (stageSpecialKaramaCost(g, intent, card, false)) return; + } + resolveSpecialKaramaEffect(g, p, card, intent); +} + +/** Only the validated original activation or its authenticated cost receipt calls this. */ +function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: SpecialKaramaIntent): void { + if (intent.kind === 'ecaz') { p.specialKaramaUsed = true; g.battle!.ecazSpecialKarama = { owner: p.id, card: card.id, event: intent.event }; log(g, `${p.name} declared the once-game unarmed Ecaz special Karama. The revealed disc-value difference adds provisional virtual dial strength, without additional physical losses.`, { faction: 'ecaz', name: 'Special Karama' }); } else if (intent.kind === 'moritani') { const pending = g.moritaniSpecialKarama!; - discard(g, p, card.id); p.specialKaramaUsed = true; const winner = getPlayer(g, intent.source.opponent); pending.optional = pending.cards.filter(id => !intent.keep.includes(id) && !intent.discard.includes(id)); @@ -28939,15 +29057,6 @@ export function executeSpecialKaramaIntent( } else resumeMoritaniSpecialKarama(g); } else if (intent.kind === 'richese') { const selected = g.richeseCache!.findIndex((c) => c.id === intent.acquire); - if (intent.nexusEvent) { - const receipt = nexusRule(() => createEmperorNexusSpecialPurchase(g, p.id, card.id, intent.acquire)); - requireRule(receipt.event === intent.nexusEvent, - 'The original joint special-purchase source changed before disposal.'); - g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); - (g.nexusEmperorPurchaseHistory ??= []).push(receipt); - (g.nexusEmperorPurchaseEvents ??= []).push(receipt.event); - } - discard(g, p, card.id); if (!intent.nexusEvent) p.spice -= 3; p.hand.push(...g.richeseCache!.splice(selected, 1)); p.specialKaramaUsed = true; @@ -28982,17 +29091,20 @@ export function executeSpecialKaramaIntent( } } else if (intent.kind === 'choam') { const selected = intent.cards; - discard(g, p, card.id); - for (const id of selected) discard(g, p, id); + const entries: FreshDiscardBatch['entries'][number][] | null = g.semutaPreview ? [] : null; + for (const id of selected) { + const used = discard(g, p, id); + entries?.push({ card: used, discardedBy: p.id, publicFace: false }); + } p.spice += selected.length * 3; p.specialKaramaUsed = true; log( g, `${p.name} used special Karama to discard ${selected.length} cards and gain ${selected.length * 3} spice.`, ); + if (entries?.length) stageSpecialKaramaDiscard(g, intent, entries, 'cashout'); } else if (intent.kind === 'ixians') { const route = intent.route; - discard(g, p, card.id); p.specialKaramaUsed = true; relocateMobileStronghold(g, { player: p.id, @@ -29002,7 +29114,6 @@ export function executeSpecialKaramaIntent( }); } else if (intent.kind === 'tleilaxu') { const target = intent.target; - discard(g, p, card.id); p.specialKaramaUsed = true; g.revivalPrevention = { player: target, turn: g.turn }; if (g.pendingRevival?.nexusEmperorEvent) @@ -29017,7 +29128,6 @@ export function executeSpecialKaramaIntent( ); } else if (intent.kind === 'fremen') { const t = intent.territory; - discard(g, p, card.id); p.specialKaramaUsed = true; g.summonedWorm = { territory: t, @@ -29051,7 +29161,6 @@ export function executeSpecialKaramaIntent( } else if (intent.kind === 'atreides') { const b = g.battle!, target = intent.target; - discard(g, p, card.id); p.specialKaramaUsed = true; b.fullPlan = { owner: p.id, target }; g.decision = null; @@ -29061,7 +29170,6 @@ export function executeSpecialKaramaIntent( ); } else if (intent.kind === 'guildHomeworld') { const shipper = getPlayer(g, intent.target); - discard(g, p, card.id); p.specialKaramaUsed = true; if (g.pendingHomeworldShipment?.guildNexusEvent) finishGuildCunningShipment(g,shipper.id,'homeworld',g.pendingHomeworldShipment,'stopped'); shipper.shipped = true; @@ -29072,7 +29180,6 @@ export function executeSpecialKaramaIntent( log(g, `${p.name} used special Karama to stop ${shipper.name}’s interplanetary shipment. No forces arrive or spice is paid; their shipment is used and movement remains available.`); } else if (intent.kind === 'guild') { const shipper = getPlayer(g, intent.target); - discard(g, p, card.id); p.specialKaramaUsed = true; if (g.pendingShipment?.source === 'ambassador') { currentGuildAmbassador(g, g.pendingShipment.ambassadorEvent); @@ -29119,10 +29226,10 @@ export function executeSpecialKaramaIntent( techIncome(g, 'axlotl', p); discard(g, p, card.id); p.specialKaramaUsed = true; + stageSpecialKaramaCost(g, intent, card, true); } else if (intent.kind === 'harkonnen') { const target = getPlayer(g, intent.target), count = intent.amount; - discard(g, p, card.id); p.specialKaramaUsed = true; const taken = shuffle(target.hand).slice(0, count); target.hand = target.hand.filter((c) => !taken.some((t) => t.id === c.id)); diff --git a/game/reference.ts b/game/reference.ts index d8e007f6..e9ad79a4 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1203,6 +1203,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A printed Karama canceling original paid Guild shipment income, non-Ambassador Emperor auction income or Richese special-purchase income also offers its exact public cost before the native denial. The price and physical transaction have already committed: claim or Continue cannot refund spice, re-ship forces, undo cache acquisition or replay a purchase. Original Movement, next-lot inspection or the Richese purchase’s suspended controls resume once. A winner may spend the very Karama it just bought; claiming that identity does not reverse its payment. Homeworld and Ambassador payment compositions remain separate.', 'A printed Karama canceling native Guild transport permission or shipment rate now offers that exact public cost while the original reserve, cross/return or typed Homeworld declaration remains unpaid. Claim or Continue restores and applies that cancellation once. Recovering the cost never restores a denied route, retains the native discount, finances an unfunded group or repeats shipment. Ordinary movement and legal replacements remain; independent price grants stay distinct.', 'A printed Karama buying another active player’s shipment price also offers its exact fresh cost before that same original grant activates. Claim or Continue preserves the original beneficiary, creator and half-normal bank-paid price. Physical recovery ends the provisional stop-refund eligibility for that cost, even if the claimant later discards it again. Guild can spend that very recovered card for an actual special stop without also refunding it to the former owner. Unclaimed original costs retain their older refund; this is selected source composition, not a publisher worked example.', + 'Printed native special-Karama costs now offer Semuta at their original disposal point. Recovery or Continue resumes the same committed native effect once even when its physical cost changed hands. Emperor revival already happened before its native disposal and is not repeated. CHOAM has a separate later private batch for its selected cashout cards after one earned credit: commit before inspection and recover one fresh card, never the old activation or one per discarded card. Original once-use, typed sources and refund remain; Advanced E3 entry and converted costs keep their separate gates.', 'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, Tupile cleanup and printed Karama canceling native Guild/Emperor/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect: claim/pass adds one movement bonus, denies free allowance without charging/moving forces, stops the uncommitted Baliset move, returns one original board counter before the same CHOAM close, or applies protection before the same recorded storm moves. Printed Karama canceling these powers pauses separately before denial; the retained Worthless fuel is not a candidate. A canceled reactive Baliset resumes its original declared move once; canceled Kulon/La La La/Gamont/Jubba do not apply their effects. A recovered card grants no other faction CHOAM power. Kaitain private groups require commitment before selection; each Tupile holder has a separate cost. Native income denial retains payment and the original occupied-share allocation. Basic unresolved epochs, Kull, other producers and public starts remain guarded.', 'Legal completed CHOAM market sales also offer their used card in the Homeworld profile. Worthless cashout keeps its original two-spice credit; a non-Worthless duplicate keeps its three-spice credit and the original same-name witness stays held. The high-Tupile Worthless-sale prohibition still applies, including duplicate Worthless sales. Claim/pass cannot repeat payout, undo the actual reserve shipment or replace the original market close; no new bank-income interpretation is made.', 'Final Ornithopter disposal also offers its used card in the Homeworld profile after the one-group range-three or original two-group movement completes, including an early end with no useful original group. Claim/pass cannot move those counters again or replay the paid native shipment; prior-move/Hajr, advisor and Homeworld-transfer boundaries remain. Clean Sapho Once Around and unstarted Movement first/last costs likewise offer after reordering the finite unacted queue. Earlier bids/funding and physical storm order stay committed, and the same next bidder or combined turn resumes once. Cyclic auctions, partly completed turns, first-versus-Guild and late battle timing are not opened.', @@ -1227,31 +1228,31 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset and ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, other private discards, nested responses and other pending rewards remain unfinished. Printed native Guild permission/rate costs now use a dedicated unpaid shipment continuation for reserve, cross/return and typed Homeworld sources; claim/pass applies the same denial/repricing once without delivery replay or invented funding. Printed shipment-price activation now has its own continuation: claim/decline creates the same original price once. Recovered physical cost retains the beneficiary/creator but loses provisional refund eligibility even after rediscard; the same recovered card can pay a real stop without being returned to its former owner.', + 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon, proactive or reactive La La La/Baliset and ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages, including before native Advanced Harkonnen capture. A private normal-auction or Black Market Ixian ally replacement pauses before its draw with a neutral, face-hidden offer; original payment and seller income settle once. Cache purchases retain their native allied-replacement prohibition. Ordinary definite Truthtrance answers pause whether or not another question remains reserved, and also when the answer bound a supported shipment or unsubmitted battle-plan promise. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. The user-approved full-hand exchange preserves final hand count and incoming reservations. Other Karama uses, other reactive Worthless uses, other private discards, nested responses and other pending rewards remain unfinished. Printed native Guild permission/rate costs now use a dedicated unpaid shipment continuation for reserve, cross/return and typed Homeworld sources; claim/pass applies the same denial/repricing once without delivery replay or invented funding. Printed shipment-price activation now has its own continuation: claim/decline creates the same original price once. Recovered physical cost retains the beneficiary/creator but loses provisional refund eligibility even after rediscard; the same recovered card can pay a real stop without being returned to its former owner. Printed native special costs now resume one committed native effect through effect/complete/cashout stages. Original Emperor post-revival disposal is preserved; CHOAM selected cards are a later private simultaneous batch after one credit. Existing native once-use, joint ordering and typed parent remain.', }, { area: 'Player controls' as const, status: 'Partial' as const, detail: - 'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. In a mixed-owner battle batch, a committed holder can inspect and select only cards discarded by another player; nonholders receive no candidates. Normal Richese starts remain gated. Earlier four original-action390px native cost/continuation journeys exercise full4 reserve claim, denied cross plus ordinary1, typed full2 all-seat decline, and poor2/full4 release then one-force full2. No overflow/browser/application error; one tool-only stale-node race was resolved with fresh evidence, not replay. Four new original-action390px journeys start at actual price activation: bank3 allowance while Guild holds the cost, same-card reserve stop, typed bank2 after all-seat decline, and same-card typed stop followed by real ordinary movement. No overflow/browser/application errors; two tool-only stale-node races were resolved without replay.', + 'All seats can Continue at the neutral fresh-discard boundary; only the actual holder sees Commit. In a mixed-owner battle batch, a committed holder can inspect and select only cards discarded by another player; nonholders receive no candidates. Normal Richese starts remain gated. Earlier four original-action390px native cost/continuation journeys exercise full4 reserve claim, denied cross plus ordinary1, typed full2 all-seat decline, and poor2/full4 release then one-force full2. No overflow/browser/application error; one tool-only stale-node race was resolved with fresh evidence, not replay. Earlier four original-action390px price journeys start at actual activation: bank3 allowance while Guild holds the cost, same-card reserve stop, typed bank2 after all-seat decline, and same-card typed stop followed by real ordinary movement. No overflow/browser/application errors; two tool-only stale-node races were resolved without replay. Four current390px journeys cover original reserve/typed Guild prevention claim/decline and controlled CHOAM private Kulon selection plus Emperor post-revival cost claim. Two original/two controlled; actual native movement/public force inspectors, no overflow/browser/application errors.', }, { area: 'AI' as const, status: 'Partial' as const, detail: - 'All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy. For both new native permission and rate cost parents, all four actual original-state profiles recover the exact cost through legal actions and preserve the original denial/repricing. No tuning or calibration. All four original-state profiles claim the price cost; a subsequent real owner action executes the retained bank-paid shipment. This is legal source participation, not a new strategy preference or calibration.', + 'All four AI profiles use their own projected hand to commit or pass the exact current event, and can choose only privately projected committed candidates. This is minimal legal participation, not tuned strategy. For both new native permission and rate cost parents, all four actual original-state profiles recover the exact cost through legal actions and preserve the original denial/repricing. No tuning or calibration. All four original-state profiles claim the price cost; a subsequent real owner action executes the retained bank-paid shipment. This is legal source participation, not a new strategy preference or calibration. All four existing profiles recover the original Guild stop cost via legal private actions, and the native prevention then executes once. No new preference, tuning or calibration.', }, { area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers (including queued questions, supported shipment promises and battle answers before or after plan submission/reveal), four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal and three battle batches from a private normal-auction or Black Market Ixian ally replacement, the cache replacement prohibition, privacy-neutral acknowledgements, the user-approved atomic full-hand exchange and remaining discard sources. The native Guild followup distinguishes unpaid transport from completed income and keeps existing denial/no-debt interpretations. Both original cards are actually auctioned; two extra active-price probes explicitly disclose controlled second-card acquisition. Printed price-cost activation is connected below; converted and special costs remain separate. The printed price-cost continuation and claimed-cost nonduplication extension are explicit. Unclaimed original refunds remain provisional and unchanged; recovery does not transfer or repeat the price benefit. Converted and other special-Karama discard sources remain separate.', + 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers (including queued questions, supported shipment promises and battle answers before or after plan submission/reveal), four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal and three battle batches from a private normal-auction or Black Market Ixian ally replacement, the cache replacement prohibition, privacy-neutral acknowledgements, the user-approved atomic full-hand exchange and remaining discard sources. The native Guild followup distinguishes unpaid transport from completed income and keeps existing denial/no-debt interpretations. Both original cards are actually auctioned; two extra active-price probes explicitly disclose controlled second-card acquisition. Printed price-cost activation is connected below; converted and special costs remain separate. The printed price-cost continuation and claimed-cost nonduplication extension are explicit. Unclaimed original refunds remain provisional and unchanged; recovery does not transfer or repeat the price benefit. Converted costs and wider E3 entries remain separate; printed special costs are connected below. Printed special costs and CHOAM cashout stages follow native disposal order without a new publisher timing ruling. Eight original and18 controlled paths plus2 original/2 controlled surfaces are explicit. Advanced E3 entry, converted costs and full assurance remain separate.', }, { area: 'Verification' as const, status: 'Partial' as const, detail: - 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified. Earlier thirteen-file158/158 passes, including12 new cases. Thirty-two original-action runtime/policy paths and four original-action390px journeys pass; two separately controlled cross/typed active-price probes retain the original independent grant. No worker/D1/real-room/deployed assurance or complete-mode claim. New eighteen-file234/234 passes, including8 new cases. Fourteen original-action runtime/policy paths and four actual original-action390px journeys pass; two explicitly controlled extra-stop-card acquisitions prove unclaimed refunds. The original same-card stop uses no extra card or staged acquisition. No worker/D1/saved/deployed or complete-mode assurance.', + 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified. Earlier thirteen-file158/158 passes, including12 new cases. Thirty-two original-action runtime/policy paths and four original-action390px journeys pass; two separately controlled cross/typed active-price probes retain the original independent grant. No worker/D1/real-room/deployed assurance or complete-mode claim. Earlier eighteen-file234/234 passes, including8 new cases. Fourteen original-action runtime/policy paths and four actual original-action390px journeys pass; two explicitly controlled extra-stop-card acquisitions prove unclaimed refunds. The original same-card stop uses no extra card or staged acquisition. No worker/D1/saved/deployed or complete-mode assurance. Current sixteen-file224/224 passes with13 new behavior cases. Twenty-six runtime/policy paths pass:8 original-action Guild paths including4 profiles,18 explicitly controlled native-effect paths. Four actual390px journeys pass (2 original/2 controlled). Existing income/refund consumers use actual neutral passes. No worker/D1/saved/deployed or complete-mode claim.', evidence: [ 'tests/semuta-engine.test.ts', 'tests/semuta-recovery.test.ts', @@ -1305,6 +1306,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-homeworld-choam-battle-karama.test.ts', 'tests/semuta-guild-transport.test.ts', 'tests/semuta-shipment-price.test.ts', + 'tests/semuta-special-karama.test.ts', ], }, ], diff --git a/tests/fixture-semuta-emperor-income.ts b/tests/fixture-semuta-emperor-income.ts index 462fba3f..bfd56fa4 100644 --- a/tests/fixture-semuta-emperor-income.ts +++ b/tests/fixture-semuta-emperor-income.ts @@ -7,7 +7,7 @@ import type { Card } from '../game/cards'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech'; import { - moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer, + moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer, passSemutaSpecialDiscards, } from './fixture-semuta-emperor-share'; export type SemutaEmperorIncomeKind = 'emperorIncome' | 'richesePurchaseIncome'; @@ -138,6 +138,7 @@ export function createSemutaEmperorIncomeFixture( const purchase: Action = { type: 'card', mode: 'special', card: special!, acquire: acquired }; sourceActions.push({ actor: 'r', action: purchase }); game = applyAction(game, 'r', purchase); + game = passSemutaSpecialDiscards(game, sourceActions); } else { if (!options.acquiredKarama) { const first = game.deck.find(card => card.effect !== 'karama'); diff --git a/tests/fixture-semuta-emperor-share.ts b/tests/fixture-semuta-emperor-share.ts index e64d109d..e47b1363 100644 --- a/tests/fixture-semuta-emperor-share.ts +++ b/tests/fixture-semuta-emperor-share.ts @@ -127,3 +127,23 @@ export function semutaEmperorSharePhysical(game: Game) { export function semutaEmperorShareViews(game: Game): void { for (const player of game.players) viewGame(game, player.id); } + +/** Pass only original special costs/results; never skip a committed private selection. */ +export function passSemutaSpecialDiscards( + state: Game, actions?: { actor: string; action: Action }[], +): Game { + let game = state; + for (let n = 0; game.pendingTreacheryDiscard?.batch.cause.startsWith('specialKarama:') && n < 20; n++) { + const pending = game.pendingTreacheryDiscard; + const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer'); + const event = pending.batch.event; + for (const seat of game.players.filter(p => !reaction.passed.includes(p.id))) { + const action: Action = { type: 'semutaPass', event }; + actions?.push({ actor: seat.id, action }); + game = applyAction(game, seat.id, action); + } + } + assert.ok(!game.pendingTreacheryDiscard?.batch.cause.startsWith('specialKarama:'), + 'The native special discard continuation must actually retire.'); + return game; +} diff --git a/tests/fixture-semuta-homeworld-emperor-income.ts b/tests/fixture-semuta-homeworld-emperor-income.ts index d6b943f9..5b24b6bf 100644 --- a/tests/fixture-semuta-homeworld-emperor-income.ts +++ b/tests/fixture-semuta-homeworld-emperor-income.ts @@ -10,7 +10,7 @@ import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech'; import { semutaEmperorIncomeCards } from './fixture-semuta-emperor-income'; import { - moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer, + moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer, passSemutaSpecialDiscards, } from './fixture-semuta-emperor-share'; export type SemutaHomeworldEmperorIncomeKind = 'emperorIncome' | 'richesePurchaseIncome'; @@ -208,6 +208,7 @@ export function createSemutaHomeworldEmperorIncomeFixture( beforePayment = structuredClone(game); acquired = game.richeseCache![0].id; act({ actor: 'r', action: { type: 'card', mode: 'special', card: special!, acquire: acquired } }, sourceActions); + game = passSemutaSpecialDiscards(game, sourceActions); } else { acquired = options.acquiredKarama ? karama : game.deck.find(card => card.effect !== 'karama')!.id; nextOriginalDraw(game, acquired); diff --git a/tests/fixture-semuta-special-karama.ts b/tests/fixture-semuta-special-karama.ts new file mode 100644 index 00000000..ca866fa9 --- /dev/null +++ b/tests/fixture-semuta-special-karama.ts @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, + viewGame, type Action, type Game, +} from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { TERRITORIES, MOBILE_LOCATION, mobileRoutes } from '../game/board'; +import { presenceAt } from '../game/force-presence'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { createSemutaGuildTransport } from './fixture-semuta-guild-transport'; +import { nextSemutaGuildIncomeNativeStep } from './fixture-semuta-guild-income'; +import { moveSemutaEmperorCard, passSemutaSpecialDiscards } from './fixture-semuta-emperor-share'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; + +export type SemutaSpecialStep = { actor: string; action: Action }; +const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!; +const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard; + +/** Two original paid auctions deliberately give Semuta to the shipper and the + * printed Karama to native Guild. No played hand, wallet, force or phase is assigned. */ +export function createOriginalSemutaSpecialGuild(homeworlds = false) { + const original = createSemutaGuildTransport({ route: homeworlds ? 'world' : 'reserve' }); + let game = structuredClone(original.setup); + const actions: SemutaSpecialStep[] = []; + const act = (actor: string, action: Action) => { + actions.push({ actor, action }); game = applyAction(game, actor, action); + }; + const reach = (until: (state: Game) => boolean) => { + for (let n = 0; !until(game) && n < 1600; n++) { + const next = nextSemutaGuildIncomeNativeStep(game); act(next.actor, next.action); + } + assert.ok(until(game)); + }; + const at = game.deck.findIndex(c => c.effect === 'karama'); assert.ok(at >= 0); + const card = game.deck.splice(at, 1)[0]; game.deck.unshift(card); + for (const position of [0, 1]) { + const index = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c); + assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } + reach(state => state.decision?.kind === 'richeseCache'); + act('r', { type: 'decision', event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID, + method: 'onceAround', direction: 'counterclockwise' }); + reach(state => !!state.richeseAuction && !state.richeseAuction.outcome && + state.richeseAuction.active === 'a' && clean(state)); + act('a', { type: 'richeseBid', event: game.richeseAuction!.event, amount: 1 }); + reach(state => player(state, 'a').hand.some(c => c.id === SEMUTA_DRUG_ID) && clean(state)); + const boughtSemuta = structuredClone(game); + reach(state => !!state.auction && state.auction.index === 0 && state.auction.active === 'g' && clean(state)); + act('g', { type: 'bid', amount: 1 }); + reach(state => player(state, 'g').hand.some(c => c.id === card.id) && clean(state)); + const boughtKarama = structuredClone(game); + reach(state => state.phase === 5 && state.active === 'a' && clean(state)); + const beforeShipment = structuredClone(game); + let shipment: Action, target: string; + if (homeworlds) { + const quote = homeworldShipmentChoice(viewGame(game, 'a'), 'homeworld:emperor', + { 'homeworld:atreides': { normal: 2, elite: 0 } }); + assert.ok(quote.action, quote.blocked ?? undefined); shipment = quote.action; target = 'homeworld:emperor'; + } else { + const destination = TERRITORIES.find(t => t.type === 'sand' && + t.sectors.some(s => s !== game.storm) && game.players.every(p => presenceAt(p, t.id) === 0)); + assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!; + shipment = { type: 'ship', territory: destination.id, sector, amount: 2, elite: 0, allyPayment: 0 }; + target = `${destination.id}:${sector}`; + } + act('a', shipment); + assert.equal(game.decision?.kind, homeworlds ? 'homeworldShipmentGuild' : 'guildShipment'); + return { original, actions, beforeShipment, beforeCost: structuredClone(game), game, + boughtSemuta, boughtKarama, homeworlds, shipment, target, + owner: 'g', holder: 'a', shipper: 'a', karama: card.id, semuta: SEMUTA_DRUG_ID, + action: { type: 'card', mode: 'special', card: card.id } as Action, + provenance: [ + 'Original printed Semuta setup; only original undealt lottery and conserved unplayed deck order selected.', + 'Atreides pays1 for the actual public cache Semuta; Guild pays1 for the actual normal Karama auction.', + 'Original Atreides shipment and actual native interception; no played cards/forces/wallets/phase staged.', + ] }; +} + +export function reactSemutaSpecial(game: Game, holder: string, claim: boolean, card?: string): Game { + const pending = game.pendingTreacheryDiscard; assert.ok(pending); + assert.ok(pending.reaction?.stage === 'offer'); const event = pending.batch.event; + if (!claim) { + const reaction = pending.reaction; + for (const seat of game.players.filter(p => !reaction.passed.includes(p.id))) + game = applyAction(game, seat.id, { type: 'semutaPass', event }); + return game; + } + game = applyAction(game, holder, { type: 'semutaCommit', event }); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + assert.ok(card, 'Private multi-card recovery needs the caller’s actual selection.'); + assert.ok(viewGame(game, holder).semutaReaction!.candidates.some(c => c.id === card)); + game = applyAction(game, holder, { type: 'semutaSelect', event, card }); + } + return game; +} + +export type ControlledSemutaSpecialKind = 'emperorForces' | 'emperorLeader' | 'choam' | + 'richese' | 'ixians' | 'tleilaxu' | 'fremen' | 'atreides' | 'harkonnen'; + +/** CONTROLLED native-effect fixture: genuine native Semuta setup, then conserved + * later hands/armies/wallets and phase are staged to isolate each printed boundary. + * These are not original-action acquisition or full-game claims. */ +export function createControlledSemutaSpecial(kind: ControlledSemutaSpecialKind) { + const faction: FactionId = kind === 'emperorForces' || kind === 'emperorLeader' ? 'emperor' : kind; + let game = createGame('CONTROLLEDSPECIALSEMUTA', newPlayer('p', 'Owner', faction), true, + ['choam', ...(['ixians', 'tleilaxu'].includes(faction) ? ['ix' as const] : [])]); + joinGame(game, newPlayer('r', faction === 'richese' ? 'Guild' : 'Richese', faction === 'richese' ? 'guild' : 'richese')); + joinGame(game, newPlayer('h', 'Holder', faction === 'atreides' ? 'guild' : 'atreides')); + for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' }); + game = initializeSemutaGameForAudit(game); + for (let n = 0; game.status === 'setup' && n < 300; n++) { + const next = nextSemutaGuildIncomeNativeStep(game); game = applyAction(game, next.actor, next.action); + } + assert.equal(game.status, 'playing'); + for (const p of game.players) { + game.deck.push(...p.hand.splice(0)); + p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 20; p.shipped = false; p.moved = 0; + if (p.elites) { + p.elites.reserves += p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); + p.elites.forces = {}; p.elites.tanks = 0; p.elites.revived = 0; + } + } + const karama = game.deck.find(c => c.effect === 'karama'); assert.ok(karama); + moveSemutaEmperorCard(game, karama.id, 'p'); moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'h'); + game.phase = 4; game.active = 'p'; game.order = ['p', 'r', 'h']; + game.movementRemaining = ['p', 'r', 'h']; game.ready = []; + game.response = null; game.decision = null; game.phaseOpening = null; + game.storm = 18; game.spiceWindow = null; game.spiceResolution = null; game.spice = {}; + const owner = player(game, 'p'), holder = player(game, 'h'); + const action: Action = { type: 'card', mode: 'special', card: karama.id }; + let selected: string[] = [], acquired: string | undefined, target: string | undefined; + if (kind === 'emperorForces') { + owner.reserves = 17; owner.tanks = 3; action.amount = 2; action.elite = 0; + } else if (kind === 'emperorLeader') { + owner.leaders[0].dead = true; action.leader = owner.leaders[0].id; + } else if (kind === 'choam') { + game.phase = 3; + const cards = game.deck.filter(c => c.effect !== 'karama' && c.id !== SEMUTA_DRUG_ID).slice(0, 2); + for (const card of cards) moveSemutaEmperorCard(game, card.id, 'p'); + selected = cards.map(c => c.id); action.cards = selected; + } else if (kind === 'richese') { + game.phase = 3; acquired = game.richeseCache![0].id; action.acquire = acquired; + } else if (kind === 'ixians') { + game.phase = 5; game.mobileStronghold = { location: 'polar_sink:0' }; + owner.forces = { [MOBILE_LOCATION]: 1 }; owner.reserves = 19; + action.route = mobileRoutes(game, 2)[0]; action.collect = false; + } else if (kind === 'tleilaxu') { + holder.reserves = 18; holder.tanks = 2; game.active = 'h'; + game = applyAction(game, 'h', { type: 'revive', amount: 2, elite: 0 }); + assert.equal(game.decision?.kind, 'revivalStop'); + } else if (kind === 'fremen') { + game.phase = 1; + const destination = TERRITORIES.find(t => t.type === 'sand' && t.sectors.some(s => s !== game.storm)); + assert.ok(destination); const sector = destination.sectors.find(s => s !== game.storm)!; + target = `${destination.id}:${sector}`; holder.forces[target] = 2; holder.reserves = 18; + action.territory = destination.id; + } else if (kind === 'atreides') { + game.phase = 6; owner.forces = { 'arrakeen:10': 2 }; owner.reserves = 18; + holder.forces = { 'arrakeen:10': 2 }; holder.reserves = 18; + game = applyAction(game, 'p', { type: 'chooseBattle', territory: 'arrakeen', target: 'h' }); + for (let n = 0; n < 40 && (game.response || game.battle?.preparation); n++) { + if (game.response) game = applyAction(game, + game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' }); + else game = applyAction(game, game.battle!.preparation!.owner, { type: 'declineBattlePower' }); + } + assert.equal(game.decision?.kind, 'fullPlanOffer'); action.target = 'h'; + } else { + game.phase = 3; target = 'h'; action.target = target; action.amount = 1; + const cards = game.deck.filter(c => c.effect !== 'karama' && c.id !== SEMUTA_DRUG_ID).slice(0, 2); + assert.equal(cards.length, 2); + moveSemutaEmperorCard(game, cards[0].id, 'p'); selected = [cards[0].id]; + moveSemutaEmperorCard(game, cards[1].id, 'h'); + } + return { kind, beforeCost: structuredClone(game), game, action, owner: 'p', holder: 'h', + karama: karama.id, semuta: SEMUTA_DRUG_ID, selected, acquired, target, + provenance: ['CONTROLLED genuine native Semuta setup, then conserved later hands/armies/wallets/phase.', + 'Actual native battle/revival declarations where needed; actual printed activation and Semuta reactions thereafter.'] }; +} + +export { passSemutaSpecialDiscards }; diff --git a/tests/karama-intents.test.ts b/tests/karama-intents.test.ts index f055ca9a..a9494038 100644 --- a/tests/karama-intents.test.ts +++ b/tests/karama-intents.test.ts @@ -270,7 +270,7 @@ void test('Guild and Tleilaxu intentions bind the actual declaration, not just i } }); -void test('Harkonnen preparation does not expose or select unseen cards; only commitment draws them', (t) => { +void test('Harkonnen preparation withholds unseen identities until the actual hand transfer', () => { const g = fixture('harkonnen'); g.phase = 3; const foreign = [hold(g, 'q', 'Baliset'), hold(g, 'q', 'Snooper')]; @@ -280,9 +280,7 @@ void test('Harkonnen preparation does not expose or select unseen cards; only co action(g, { target: 'q', amount: 1 }), ); assert.ok(foreign.every((id) => !JSON.stringify(intent).includes(id))); - const draw = t.mock.method(crypto, 'getRandomValues'); executeSpecialKaramaIntent(g, intent); - assert.ok(draw.mock.callCount() > 0); assert.equal(g.players[1].hand.length, 1); assert.equal(g.players[0].hand.length, 1); assert.equal(g.decision?.kind, 'handExchange'); diff --git a/tests/semuta-shipment-price.test.ts b/tests/semuta-shipment-price.test.ts index 307e1989..af7733ba 100644 --- a/tests/semuta-shipment-price.test.ts +++ b/tests/semuta-shipment-price.test.ts @@ -5,6 +5,7 @@ import { createSemutaShipmentPrice, reactSemutaShipmentPrice, semutaPurchasedShipment, allowSemutaPurchasedShipment, } from './fixture-semuta-shipment-price'; +import { passSemutaSpecialDiscards } from './fixture-semuta-emperor-share'; const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!; function reject(game: Game, actor: string, action: Action) { @@ -54,6 +55,8 @@ for (const homeworlds of [false, true]) const shipment = semutaPurchasedShipment(game, homeworlds); game = applyAction(game, f.beneficiary, shipment.action); game = applyAction(game, f.guild, { type: 'card', card: f.karama, mode: 'special' }); + assert.equal(player(game, f.beneficiary).shipped, false); + game = passSemutaSpecialDiscards(game); const shipper = player(game, f.beneficiary); assert.equal(shipper.spice, original.spice); assert.equal(shipper.reserves, original.reserves); @@ -83,6 +86,7 @@ void test('declining the price cost retains its existing exact-card refund when const shipment = semutaPurchasedShipment(game, homeworlds); game = applyAction(game, f.beneficiary, shipment.action); game = applyAction(game, f.guild, { type: 'card', card: stop.id, mode: 'special' }); + game = passSemutaSpecialDiscards(game); assert.equal(player(game, f.beneficiary).spice, before.spice); assert.equal(player(game, f.beneficiary).reserves, before.reserves); assert.equal(player(game, f.beneficiary).shipped, true); diff --git a/tests/semuta-special-karama.test.ts b/tests/semuta-special-karama.test.ts new file mode 100644 index 00000000..e2bf8ae0 --- /dev/null +++ b/tests/semuta-special-karama.test.ts @@ -0,0 +1,154 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { + createOriginalSemutaSpecialGuild, createControlledSemutaSpecial, + reactSemutaSpecial, type ControlledSemutaSpecialKind, +} from './fixture-semuta-special-karama'; + +const player = (game: Game, actor: string) => game.players.find(p => p.id === actor)!; +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} +function exactCost(game: Game, id: string) { + assert.equal(game.discard.filter(c => c.id === id).length + game.deck.filter(c => c.id === id).length + + game.players.reduce((sum, p) => sum + p.hand.filter(c => c.id === id).length, 0), 1); +} + +for (const homeworlds of [false, true]) + void test(`original ${homeworlds ? 'Homeworld' : 'reserve'} Guild stop waits for cost recovery and then prevents the same unpaid shipment`, () => { + for (const claim of [false, true]) { + const f = createOriginalSemutaSpecialGuild(homeworlds); + let game = applyAction(f.beforeCost, f.owner, f.action); + assert.equal(game.pendingTreacheryDiscard?.batch.cause, + homeworlds ? 'specialKarama:guildHomeworld' : 'specialKarama:guild'); + assert.equal(player(game, f.shipper).shipped, false); + assert.equal(player(game, f.shipper).spice, 9); + assert.equal(player(game, f.shipper).reserves, 10); + assert.equal(player(game, f.owner).specialKaramaUsed, undefined); + reject(game, f.shipper, { type: 'endMovement' }); + game = reactSemutaSpecial(game, f.holder, claim); + assert.equal(player(game, f.shipper).shipped, true); + assert.equal(player(game, f.shipper).spice, 9); + assert.equal(player(game, f.shipper).reserves, 10); + assert.equal(player(game, f.shipper).moved, 0); + assert.equal(player(game, f.owner).specialKaramaUsed, true); + assert.equal(player(game, f.holder).hand.some(c => c.id === f.karama), claim); + assert.equal(game.discard.filter(c => c.id === f.semuta).length, claim ? 1 : 0); + if (homeworlds) assert.equal(game.homeworlds!.custody!.visitors[f.target]?.[f.shipper], undefined); + else assert.equal(player(game, f.shipper).forces[f.target] ?? 0, 0); + exactCost(game, f.karama); + reject(game, f.owner, f.action); + game = applyAction(game, f.shipper, { type: 'endMovement' }); + assert.notEqual(game.active, f.shipper); + } + }); + +for (const kind of ['emperorForces', 'emperorLeader', 'choam', 'richese', 'ixians', + 'tleilaxu', 'fremen', 'atreides', 'harkonnen'] as ControlledSemutaSpecialKind[]) + void test(`controlled native ${kind} preserves its own cost/effect order across Semuta claim or decline`, () => { + for (const claim of [false, true]) { + const f = createControlledSemutaSpecial(kind); + const before = structuredClone(f.beforeCost); + let game = applyAction(f.beforeCost, f.owner, f.action); + assert.equal(game.pendingTreacheryDiscard?.batch.cause, `specialKarama:${kind}`); + if (kind === 'emperorForces') { + assert.equal(player(game, f.owner).reserves, 19); + assert.equal(player(game, f.owner).tanks, 1); + assert.equal(player(game, f.owner).specialKaramaUsed, true); + } else if (kind === 'emperorLeader') { + assert.equal(player(game, f.owner).leaders[0].dead, false); + assert.equal(player(game, f.owner).specialKaramaUsed, true); + } else { + assert.equal(player(game, f.owner).specialKaramaUsed, undefined); + assert.equal(player(game, f.owner).spice, player(before, f.owner).spice); + assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves); + } + if (kind === 'richese') { + assert.ok(game.richeseCache!.some(c => c.id === f.acquired)); + assert.equal(player(game, f.owner).hand.some(c => c.id === f.acquired), false); + } + if (kind === 'fremen') assert.equal(player(game, f.holder).tanks, 0); + game = reactSemutaSpecial(game, f.holder, claim); + if (kind === 'choam') { + assert.equal(player(game, f.owner).spice, 26); + assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'specialKarama:choamCashout'); + game = reactSemutaSpecial(game, f.holder, !claim, !claim ? f.selected[0] : undefined); + assert.equal(player(game, f.owner).spice, 26); + if (!claim) assert.ok(player(game, f.holder).hand.some(c => c.id === f.selected[0])); + } else if (kind === 'emperorForces') { + assert.equal(player(game, f.owner).reserves, 19); + assert.equal(player(game, f.owner).tanks, 1); + assert.equal(player(game, f.owner).elites!.revived, 0); + } else if (kind === 'emperorLeader') { + assert.equal(player(game, f.owner).leaders[0].dead, false); + } else if (kind === 'richese') { + assert.equal(player(game, f.owner).spice, 17); + assert.equal(player(game, f.owner).hand.filter(c => c.id === f.acquired).length, 1); + assert.equal(game.richeseCache!.some(c => c.id === f.acquired), false); + } else if (kind === 'ixians') { + assert.equal(game.mobileStronghold!.location, (f.action.route as string[]).at(-1)); + assert.equal(player(game, f.owner).reserves, 19); + assert.equal(player(game, f.owner).moved, 0); + } else if (kind === 'tleilaxu') { + assert.equal(game.revivalPrevention!.player, f.holder); + assert.equal(player(game, f.holder).tanks, 2); + assert.equal(player(game, f.holder).reserves, 18); + reject(game, f.holder, { type: 'revive', amount: 2, elite: 0 }); + } else if (kind === 'fremen') { + assert.equal(player(game, f.holder).tanks, 2); + assert.equal(player(game, f.holder).forces[f.target!] ?? 0, 0); + assert.equal(player(game, f.owner).shipped, false); + } else if (kind === 'atreides') { + assert.deepEqual(game.battle!.fullPlan, { owner: f.owner, target: f.holder }); + assert.equal(player(game, f.owner).forces['arrakeen:10'], 2); + assert.equal(player(game, f.holder).forces['arrakeen:10'], 2); + } else { + assert.equal(game.decision?.kind, 'handExchange'); + assert.equal(player(game, f.owner).hand.length, 2); + const returned = f.selected[0]; + game = applyAction(game, f.owner, { type: 'decision', returnCards: [returned] }); + assert.equal(player(game, f.owner).hand.length, 1); + assert.equal(player(game, f.holder).hand.length, 2); + } + assert.equal(player(game, f.owner).specialKaramaUsed, true); + exactCost(game, f.karama); + reject(game, f.owner, f.action); + } + }); + +void test('CHOAM cashout is a separate private simultaneous batch with one earned credit and only fresh selectable cards', () => { + const f = createControlledSemutaSpecial('choam'); + let game = reactSemutaSpecial(applyAction(f.beforeCost, f.owner, f.action), f.holder, false); + assert.equal(player(game, f.owner).spice, 26); + const event = game.pendingTreacheryDiscard!.batch.event; + for (const seat of game.players) + assert.deepEqual(viewGame(game, seat.id).semutaReaction!.candidates, []); + game = applyAction(game, f.holder, { type: 'semutaCommit', event }); + assert.deepEqual(new Set(viewGame(game, f.holder).semutaReaction!.candidates.map(c => c.id)), new Set(f.selected)); + reject(game, f.holder, { type: 'semutaSelect', event, card: f.karama }); + const chosen = f.selected[1]; + game = applyAction(game, f.holder, { type: 'semutaSelect', event, card: chosen }); + assert.equal(player(game, f.owner).spice, 26); + assert.ok(player(game, f.holder).hand.some(c => c.id === chosen)); + assert.ok(game.discard.some(c => c.id === f.selected[0])); + assert.equal(game.discard.filter(c => c.id === f.karama).length, 1); + assert.equal(game.discard.filter(c => c.id === f.semuta).length, 1); +}); + +void test('a malformed saved native intent cannot spend a second effect or reward through a fresh-card claim', () => { + const f = createOriginalSemutaSpecialGuild(); + const game = applyAction(f.beforeCost, f.owner, f.action); + const event = game.pendingTreacheryDiscard!.batch.event; + reject(game, f.owner, { type: 'semutaCommit', event }); + const changed = structuredClone(game); + const continuation = changed.pendingTreacheryDiscard!.continuation; + assert.ok(continuation?.kind === 'specialKaramaDiscard'); + continuation.intent.turn++; + reject(changed, f.holder, { type: 'semutaCommit', event }); + const done = reactSemutaSpecial(game, f.holder, true); + assert.equal(player(done, f.shipper).spice, 9); + assert.equal(player(done, f.shipper).shipped, true); +});