diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 95071086..dbd80e7a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,44 @@ open mode gates. ## Current checkpoint and work +**Semuta on native disaster cancellation costs — 26 October. Prototyped / Partial.** +Printed-role Karama stopping Fremen Storm protection, worm survival, allied worm +protection or Advanced additional placement now offers its exact used cost before +prevention. `karamaDisasterDiscard` retains the native traversal, deposited group, +Spice cursor, rides and summoned parent. Pure native validation executes no +casualty, placement, future draw or ride. Claim/pass restores the response and +finishes native cancellation once. A focused qualifier retains the exact queued +Fremen Storm shipment entry, which drains after original casualties without +shipping or paying again. Summons retain their entire independent saved parent. +Existing persistent controls and minimal profiles serve all four kinds. +[Source and boundaries](SEMUTA_SOURCE_UPDATE_20260907.md#storm-and-worm-cancellation-costs--26-october-2026). + +Two disjoint source lanes, bounded native/diff review and root integration pass +**15 files,175/175**, types/lint/build and **16 native engine/GameTable SSR/actual +Semuta-callback journeys** on unchanged source. Twenty-six new cases cover eight +native sources, claim/pass, original allowance, both legal typed half-loss +choices, relevant stale/queued-entry rejection and all four minimal profiles. +Real cache1 Semuta and original dealt Karama precede every cost. Storm traversal85 +and shipment66 actions use actual free3 entry into Great Flat15, including one +elite; denial kills all3, allowance loses2 with a real elite0/1 chooser. The +protected original Tabr10 remains. Natural survival106/129 Basic/Advanced actions +retain one actual Funeral Plain15 Fremen; allied194/235 uses a real Nexus alliance +and CHOAM's paid1 Great Flat15 entry. Fremen is absent from that threatened allied +territory, so co-present allied survival is not certified by these sources. +Additional135 uses consecutive actual worms and original ride; summoned130 passes +the earlier distinct original Karama disposal before its separate cancellation +cost. All original45 cards remain physical; no played hand, casualty, wallet, +force, alliance, clock, ride, pending control or parent is installed. + +Narrow sources, shared checks and runtime pass without a repair. Private +source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-disaster-proof/`; +journeys/HTML: adjacent `20261026-semuta-disaster-runtime/`. +No saved-room/D1/server/reset/restart/deployment/recovery operation occurs. +These are engine/SSR/callback results; hydration, DOM/browser/mobile, active +Ecological/Great Maker parents, wider combinations, complete modes and +deployed/final assurance remain unfinished. + **Semuta on pending revival-benefit cancellation costs — 26 October. Prototyped / Partial.** Printed-role Karama stopping native CHOAM revival, Tleilaxu granted limits or discounts, early leaders or Advanced foreign Ghola now offers its exact used diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d4ac622c..6939eb68 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,45 @@ # Dune implementation status +## 26 October 2026 — Semuta claims native disaster cancellation costs + +**Prototyped / Partial.** Printed-role Karama stopping native Fremen Storm +protection, worm survival, allied protection or Advanced additional placement +offers its exact used cost before prevention. `karamaDisasterDiscard` retains the +original response, physical cohorts, Storm/Spice cursor, rides and summoned +parent. Pure native validation never executes an allowed outcome, casualty, +future draw or ride for Semuta. A focused qualifier retains the exact deposited +Fremen Storm shipment's single queued entry; after cancellation destroys that +group, normalization drains the original arrival without re-shipping or paying. +Summons keep their whole independent `resume`; claim/pass restores only the cost's +response, then native cancellation finishes once. Existing controls and minimal +bots serve all four kinds, and earlier signature formats remain unchanged. +[Source and limits](SEMUTA_SOURCE_UPDATE_20260907.md#storm-and-worm-cancellation-costs--26-october-2026). + +Two disjoint source lanes, bounded native/diff review and root integration pass +**15 files,175/175**, types/lint/build and **16 native engine/GameTable SSR/actual +Semuta-callback journeys** on unchanged source. Twenty-six new cases cover eight +sources, applicable bands, claim/pass, native allowance, both legal typed Storm +losses, relevant stale/queued-entry rejection and four minimal profiles. Actual +cache1 Semuta and original dealt Karama precede every cost, retaining all45 cards. +Storm traversal85/shipment66 actions use free3/elite1 at Great Flat15: denial kills3, +allowance loses2 through the original elite0/1 choice; protected Tabr10 remains. +Natural survival106/129 Basic/Advanced uses actual Funeral Plain15 entry; allied +194/235 retains the actual Nexus alliance and CHOAM's paid1/cost2 Great Flat15 +group, with Fremen genuinely absent from that territory. Co-present allied +survival remains outside this source evidence. Additional135 keeps the earlier +allowed worm and queued ride; summoned130 spends its distinct original cost and +actually passes that earlier fresh group before the later cancellation cost. +No played hand, force, casualty, wallet, alliance, clock, ride or pending parent +is installed. Narrow feedback and frozen integration require no repair. + +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-disaster-proof/`; +journeys/HTML: adjacent `20261026-semuta-disaster-runtime/`. +No saved-room/D1/server/reset/restart/deployment/recovery operation occurs. +These are engine/SSR/callback results; hydration, DOM/browser/mobile, active +Ecological/Great Maker parents, broader combinations, complete modes and +deployed/final assurance remain open. + ## 26 October 2026 — Semuta claims pending revival-benefit cancellation costs **Prototyped / Partial.** Printed-role Karama stopping native CHOAM revival, diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index ac2b58c9..174f7948 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -2,6 +2,84 @@ 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. +## Storm and worm cancellation costs — 26 October 2026 + +**Prototyped / Partial.** Printed-role Karama stopping native Fremen Storm +protection, worm survival, allied worm protection or Advanced additional-worm +placement now offers its exact used cost. `karamaDisasterDiscard` keeps the +original response and disaster parent live until claim/pass. Existing persistent +neutral controls, private claim/pass and minimal legal profiles serve these +sources. This applies the existing fresh-discard timing, not a new publisher +ruling or an additional protection rule. + +Native [disaster validation](../game/disaster-preflight.ts) remains pure preflight: +validate the original traversal, deposited cohort or worm occurrence without +executing allowed protection, casualties, placement, a draw, ride or Nexus. +The physical cost is already spent and is not supplied as an unspent card. +The subtype signature retains physical cohorts/typed pools and native Storm, +Spice, ride, alliance, module and summoned-parent records. Earlier continuation +formats remain unchanged. Competing decisions, opening, arrival and transport +preparations retain their existing boundaries. + +Storm traversal cancellation sets the original protection false and finishes +the native traversal once. Shipment protection instead retains the original +already-deposited Fremen group and its exact single queued shipment arrival. +A [focused qualifier](../game/semuta-disaster-arrival.ts) checks the original +owner, active turn, clock, location and typed amount; the native disaster validator +owns physical cohort validation. Sign and retain the queue through Semuta. After +denial kills the original group, let normalization consume the earned arrival +once; never require that destroyed cohort to survive or replay shipment/payment. + +Additional placement denial records the original native per-turn restriction +and resumes Spice without creating the additional worm. Allied protection denial +may open a separate native Fremen survival response; taking Karama can make that +new response actionable. Survival denial devours under the original captured +ally protection, then continues the native worm. A summoned worm retains its +entire `resume` inside its own parent, separate from the fresh cost's saved +response. Native completion restores it and merges rides/Nexus once. No future +response, random draw or parent is flattened or pre-executed for Semuta. + +Two disjoint source lanes, bounded native/diff review and root integration pass +**15 files,175/175**, types/lint/build and **16 native engine/GameTable SSR/actual +Semuta-callback journeys** on unchanged source. Twenty-six new cases cover eight +native sources, claim/pass, native allowance, both typed Storm-loss choices, +relevant rejection and all four minimal profiles. Real cache1 Semuta and original +dealt Karama supply every cost; all original45 main/cache cards remain physical. +No played hand, force, casualty, wallet, alliance, clock, ride or pending parent +is installed. Narrow feedback and frozen integration pass without a repair. + +[Storm sources](../tests/fixture-semuta-disaster-storm.ts) and +[cases](../tests/semuta-disaster-storm-engine.test.ts) use actual Advanced +Fremen setup10 at protected Tabr14 and free reserve entry3/elite1 at Great Flat15. +Shipment66 actions follows real first dials14+0 ending15 and retains the deposited +cohort's queued entry. Traversal85 follows13+0 ending14, then the actual next +Storm card before traversal. Denial kills3/elite1; native allowance still gives +the actual loss2 chooser with elite0 or1. Original protected10 remain; neither +declaration nor reserve withdrawal/payment replays. + +[Worm sources](../tests/fixture-semuta-disaster-worm.ts) and +[cases](../tests/semuta-disaster-worm-engine.test.ts) use original Fremen free1 +Funeral Plain15 entry and CHOAM paid1/cost2 at Great Flat15. Natural survival106/129 +Basic/Advanced source actions reach the actual turn2 worm. Allied194/235 forms the +real reciprocal CHOAM/Fremen Nexus alliance before the turn3 worm threatens the +separate CHOAM group. Fremen is absent there: these sources do not certify the +co-present allied/survival chain. Additional135 reaches consecutive real worms, +allows the earlier survival and declares the additional placement with the +original ride still queued. Summoned130 actually spends Fremen's distinct original +Karama and passes every participant in that earlier discard group, retaining +Semuta for Emperor's later cost. Its original summon parent restores once; the +earlier cost remains discarded and its special use stays spent. Actual later +passes, ride declines and Nexus controls finish at quiet Charity, without another +card or shipment. Used-cost claim cannot create a second appearance or draw. + +Private source-bound report/logs: +`/home/quintine/.local/share/dune/checkpoints/20261026-semuta-disaster-proof/`; +journeys/HTML: adjacent `20261026-semuta-disaster-runtime/`. +No saved-room/database/server/reset/restart/deployment/recovery operation occurs. +Engine/SSR/callback evidence leaves hydration, DOM/browser/mobile, active +Ecological/Great Maker parents, wider combinations, complete modes and +exact-revision deployed acceptance open. + ## Pending revival-benefit cancellation costs — 26 October 2026 **Prototyped / Partial.** Printed-role Karama stopping native CHOAM revival, diff --git a/game/engine.ts b/game/engine.ts index 1bae5e9e..eda69af5 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,5 +1,6 @@ import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income'; import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival'; +import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival'; import { traitorDecisionChoices, bindTraitorDecisionPromise, settleTraitorDecisionPromises, reconcileTraitorDecisionPromises as reconcileTraitorDecisions, validateTraitorDecisionPromise, TraitorDecisionError, type TraitorDecisionContext, type TraitorDecisionPromise } from './traitor-decision-promises'; @@ -1576,7 +1577,7 @@ export type Game = { stateSignature: string; } | { - kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard'; + kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard'; player: string; card: string; use: Extract; @@ -5929,9 +5930,31 @@ function semutaRevivalKaramaResponse(g: Game, response: ResponseWindow, player: throw error; } } +/** Keep traversal/devouring native and deferred. The spent card is no longer + * a held cost, and no allowed outcome or subsequent worm is executed here. */ +function semutaDisasterKaramaResponse(g: Game, response: ResponseWindow, player: string): boolean { + if (!['stormProtection','wormPlacement','wormAllyProtection','wormSurvival'].includes(response.kind) || + g.battle || g.decision || g.phaseOpening || g.pendingKarama || g.truthtrance || + g.pendingRevival || g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment || + g.pendingFremenMove || g.pendingIxMove || g.pendingMobileMove || g.pendingChoamMove || + g.pendingSpiritualAdvisor || g.pendingAmbassador || g.pendingTerrorEntry || + g.pendingArrivalOverlap || !semutaDisasterArrivalSupported(g,response) || g.ornithopter || + (g.homeworldRevivalReturn && g.homeworldRevivalReturn.stage !== 'complete') || + (g.ecologicalStorm && g.ecologicalStorm.stage !== 'complete') || + (g.greatMaker && g.greatMaker.stage !== 'complete') || + g.choamMarket || g.pendingChoamMarketGhola) return false; + const context: Game = {...g,pendingTreacheryDiscard:null,response}; + try { + validateKaramaUse(context,getPlayer(context,player),{kind:'cancel',response}); + return true; + } catch (error) { + if (error instanceof RuleError) return false; + throw error; + } +} type KaramaResponseDiscardContinuation = Extract< NonNullable['continuation'], - { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' } + { kind: 'karamaCharityDiscard' | 'karamaInflationDiscard' | 'karamaBgCharityDiscard' | 'karamaBattleDiscard' | 'karamaChoamWorthlessDiscard' | 'karamaEmperorShareDiscard' | 'karamaIncomeDiscard' | 'karamaShipmentDiscard' | 'karamaBgConversionCancelDiscard' | 'karamaRicheseAuctionDiscard' | 'karamaRicheseGiftDiscard' | 'karamaAuctionAdvantageDiscard' | 'karamaTerminalDiscard' | 'karamaMovementDiscard' | 'karamaRevivalDiscard' | 'karamaDisasterDiscard' } >; function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinuation) { const battle = c.kind === 'karamaBattleDiscard'; @@ -5946,6 +5969,7 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu const terminal = c.kind === 'karamaTerminalDiscard'; const movement = c.kind === 'karamaMovementDiscard'; const revival = c.kind === 'karamaRevivalDiscard'; + const disaster = c.kind === 'karamaDisasterDiscard'; return JSON.stringify({ player: c.player, card: c.card, use: c.use, parent: nullentropyParentSignature(g, c.resume, @@ -6070,12 +6094,33 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu leaderRevived:p.leaderRevived, cycle:p.revivalCycle, ecazCohort:p.ecazRevivalCohort, gholaBlocked:p.gholaBlocked, leaders:p.leaders, kwisatz:p.kwisatz})), } : undefined, + disaster: disaster ? { + active:g.active, ready:g.ready, remaining:g.movementRemaining, + storm:g.storm, pending:g.stormPending, resolution:g.stormResolution, + dials:g.stormDials, dialers:g.stormDialers, card:g.stormCard, known:g.stormCardKnown, + source:g.stormMovementSource, wall:g.shieldWallDestroyed, spice:g.spice, + deck:g.spiceDeck, discard:g.spiceDiscard, sequence:g.spiceSequence, + deferred:g.spiceResolution, window:g.spiceWindow, nexus:g.nexus, + rides:g.wormRides, placementBlocked:g.wormPlacementCanceledTurn, + summoned:g.summonedWorm, beforeBlow:g.summonedBeforeBlow, + nexusBeforeRides:g.summonedNexusBeforeRides, sandtrout:g.sandtrout, + arrival:g.pendingArrivalReaction, alliedShipment:g.basicAlliedShipment, + positions:g.playerPositions, mobile:g.mobileStronghold, + ecological:g.ecologicalStorm, maker:g.greatMaker, + homeworlds:g.homeworlds, occupation:g.homeworldOccupationHistory, + homeworldProgress:g.homeworldRevivalProgress, tech:g.techTokens, skills:g.leaderSkills, + fremenProtection:g.nexusFremenProtection, cunningOffer:g.nexusFremenCunningOffer, + cunningRides:g.nexusFremenCunningRides, nexusCards:g.nexusCards, + players:g.players.map(p => ({id:p.id, leaders:p.leaders, kwisatz:p.kwisatz, + advisors:p.advisors, noField:p.noField, noFieldEvent:p.noFieldEvent, + wormBlocked:p.fremenNexusWormBlockedTurn})), + } : undefined, order: g.order, players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally, hand: p.hand.map(card => card.id), spice: p.spice, - shipped: income || shipment || conversion || terminal || movement ? p.shipped : undefined, - physical: battle || worthless || income || shipment || conversion || terminal || movement || revival + shipped: income || shipment || conversion || terminal || movement || disaster ? p.shipped : undefined, + physical: battle || worthless || income || shipment || conversion || terminal || movement || revival || disaster ? { forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites, leaders: battle ? p.leaders : undefined, noField: shipment ? p.noField : undefined, @@ -6992,6 +7037,7 @@ function treacheryDiscardIntegrity(g: Game) { continuation?.kind === 'karamaTerminalDiscard' || continuation?.kind === 'karamaMovementDiscard' || continuation?.kind === 'karamaRevivalDiscard' || + continuation?.kind === 'karamaDisasterDiscard' || continuation?.kind === 'karamaShipmentDiscard' || continuation?.kind === 'karamaBgConversionCancelDiscard') { const c = continuation, entry = batch.entries[0]; @@ -7028,6 +7074,8 @@ function treacheryDiscardIntegrity(g: Game) { semutaMovementKaramaResponse(g,c.use.response,c.player)) || (c.kind === 'karamaRevivalDiscard' && semutaRevivalKaramaResponse(g,c.use.response,c.player)) || + (c.kind === 'karamaDisasterDiscard' && + semutaDisasterKaramaResponse(g,c.use.response,c.player)) || (c.kind === 'karamaShipmentDiscard' && semutaGuildShipmentKaramaResponse(g, c.use.response)) || (c.kind === 'karamaBgConversionCancelDiscard' && @@ -7934,6 +7982,7 @@ function semutaOfferSupported( const terminalDiscard = continuation.kind === 'karamaTerminalDiscard'; const movementDiscard = continuation.kind === 'karamaMovementDiscard'; const revivalDiscard = continuation.kind === 'karamaRevivalDiscard'; + const disasterDiscard = continuation.kind === 'karamaDisasterDiscard'; const bgDiscard = continuation.kind === 'bgKaramaCostDiscard' || continuation.kind === 'karamaBgConversionCancelDiscard'; const ambassadorDiscard = continuation.kind === 'ambassador'; @@ -7972,7 +8021,7 @@ function semutaOfferSupported( continuation.kind !== 'karamaChoamWorthlessDiscard' && continuation.kind !== 'karamaEmperorShareDiscard' && continuation.kind !== 'karamaIncomeDiscard' && - !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && + !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && continuation.kind !== 'karamaShipmentDiscard' && continuation.kind !== 'karamaShipmentPriceDiscard' && continuation.kind !== 'specialKaramaDiscard' && @@ -8009,7 +8058,7 @@ function semutaOfferSupported( continuation.promise.kind === 'shipment' || continuation.promise.kind === 'movement' || continuation.promise.kind === 'battle'))) return false; - if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && continuation.kind !== 'kaitainDiscard' && + if (g.homeworlds && !bgDiscard && !richeseDiscard && !auctionAdvantageDiscard && !terminalDiscard && !movementDiscard && !revivalDiscard && !disasterDiscard && continuation.kind !== 'kaitainDiscard' && continuation.kind !== 'tupileCleanupDiscard' && continuation.kind !== 'karamaIncomeDiscard' && continuation.kind !== 'karamaShipmentDiscard' && @@ -8116,6 +8165,7 @@ function semutaOfferSupported( terminalDiscard || movementDiscard || revivalDiscard || + disasterDiscard || continuation.kind === 'karamaShipmentDiscard' ? (continuation.kind === 'karamaBattleDiscard' ? semutaBattleKaramaResponse(g, continuation.use.response) @@ -8135,6 +8185,8 @@ function semutaOfferSupported( ? semutaMovementKaramaResponse(g,continuation.use.response,continuation.player) : revivalDiscard ? semutaRevivalKaramaResponse(g,continuation.use.response,continuation.player) + : disasterDiscard + ? semutaDisasterKaramaResponse(g,continuation.use.response,continuation.player) : continuation.kind === 'karamaShipmentDiscard' ? semutaGuildShipmentKaramaResponse(g, continuation.use.response) : ['choamCharity', 'choamInflation', 'bgCharity'].includes(continuation.resume.response?.kind ?? '')) && @@ -8173,6 +8225,7 @@ function semutaOfferSupported( terminalDiscard || movementDiscard || revivalDiscard || + disasterDiscard || continuation.kind === 'karamaShipmentDiscard' || ((continuation.kind === 'ordinaryCardDiscard' ? ordinaryDiscardIncomeMatches(g, continuation) @@ -8532,6 +8585,7 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string next.kind === 'karamaTerminalDiscard' || next.kind === 'karamaMovementDiscard' || next.kind === 'karamaRevivalDiscard' || + next.kind === 'karamaDisasterDiscard' || next.kind === 'karamaShipmentDiscard' || next.kind === 'karamaBgConversionCancelDiscard' || next.kind === 'karamaPurchaseDiscard' || @@ -15889,6 +15943,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered semutaTerminalKaramaResponse(g,use.response,p.id) || semutaMovementKaramaResponse(g,use.response,p.id) || semutaRevivalKaramaResponse(g,use.response,p.id) || + semutaDisasterKaramaResponse(g,use.response,p.id) || guildShipmentDiscard || bgConversionCancelDiscard)) { const continuation: KaramaResponseDiscardContinuation = { kind: bgConversionCancelDiscard ? 'karamaBgConversionCancelDiscard' @@ -15899,6 +15954,7 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse, kullOffered : ['advisor','atreidesSpice','stormPeek','faceDancerReplacement'].includes(use.response.kind) ? 'karamaTerminalDiscard' : ['fremenMovement','ixMovement','mobileStronghold','guildTiming'].includes(use.response.kind) ? 'karamaMovementDiscard' : ['choamRevival','revivalLimit','revivalDiscount','earlyRevival','foreignGhola'].includes(use.response.kind) ? 'karamaRevivalDiscard' + : ['stormProtection','wormPlacement','wormAllyProtection','wormSurvival'].includes(use.response.kind) ? 'karamaDisasterDiscard' : use.response.kind === 'choamCharity' ? 'karamaCharityDiscard' : use.response.kind === 'choamInflation' ? 'karamaInflationDiscard' diff --git a/game/reference.ts b/game/reference.ts index ab1ab646..71f5f060 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1292,6 +1292,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Printed-role Karama stopping Atreides auction inspection, Ixian auction preparation or Advanced Technology also offers its exact cost before prevention. Claim or Continue resumes the original lot or ordinary draw once, without new inspection, extra Ix choice or exchange. Technology stays used and its original unseen lot remains; the native Atreides peek follows. If outgoing Semuta was selected for that now-prevented exchange, only that reservation releases so Semuta can claim the counter’s cost. Other commitments and independent knowledge remain.', 'Printed-role Karama stopping clean native Tleilaxu revival income offers that exact used cost before income prevention. The ordinary or Ghola revival has already completed: returned forces/leaders, actual payment, normal/free/elite allowance use and the free-income history stay unchanged. Claim or Continue withholds the original income once; taking its counter cannot repeat the revival or recover an already discarded Ghola. Ghola’s own earlier discard is a separate opportunity. Unfinished battle, Ambassador, market and arrival parents retain their separate boundaries.', 'Printed-role Karama stopping a clean native CHOAM revival benefit, Tleilaxu granted force limit or discount, early leader revival or Advanced foreign Ghola also offers its exact used cost. The original request stays unpaid and its counters or leader stay in the Tanks through the offer. Claim or Continue finishes the original prevention once: native rules may abandon the request, reprice it, open its remaining response or complete an affordable return. Unfunded repricing returns nothing and consumes no allowance. Any income follows the actual return once; taking Karama cannot repeat the request. Other unfinished parents remain separate.', + 'Printed-role Karama stopping native Fremen Storm protection, worm survival, allied worm protection or Advanced additional-worm placement offers its exact used cost before prevention. Claim or Continue finishes that cancellation once. Storm entry keeps its already-deposited cohort and queued arrival through the offer; denial then kills that original group without shipping again. Storm traversal, devouring, later survival responses, Spice draws and rides remain native. A summoned worm retains its own saved parent until native completion. Other unfinished parents retain their separate boundaries.', 'Printed-role Karama stopping ordinary Basic Spiritual Advisors, Atreides Movement foresight, Advanced Fremen Storm knowledge or native Face Dancer replacement also offers its exact used cost. Claim or Continue finishes that original prevention once. The paid shipment and remaining turn stay intact; the stopped free packet is not placed. Foresight gains no new knowledge or draw. An original Bene Gesserit stance or Guild opening choice waits unchanged until the Atreides cost finishes. A stopped replacement retains the original Face Dancers and reserve, with its attempt still used. Storm opening acknowledgements must finish before cancellation; they are never bypassed or repeated. Complex Spiritual and arrival parents remain separate.', 'Printed-role Karama stopping ordinary native Fremen range, Cyborg range, Advanced Guild timing or HMS movement/collection also offers its exact used cost. Claim or Continue completes the original denial once. Fremen and Ixian forces remain in place with their move unused and current-use restriction intact; choose a legal replacement. Guild follows the original remaining queue under its native timing lock. A canceled HMS route neither relocates nor collects; only after the fresh cost finishes does the original Storm turn begin, including its normal Advanced card handling. No declaration, payment, movement, collection or turn opening repeats. Card-flight and other complex parents retain their separate boundaries.', 'The explicit fresh Leader Skills entry composes Semuta with original all-fourteen-card native Basic Richese/CHOAM or Advanced E1/E2 setup, retaining original Tech and Advanced Stronghold prerequisites. Actual offers train original living discs; the native Auditor remains ineligible. A completed Hajr discard can resume the same Movement owner before an original private Mentat question. Mandatory loser disposal can resume trained Suk casualty rescue or Rihani’s normal private inspection and optional skilled exchange before winner cleanup. Winning compulsory Artillery stays held through the original skill, then becomes its own fresh Treachery discard. Traitor/Face Dancer movement is never a Semuta target; no rescue, question, inspection or exchange repeats. Basic Ix mixtures, E3, other module/Banker overlays and public starts stay separate.', @@ -1434,6 +1435,11 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/semuta-revival-benefits-forces-engine.test.ts', 'tests/fixture-semuta-revival-benefits-leaders.ts', 'tests/semuta-revival-benefits-leaders-engine.test.ts', + 'game/semuta-disaster-arrival.ts', + 'tests/fixture-semuta-disaster-storm.ts', + 'tests/semuta-disaster-storm-engine.test.ts', + 'tests/fixture-semuta-disaster-worm.ts', + 'tests/semuta-disaster-worm-engine.test.ts', 'tests/fixture-semuta-spiritual-foresight.ts', 'tests/semuta-spiritual-foresight-engine.test.ts', 'tests/fixture-semuta-storm-dancer.ts', diff --git a/game/semuta-disaster-arrival.ts b/game/semuta-disaster-arrival.ts new file mode 100644 index 00000000..3fb971c2 --- /dev/null +++ b/game/semuta-disaster-arrival.ts @@ -0,0 +1,23 @@ +import type { Game, ResponseWindow } from './engine'; +import { location, validGameLocation } from './board'; + +/** Native Fremen Storm entry is already deposited; its earned arrival waits + * behind protection. The disaster validator owns physical cohort validation. */ +export function semutaDisasterArrivalSupported(g: Game, response: ResponseWindow): boolean { + const arrivals = g.pendingArrivalReaction; + if (!arrivals?.length) return true; + if (response.kind !== 'stormProtection' || response.resume !== 'shipment' || + !Array.isArray(arrivals) || arrivals.length !== 1 || !g.advanced || g.phase !== 5) return false; + const entry = arrivals[0]; + if (!entry || Object.keys(entry).sort().join(',') !== + 'amount,cause,elite,entrant,phase,resume,sector,territory,turn' || + entry.turn !== g.turn || entry.phase !== g.phase || + entry.cause !== 'shipment' || entry.resume !== 'none' || + entry.entrant !== response.owner || entry.entrant !== g.active || + entry.amount !== response.amount || entry.elite !== (response.elite ?? 0) || + !validGameLocation(g,entry.territory,entry.sector) || + location(entry.territory,entry.sector) !== response.location) return false; + const entrant = g.players.find(p => p.id === entry.entrant); + return entrant?.faction === 'fremen' && entrant.shipped === true && + !!g.movementRemaining?.includes(entrant.id); +} diff --git a/tests/fixture-semuta-disaster-storm.ts b/tests/fixture-semuta-disaster-storm.ts new file mode 100644 index 00000000..90601b2f --- /dev/null +++ b/tests/fixture-semuta-disaster-storm.ts @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import { + createGame, initializeSemutaGameForAudit, joinGame, newPlayer, type Action, type Game, +} from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { validateStormCancellation } from '../game/disaster-preflight'; +import { stormSectorAfter } from '../game/disaster-rules'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { + actSemutaFremenGuildMovement, nextSemutaFremenGuildMovement, + passSemutaFremenGuildMovement, claimSemutaFremenGuildMovement, + semutaFremenGuildMovementClean, semutaFremenGuildMovementInventory, semutaFremenGuildMovementPlayer, +} from './fixture-semuta-fremen-guild-movement'; + +export type SemutaDisasterStormKind = 'traversal' | 'shipment'; +export type SemutaDisasterStormStep = { actor: string; action: Action }; +export const actSemutaDisasterStorm = actSemutaFremenGuildMovement; +export const passSemutaDisasterStorm = passSemutaFremenGuildMovement; +export const claimSemutaDisasterStorm = claimSemutaFremenGuildMovement; +export const semutaDisasterStormPlayer = semutaFremenGuildMovementPlayer; +export const semutaDisasterStormInventory = semutaFremenGuildMovementInventory; +export const semutaDisasterStormClean = semutaFremenGuildMovementClean; + +/** First-Storm dials are real controls. Every later distance remains the actual + * positive native Storm Card. Protection/fresh disposal stays caller-owned. */ +export function nextSemutaDisasterStorm(game: Game, + kind: SemutaDisasterStormKind = 'traversal'): SemutaDisasterStormStep | null { + if (game.response?.kind === 'stormProtection' || game.pendingTreacheryDiscard || game.truthtrance) return null; + const step = nextSemutaFremenGuildMovement(game); + if (step?.action.type === 'stormDial' && game.turn === 1) + return { actor: step.actor, action: { ...step.action, + amount: step.actor === game.stormDialers[0] ? kind === 'shipment' ? 14 : 13 : 0 } }; + return step; +} +export function advanceSemutaDisasterStorm(game: Game, until: (state: Game) => boolean, + actions: SemutaDisasterStormStep[] = [], kind: SemutaDisasterStormKind = 'traversal'): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextSemutaDisasterStorm(game, kind); + assert.ok(step, 'Resolve the actual protection or fresh-cost choice before advancing'); + game = actSemutaDisasterStorm(game, step.actor, step.action, actions); + } + throw Error('The native Storm-protection source did not reach its finite boundary'); +} +/** Allow only the original protection. A real mixed casualty chooser is left + * explicit, and later forecast/phase-opening controls are never answered here. */ +export function allowSemutaDisasterStorm(game: Game, actions: SemutaDisasterStormStep[] = []): Game { + assert.equal(game.response?.kind, 'stormProtection'); + for (let count = 0; count < game.players.length && game.response?.kind === 'stormProtection'; count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = actSemutaDisasterStorm(game, actor.id, { type: 'passResponse' }, actions); + } + assert.notEqual(game.response?.kind, 'stormProtection'); return game; +} +/** Finish the actual casualty choice and original Storm's post-traversal market + * only. Stop at phase1, retaining its new forecast/opening; shipment stays on + * the already paid/free committed combined turn. No second arrival is issued. */ +export function finishSemutaDisasterStorm(game: Game, kind: SemutaDisasterStormKind, + actions: SemutaDisasterStormStep[] = [], elite = 0): Game { + for (let count = 0; count < 20; count++) { + assert.equal(game.pendingTreacheryDiscard ?? null, null, 'Choose the original cost batch first'); + if (game.decision?.kind === 'stormLosses') { + game = actSemutaDisasterStorm(game, game.decision.player, { type: 'decision', elite }, actions); + } else if (kind === 'traversal' && game.phase === 0 && game.decision?.kind === 'choamMarket') { + game = actSemutaDisasterStorm(game, game.decision.player, { type: 'decision', done: true }, actions); + } else { + assert.equal(game.stormResolution ?? null, null); + assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); + assert.equal(game.phase, kind === 'shipment' ? 5 : 1); + return game; + } + } + throw Error('The original Storm-protection aftermath did not settle'); +} + +/** Actual Advanced R/C/F/E preview: original deal/cache, native Fremen setup, + * first-Storm dials, free reserve entry, and (for traversal) the next real Storm. + * No played force, typed custody, clock, wallet or pending control is installed. */ +export function createSemutaDisasterStormFixture(options: { kind: SemutaDisasterStormKind }) { + const kind = options.kind, richese = 'richese', holder = 'choam', owner = 'fremen', spender = 'emperor'; + const key = 'the_great_flat:15', amount = 3, elite = 1, firstDistance = kind === 'shipment' ? 14 : 13; + let game = createGame('SEMUTADISASTERSTORM', newPlayer(richese, 'Richese', 'richese'), true, ['choam']); + for (const faction of [holder, owner, spender] as const) joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaDisasterStormStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaDisasterStorm(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaDisasterStorm(game, until, actions, kind); }; + const transition = (run: () => void) => { + const before = structuredClone(game), start = actions.length; run(); + return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) }; + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); + const setup = structuredClone(game), initialization = { before: lobby, after: setup, + entry: 'original-advanced-semuta-initializer-on-ready-undealt-lobby' }; + assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.semutaPreview, true); + assert.ok(game.players.every(player => player.hand.length === 0)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaDisasterStormInventory(game); assert.equal(originalInventory.length, 45); + assert.equal(game.deck.length, 35); assert.equal(game.richeseCache!.length, 10); + const printed = game.deck.find(card => card.effect === 'karama'); assert.ok(printed); + const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 3); + assert.equal(ordinary.length, 3); + const deal = [...ordinary.map(card => card.id), printed.id], cost = printed.id; + const dealOrder = deal.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { card, from, position }; + }); + const beforeStartingDeal = structuredClone(game); + reach(state => state.status === 'playing'); const afterSetup = structuredClone(game); + assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal); + assert.equal(semutaDisasterStormPlayer(game, owner).forces['sietch_tabr:14'], 10); + assert.equal(semutaDisasterStormPlayer(game, owner).elites?.reserves, 3); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + for (let position = 0; position < 4; position++) { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && + ['hagga_basin', 'rock_outcroppings', 'broken_land', 'oh_gap'].includes(card.territory)); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); + spiceOrder.push({ position, from, card: structuredClone(card) }); + } + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + let beforeBid: Game | undefined, bid: SemutaDisasterStormStep | undefined, bidAt = -1; + for (let count = 0; count < 900 && !semutaDisasterStormPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const step = nextSemutaDisasterStorm(game, kind); assert.ok(step); + if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) { + beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length; + } + act(step.actor, step.action); + } + assert.ok(beforeBid && bid && bidAt >= 0); + const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, + steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; + assert.equal(semutaDisasterStormPlayer(beforeBid, holder).spice - semutaDisasterStormPlayer(game, holder).spice, 1); + assert.equal(semutaDisasterStormPlayer(game, richese).spice - semutaDisasterStormPlayer(beforeBid, richese).spice, 1); + reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && semutaDisasterStormClean(state)); + assert.equal(game.storm, stormSectorAfter(1, firstDistance)); + const nativeAction: Action = { type: 'ship', territory: 'the_great_flat', sector: 15, amount, elite, allyPayment: 0 }; + const beforeDeclaration = structuredClone(game); + const shipment = transition(() => { act(owner, nativeAction); }); + assert.equal(semutaDisasterStormPlayer(game, owner).forces[key], amount); + assert.equal(semutaDisasterStormPlayer(game, owner).elites?.forces[key], elite); + assert.equal(semutaDisasterStormPlayer(game, owner).reserves, semutaDisasterStormPlayer(beforeDeclaration, owner).reserves - amount); + assert.equal(semutaDisasterStormPlayer(game, owner).elites?.reserves, 2); + assert.equal(semutaDisasterStormPlayer(game, owner).spice, semutaDisasterStormPlayer(beforeDeclaration, owner).spice); + let beforeStorm: Game | null = null, traversal: ReturnType | null = null; + if (kind === 'traversal') { + assert.equal(game.response ?? null, null); assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); + act(owner, { type: 'endMovement' }); + reach(state => state.turn === 2 && state.phase === 0 && !state.stormResolution && semutaDisasterStormClean(state)); + beforeStorm = structuredClone(game); + traversal = transition(() => { reach(state => state.response?.kind === 'stormProtection'); }); + assert.equal(game.stormResolution?.from, 14); assert.equal(game.stormResolution?.traversed, 0); + assert.ok(game.stormResolution && game.stormResolution.distance >= 1 && game.stormResolution.distance <= 6); + assert.deepEqual(game.stormResolution.pending, []); + } else { + assert.equal(game.pendingArrivalReaction?.length, 1); + assert.deepEqual(game.pendingArrivalReaction![0], { + entrant: owner, territory: 'the_great_flat', sector: 15, amount, elite, + cause: 'shipment', resume: 'none', turn: 1, phase: 5, + }); + } + assert.equal(game.response?.kind, 'stormProtection'); assert.equal(game.response?.owner, owner); + assert.equal(game.response?.resume, kind === 'shipment' ? 'shipment' : 'storm'); + assert.equal(game.phaseOpening ?? null, null); assert.equal(game.decision ?? null, null); + assert.equal(game.pendingTreacheryDiscard ?? null, null); + validateStormCancellation(game, game.response!); + assert.equal(semutaDisasterStormPlayer(game, owner).tanks, 0); + assert.equal(semutaDisasterStormPlayer(game, owner).elites?.tanks, 0); + assert.ok(semutaDisasterStormPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.ok(semutaDisasterStormPlayer(game, spender).hand.some(card => card.id === cost)); + const source = structuredClone(game), originalResponse = structuredClone(game.response!); + const originalResolution = structuredClone(game.stormResolution ?? null); + const originalArrival = structuredClone(game.pendingArrivalReaction ?? null); + const originalStormMovementSource = structuredClone(game.stormMovementSource ?? null); + const expectedStorm = kind === 'traversal' ? stormSectorAfter(originalResolution!.from, originalResolution!.distance) : game.storm; + const action: Action = { type: 'card', card: cost, mode: 'cancel' }; + assert.deepEqual(semutaDisasterStormInventory(game), originalInventory); + return { game, source, beforeCost: source, kind, advanced: true, richese, holder, spender, owner, + cost, semuta: SEMUTA_DRUG_ID, action, nativeAction, key, location: key, target: 'the_great_flat', + amount, elite, expectedStorm, firstDistance, + originalResponse, originalResolution, originalArrival, originalStormMovementSource, + beforeDeclaration, shipment, beforeStorm, traversal, initial, lobby, setup, initialization, + beforeStartingDeal, afterSetup, deal, dealOrder, originalSpice, spiceOrder, acquired, acquisition, + originalInventory, actions, sourceActions: actions, sourceActionCount: actions.length, + staging: [ + 'Original Advanced Semuta initializer on its genuinely ready undealt R/C/F/E lobby; original main35/cache10 remain physical. Only unopened deal/Spice positions and scalar lotteries are selected. Emperor receives printed Karama; CHOAM really pays1 for original cache Semuta.', + 'Fremen really places ten ordinary setup counters at protected Tabr14, retaining ten reserves including three native elite counters. Actual first Storm dials13+0 (traversal) or14+0 (shipment) end14 or15; Tabr remains safe.', + 'Fremen actually ships three reserves, including one native elite, free to The Great Flat15. No played force, typed custody, wallet, clock or pending receipt is installed.', + kind === 'shipment' ? 'The physical shipment already committed and its original nine-field arrival is queued behind native stormProtection. Preserve that exact queue through the used-cost child; the native arrival drains after casualties without shipping again.' : + 'The safe first-turn entry finishes; the next actual positive native Storm Card necessarily crosses Great Flat15 from14. Stop before traversed0 protection, with the physical mixed cohort and original full traversal inputs unchanged.', + 'Native allowance loses ceil(3/2)=2 and offers the actual 0..1 elite casualty chooser; prevention loses all3 including the one elite. Finish only original casualties/Storm market, retaining later forecast/opening controls.', + ] }; +} +export type SemutaDisasterStormFixture = ReturnType; diff --git a/tests/fixture-semuta-disaster-worm.ts b/tests/fixture-semuta-disaster-worm.ts new file mode 100644 index 00000000..1bb7dd6b --- /dev/null +++ b/tests/fixture-semuta-disaster-worm.ts @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict'; +import { + createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, +} from '../game/engine'; +import { validateWormCancellation } from '../game/disaster-preflight'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { actTruthMovement } from './fixture-truth-movement'; +import { + nextSemutaRicheseCacheKarama, semutaRicheseCacheKaramaInventory, + semutaRicheseCacheKaramaPlayer, semutaRicheseCacheKaramaClean, +} from './fixture-semuta-richese-cache-karama'; + +export type SemutaDisasterWormKind = 'wormSurvival' | 'wormAllyProtection' | 'wormPlacement'; +export type SemutaDisasterWormStep = { actor: string; action: Action }; +export const actSemutaDisasterWorm = actTruthMovement; +export const semutaDisasterWormPlayer = semutaRicheseCacheKaramaPlayer; +export const semutaDisasterWormInventory = semutaRicheseCacheKaramaInventory; +export const semutaDisasterWormClean = semutaRicheseCacheKaramaClean; +export const semutaDisasterWormPhysical = (game: Game) => ({ + players: game.players.map(p => ({ id: p.id, forces: p.forces, elites: p.elites, reserves: p.reserves, + tanks: p.tanks, spice: p.spice, shipped: p.shipped, moved: p.moved, ally: p.ally })), + spice: game.spice, +}); + +/** Original controls only. Fresh disposal is caller-owned; no new worm or + * optional replacement is requested by this continuation driver. */ +export function nextSemutaDisasterWorm(game: Game): SemutaDisasterWormStep | null { + if (game.pendingTreacheryDiscard || game.truthtrance) return null; + if (!game.phaseOpening && !game.response) { + const fremen = game.players.find(p => p.faction === 'fremen'); + if (game.status === 'setup' && game.setupStage === 'forces' && fremen?.reserves === 20 && + viewGame(game, fremen.id).setupPending.includes(fremen.id)) + return { actor: fremen.id, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } }; + const decision = game.decision; + if (decision?.kind === 'guildTiming') + return { actor: decision.player, action: { type: 'decision', take: false } }; + if (decision?.kind === 'wormRide' || decision?.kind === 'wormPlacement') + return { actor: decision.player, action: { type: 'decision', accept: false } }; + if (decision?.kind === 'wormProtection') + return { actor: decision.player, action: { type: 'decision', accept: true } }; + } + return nextSemutaRicheseCacheKarama(game); +} +export const nextSemutaDisasterWormStep = nextSemutaDisasterWorm; +export function advanceSemutaDisasterWorm(game: Game, until: (state: Game) => boolean, + actions: SemutaDisasterWormStep[] = []): Game { + for (let count = 0; count < 2200; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextSemutaDisasterWorm(game); + assert.ok(step, `Choose the original fresh reaction at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actSemutaDisasterWorm(game, step.actor, step.action, actions); + } + throw Error('The native worm source did not reach its finite boundary'); +} +export function passSemutaDisasterWorm(game: Game, actions: SemutaDisasterWormStep[] = []): Game { + const event = game.pendingTreacheryDiscard?.batch.event; assert.ok(event); + for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) { + const pending: NonNullable = game.pendingTreacheryDiscard!; + const reaction = pending.reaction; assert.ok(reaction?.stage === 'offer'); + const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor); + game = actSemutaDisasterWorm(game, actor.id, { type: 'semutaPass', event }, actions); + } + assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event); return game; +} +export function claimSemutaDisasterWorm(game: Game, holder: string, + actions: SemutaDisasterWormStep[] = []): Game { + const pending: NonNullable = game.pendingTreacheryDiscard!; + assert.ok(pending && pending.batch.entries.length === 1); + const event = pending.batch.event, card = pending.batch.entries[0].card.id; + game = actSemutaDisasterWorm(game, holder, { type: 'semutaCommit', event }, actions); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = actSemutaDisasterWorm(game, holder, { type: 'semutaSelect', event, card }, actions); + return game; +} +/** Pass only this original response. A subsequent independent survival + * response, additional placement or ride remains a real later control. */ +export function allowSemutaDisasterWorm(game: Game, actions: SemutaDisasterWormStep[] = []): Game { + const kind = game.response?.kind; + assert.ok(kind === 'wormSurvival' || kind === 'wormAllyProtection' || kind === 'wormPlacement'); + for (let count = 0; game.response?.kind === kind && count < game.players.length; count++) { + const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor); + game = actSemutaDisasterWorm(game, actor.id, { type: 'passResponse' }, actions); + if (game.response?.kind !== kind || game.response.passed.length === 0) break; + } + return game; +} +/** Resolve actual remaining survival/ride/Nexus controls to quiet Charity. + * Never bypass a new fresh batch or request another producer. */ +export function finishSemutaDisasterWorm(game: Game, actions: SemutaDisasterWormStep[] = []): Game { + const turn = game.turn; + return advanceSemutaDisasterWorm(game, state => state.turn === turn && state.phase === 2 && + semutaDisasterWormClean(state), actions); +} + +/** Genuine paid Semuta, original printed costs and natural Spice sequence. + * Unopened positions are selected before the corresponding native draws. + * No played hand, wallet, force, alliance, phase or pending frame is installed. */ +export function createSemutaDisasterWormFixture(options: { + kind?: SemutaDisasterWormKind; advanced?: boolean; summoned?: boolean; +} = {}) { + const kind = options.kind ?? 'wormSurvival', advanced = options.advanced ?? kind === 'wormPlacement'; + const summoned = options.summoned ?? false; + assert.ok(kind !== 'wormPlacement' || advanced, 'Additional worm placement is Advanced only'); + assert.ok(!summoned || advanced && kind === 'wormSurvival', 'The bounded summoned source is Advanced survival only'); + const richese = 'richese', holder = 'choam', owner = 'fremen', guild = 'guild', spender = 'emperor'; + const location = 'funeral_plain:15', allyLocation = 'the_great_flat:15'; + let game = createGame('SEMUTADISASTERWORM', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam']); + for (const faction of ['choam', 'fremen', 'guild', 'emperor'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const initial = structuredClone(game), actions: SemutaDisasterWormStep[] = []; + const act = (actor: string, action: Action) => { game = actSemutaDisasterWorm(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceSemutaDisasterWorm(game, until, actions); }; + const transition = (run: () => void) => { + const before = structuredClone(game), at = actions.length; run(); + return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) }; + }; + for (const p of game.players) act(p.id, { type: 'ready' }); + const lobby = structuredClone(game); + if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); + else act(game.host, { type: 'start' }); + const setup = structuredClone(game), initialization = { before: lobby, after: setup, + entry: advanced ? 'original-undealt-semuta-preview' : 'public-basic-start' }; + assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.equal(game.semutaPreview, true); + assert.ok(game.players.every(p => p.hand.length === 0)); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + const originalInventory = semutaDisasterWormInventory(game); assert.equal(originalInventory.length, 45); + const karamas = game.deck.filter(card => card.effect === 'karama'); assert.equal(karamas.length, 2); + const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 3); assert.equal(ordinary.length, 3); + const selected = [ordinary[0], ordinary[1], karamas[0], ordinary[2], karamas[1]]; + const dealOrder = selected.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: card.id }; + }); + const beforeStartingDeal = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game), cost = karamas[1].id, summonCost = karamas[0].id; + assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id)); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder: { turn: number; position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + const orderSpice = (faces: string[]) => { + for (let position = 0; position < faces.length; position++) { + const face = faces[position]; + const from = game.spiceDeck.findIndex((card, index) => index >= position && + (face === 'worm' ? 'worm' in card && !card.suppressed && !card.thumper && !card.greatMaker + : 'territory' in card && card.territory === face && !card.discovery)); + assert.ok(from >= position, `The unopened native Spice stock needs ${face}`); + const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); + spiceOrder.push({ turn: game.turn, position, from, card: structuredClone(card) }); + } + }; + orderSpice(advanced ? ['funeral_plain', 'hagga_basin'] : ['funeral_plain']); + let beforeBid: Game | undefined, bid: SemutaDisasterWormStep | undefined, bidAt = -1; + for (let count = 0; count < 900 && !semutaDisasterWormPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID); count++) { + const step = nextSemutaDisasterWorm(game); assert.ok(step); + if (step.actor === holder && step.action.type === 'richeseBid' && step.action.amount === 1) { + beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length; + } + act(step.actor, step.action); + } + assert.ok(beforeBid && bid && bidAt >= 0); + const acquired = structuredClone(game), acquisition = { before: beforeBid, bid, + steps: structuredClone(actions.slice(bidAt)), after: acquired, price: 1 }; + assert.equal(semutaDisasterWormPlayer(beforeBid, holder).spice - semutaDisasterWormPlayer(game, holder).spice, 1); + reach(state => state.turn === 1 && state.phase === 5 && semutaDisasterWormClean(state)); + assert.equal(game.storm, 1); + const shipments: { actor: string; location: string; cost: number; before: Game; steps: SemutaDisasterWormStep[]; after: Game }[] = []; + for (let count = 0; game.turn === 1 && game.phase === 5 && count < 160; count++) { + if (semutaDisasterWormClean(game) && (game.active === holder || game.active === owner) && + !semutaDisasterWormPlayer(game, game.active!).shipped) { + const actor = game.active!, destination = actor === holder ? 'the_great_flat' : 'funeral_plain'; + const paid = actor === holder ? 2 : 0; + const shipment = transition(() => { + act(actor, { type: 'ship', territory: destination, sector: 15, amount: 1, elite: 0, allyPayment: 0 }); + reach(state => semutaDisasterWormClean(state)); + }); + assert.equal(semutaDisasterWormPlayer(shipment.before, actor).spice - semutaDisasterWormPlayer(game, actor).spice, paid); + shipments.push({ actor, location: `${destination}:15`, cost: paid, ...shipment }); + } else { + const step = nextSemutaDisasterWorm(game); assert.ok(step); act(step.actor, step.action); + } + } + assert.equal(shipments.length, 2); assert.equal(semutaDisasterWormPlayer(game, owner).forces[location], 1); + assert.equal(semutaDisasterWormPlayer(game, holder).forces[allyLocation], 1); + const beforeBlow = (state: Game, turn: number) => state.turn === turn && state.phase === 1 && + semutaDisasterWormClean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0; + reach(state => beforeBlow(state, 2)); + const beforeSecondBlow = structuredClone(game); + orderSpice(summoned ? ['the_great_flat', 'broken_land'] + : [...(kind === 'wormPlacement' ? ['worm', 'worm'] : ['worm']), 'the_great_flat', ...(advanced ? ['broken_land'] : [])]); + const priorSurvivals: { before: Game; steps: SemutaDisasterWormStep[]; after: Game }[] = []; + let nativeAction: Action | null = null, beforeDeclaration: Game | null = null; + let summonDiscard: { before: Game; steps: SemutaDisasterWormStep[]; after: Game } | null = null; + let alliance: { before: Game; steps: SemutaDisasterWormStep[]; after: Game } | null = null; + if (summoned) { + beforeDeclaration = structuredClone(game); + nativeAction = { type: 'card', mode: 'special', card: summonCost, territory: 'funeral_plain' }; + act(owner, nativeAction); + if (game.pendingTreacheryDiscard) summonDiscard = transition(() => { game = passSemutaDisasterWorm(game, actions); }); + reach(state => state.response?.kind === 'wormSurvival'); + assert.ok(game.summonedWorm); assert.ok(game.summonedWorm.resume); + } else { + reach(state => state.response?.kind === 'wormSurvival'); + if (kind !== 'wormSurvival') priorSurvivals.push(transition(() => { game = allowSemutaDisasterWorm(game, actions); })); + if (kind === 'wormPlacement') { + reach(state => state.decision?.kind === 'wormPlacement' && !state.response && !state.phaseOpening); + beforeDeclaration = structuredClone(game); nativeAction = { type: 'decision', accept: true, territory: 'funeral_plain' }; + act(owner, nativeAction); + } else if (kind === 'wormAllyProtection') { + reach(state => state.turn === 2 && state.phase === 1 && state.nexus && !state.spiceWindow && + !state.spiceResolution && semutaDisasterWormClean(state)); + alliance = transition(() => { + act(holder, { type: 'alliance', target: owner }); act(owner, { type: 'alliance', target: holder }); + }); + assert.equal(semutaDisasterWormPlayer(game, holder).ally, owner); + assert.equal(semutaDisasterWormPlayer(game, owner).ally, holder); + reach(state => beforeBlow(state, 3)); + orderSpice(['worm', 'rock_outcroppings', ...(advanced ? ['oh_gap'] : [])]); + reach(state => state.decision?.kind === 'wormProtection' && !state.response && !state.phaseOpening); + beforeDeclaration = structuredClone(game); nativeAction = { type: 'decision', accept: true }; + act(owner, nativeAction); + } + } + assert.ok(game.response); assert.equal(game.response.kind, kind); assert.equal(game.response.owner, owner); + assert.ok(!game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.battle); + validateWormCancellation(game, game.response); + assert.ok(semutaDisasterWormPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); + assert.ok(semutaDisasterWormPlayer(game, spender).hand.some(card => card.id === cost)); + assert.deepEqual(semutaDisasterWormInventory(game), originalInventory); + const source = structuredClone(game), originalResponse = structuredClone(game.response), territory = game.response.location!; + const recipient = game.response.recipient ?? null, targetLocation = `${territory}:15`; + const action: Action = { type: 'card', card: cost, mode: 'cancel' }; + return { game, source, beforeCost: source, kind, advanced, summoned, holder, spender, owner, recipient, richese, guild, + cost, karama: cost, semuta: SEMUTA_DRUG_ID, summonCost, action, nativeAction, beforeDeclaration, + territory, target: territory, location: targetLocation, fremenLocation: location, allyLocation, + originalResponse, originalPhysical: structuredClone(semutaDisasterWormPhysical(game)), + originalSequence: structuredClone(game.spiceSequence ?? null), originalResolution: structuredClone(game.spiceResolution ?? null), + originalRides: [...game.wormRides], originalSummoned: structuredClone(game.summonedWorm ?? null), + initial, lobby, setup, initialization, beforeStartingDeal, afterSetup, deal: selected.map(card => card.id), dealOrder, + originalInventory, originalSpice, spiceOrder, acquired, acquisition, shipments, beforeSecondBlow, + priorSurvivals, alliance, summonDiscard, actions, sourceActions: actions, sourceActionCount: actions.length, + staging: [ + advanced ? 'Original Advanced Semuta preview initializer runs on the ready undealt R/C/F/G/E lobby, preserving main35/cache10. No Basic-to-Advanced conversion.' : 'Actual public Basic R/C/F/G/E ready/start preserves original main35/cache10.', + 'Only original unopened starting-card/Spice positions and scalar lotteries are selected. Fremen and Emperor each receive a distinct original printed Karama. Fremen actually places ten starting normals at Sietch Tabr; CHOAM actually purchases cache Semuta for one.', + 'CHOAM actually pays two to ship one normal to Great Flat15; Fremen actually freely ships one normal to Funeral Plain15. First native Storm dials are zero. Real Collection and subsequent Storm controls occur; neither cohort is installed or protected artificially.', + kind === 'wormAllyProtection' ? 'Turn2 natural worm protects the separate Funeral cohort through real passes, then CHOAM and Fremen actually propose/accept their Nexus alliance. Turn3 natural worm hits the real Great Flat pile and CHOAM cohort. Fremen is absent there: this proves native ally protection without claiming a co-present independent survival branch.' : kind === 'wormPlacement' ? 'Two consecutive original turn2 worms create the actual Advanced additional-placement chooser. Fremen really chooses Funeral Plain, after the earlier survival was allowed; the additional native response is unpaid and unresolved.' : summoned ? 'Advanced Fremen actually spends its separately dealt original Karama to summon at Funeral before the turn2 blow. Every earlier fresh-discard participant really passes, retaining CHOAM Semuta. The full original summoned resume remains native.' : 'The original turn2 natural worm hits the real first-turn Funeral pile and Fremen cohort; stop at its native survival response.', + 'Stop before Emperor spends its originally dealt printed Karama. No played custody, wallet, forces, ally, Spice, clock, queued ride or pending response is assigned.', + ], + }; +} +export type SemutaDisasterWormFixture = ReturnType; diff --git a/tests/semuta-disaster-storm-engine.test.ts b/tests/semuta-disaster-storm-engine.test.ts new file mode 100644 index 00000000..9999fa32 --- /dev/null +++ b/tests/semuta-disaster-storm-engine.test.ts @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { validateStormCancellation } from '../game/disaster-preflight'; +import { + createSemutaDisasterStormFixture as fixture, actSemutaDisasterStorm as act, + passSemutaDisasterStorm as pass, claimSemutaDisasterStorm as claim, + allowSemutaDisasterStorm as allow, finishSemutaDisasterStorm as finish, + semutaDisasterStormPlayer as player, semutaDisasterStormInventory as inventory, + type SemutaDisasterStormFixture, +} from './fixture-semuta-disaster-storm'; + +const kinds = ['traversal', 'shipment'] as const; +const physical = (game: Game) => ({ + turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active, ready: game.ready, + stormResolution: game.stormResolution, stormMovementSource: game.stormMovementSource, + stormDials: game.stormDials, stormPending: game.stormPending, stormDialers: game.stormDialers, + stormCard: game.stormCard, stormCardKnown: game.stormCardKnown, + pendingArrivalReaction: game.pendingArrivalReaction, basicAlliedShipment: game.basicAlliedShipment, + pendingAmbassador: game.pendingAmbassador, pendingTerrorEntry: game.pendingTerrorEntry, + pendingArrivalOverlap: game.pendingArrivalOverlap, movementRemaining: game.movementRemaining, + spice: game.spice, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, + players: game.players.map(player => ({ id: player.id, spice: player.spice, + reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites, + shipped: player.shipped, moved: player.moved, revived: player.revived, ally: player.ally })), +}); +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function nativeSource(f: SemutaDisasterStormFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.semutaPreview, true); + assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0)); + assert.deepEqual(f.game.players.map(player => player.faction), ['richese', 'choam', 'fremen', 'emperor']); + assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama')); + assert.equal(player(f.afterSetup, f.owner).forces['sietch_tabr:14'], 10); + assert.equal(player(f.afterSetup, f.owner).elites?.reserves, 3); + assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'fremenSetup')); + assert.equal(f.acquisition.price, 1); + assert.ok(f.acquisition.steps.some(step => step.actor === f.holder && step.action.type === 'richeseBid' && step.action.amount === 1)); + assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquisition.after, f.holder).spice, 1); + assert.equal(player(f.acquisition.after, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1); + assert.deepEqual(f.actions.filter(step => step.action.type === 'stormDial') + .map(step => Number(step.action.amount)).sort((a, b) => a - b), [0, f.firstDistance]); + assert.equal(f.shipment.steps.length, 1); assert.deepEqual(f.shipment.steps[0], { actor: f.owner, action: f.nativeAction }); + assert.equal(player(f.shipment.before, f.owner).spice, player(f.shipment.after, f.owner).spice); + assert.equal(player(f.shipment.after, f.owner).reserves, player(f.shipment.before, f.owner).reserves - 3); + assert.equal(player(f.shipment.after, f.owner).elites?.reserves, 2); + assert.equal(player(f.shipment.after, f.owner).forces[f.key], 3); + assert.equal(player(f.shipment.after, f.owner).elites?.forces[f.key], 1); + assert.equal(player(f.shipment.after, f.owner).shipped, true); + assert.equal(player(f.game, f.owner).forces['sietch_tabr:14'], 10, 'The native protected setup group survives'); + assert.equal(player(f.game, f.owner).forces[f.key], 3); assert.equal(player(f.game, f.owner).elites?.forces[f.key], 1); + assert.equal(player(f.game, f.owner).tanks, 0); assert.equal(player(f.game, f.owner).elites?.tanks, 0); + assert.equal(f.game.response?.kind, 'stormProtection'); assert.equal(f.game.response?.owner, f.owner); + assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null); + assert.equal(f.game.pendingTreacheryDiscard ?? null, null); + validateStormCancellation(f.game, f.game.response!); + if (f.kind === 'shipment') { + assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.storm, 15); + assert.equal(f.game.active, f.owner); assert.equal(f.game.response?.resume, 'shipment'); + assert.equal(f.game.response?.location, f.key); assert.equal(f.game.response?.amount, 3); assert.equal(f.game.response?.elite, 1); + assert.equal(f.originalResolution, null); assert.equal(f.beforeStorm, null); assert.equal(f.traversal, null); + assert.deepEqual(f.originalArrival, [{ entrant: f.owner, territory: f.target, sector: 15, + amount: 3, elite: 1, cause: 'shipment', resume: 'none', turn: 1, phase: 5 }]); + } else { + assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0); assert.equal(f.game.storm, 14); + assert.equal(f.game.response?.resume, 'storm'); assert.ok(f.beforeStorm && f.traversal && f.originalResolution); + assert.equal(f.originalResolution.from, 14); assert.equal(f.originalResolution.traversed, 0); + assert.ok(f.originalResolution.distance >= 1 && f.originalResolution.distance <= 6); + assert.deepEqual(f.originalResolution.pending, []); assert.equal(f.originalArrival?.length ?? 0, 0); + assert.deepEqual(player(f.game, f.owner).forces, player(f.shipment.after, f.owner).forces); + assert.deepEqual(player(f.game, f.owner).elites?.forces, player(f.shipment.after, f.owner).elites?.forces); + } + assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta)); + assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost)); + assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.originalInventory.length, 45); + assert.deepEqual(inventory(f.game), f.originalInventory); +} +function open(f: SemutaDisasterStormFixture) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; + assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaDisasterDiscard'); + assert.equal(pending.reaction?.stage, 'offer'); assert.equal(pending.batch.cause, 'karama:stormProtection'); + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })), + [{ card: f.cost, by: f.spender, face: true }]); + if (pending.continuation.kind !== 'karamaDisasterDiscard') throw Error('Use the exact native disaster cost'); + assert.equal(pending.continuation.player, f.spender); assert.equal(pending.continuation.card, f.cost); + assert.deepEqual(pending.continuation.use.response, f.originalResponse); + assert.deepEqual(pending.continuation.resume.response, f.originalResponse); + assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(game.discard.filter(card => card.id === f.cost).length, 1); + assert.deepEqual(physical(game), physical(f.game)); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function physicalOutcome(game: Game, f: SemutaDisasterStormFixture, losses: number, eliteLosses: number) { + assert.equal(player(game, f.owner).tanks, player(f.game, f.owner).tanks + losses); + assert.equal(player(game, f.owner).elites?.tanks, (player(f.game, f.owner).elites?.tanks ?? 0) + eliteLosses); + assert.equal(player(game, f.owner).forces[f.key] ?? 0, f.amount - losses); + assert.equal(player(game, f.owner).elites?.forces[f.key] ?? 0, f.elite - eliteLosses); + assert.equal(player(game, f.owner).forces['sietch_tabr:14'], 10); + assert.equal(player(game, f.owner).reserves, player(f.game, f.owner).reserves); + assert.equal(player(game, f.owner).elites?.reserves, player(f.game, f.owner).elites?.reserves); + assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice); + assert.equal(player(game, f.owner).shipped, player(f.game, f.owner).shipped); + assert.equal(player(game, f.owner).moved, player(f.game, f.owner).moved); + assert.equal(game.pendingArrivalReaction?.length ?? 0, 0); + assert.equal(game.stormResolution ?? null, null); assert.equal(game.storm, f.expectedStorm); + assert.equal(game.phase, f.kind === 'shipment' ? 5 : 1); assert.equal(game.turn, f.game.turn); + assert.deepEqual(game.spiceDeck, f.game.spiceDeck); assert.deepEqual(game.spiceDiscard, f.game.spiceDiscard); + if (f.kind === 'shipment') { + assert.equal(game.active, f.owner); assert.deepEqual(game.movementRemaining, f.game.movementRemaining); + assert.deepEqual(game.ready, f.game.ready); assert.deepEqual(game.stormMovementSource ?? null, f.originalStormMovementSource); + } else { + assert.equal(game.stormMovementSource ?? null, null); + assert.notEqual(game.response?.kind, 'stormProtection'); + } + assert.deepEqual(inventory(game), f.originalInventory); +} +function prevented(game: Game, f: SemutaDisasterStormFixture, claimed: boolean) { + assert.equal(game.pendingTreacheryDiscard ?? null, null); assert.equal(game.pendingKarama ?? null, null); + assert.notEqual(game.decision?.kind, 'stormLosses'); assert.notEqual(game.response?.kind, 'stormProtection'); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed)); + assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed)); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed)); + const steps: { actor: string; action: Action }[] = []; + game = finish(game, f.kind, steps); + assert.ok(steps.every(step => step.action.type === 'decision' && step.action.done === true)); + physicalOutcome(game, f, 3, 1); + const snapshot = structuredClone(game); assert.deepEqual(finish(game, f.kind), snapshot); + return game; +} + +for (const kind of kinds) for (const claimed of [false, true]) void test( + `native Advanced ${kind} Storm protection cost ${claimed ? 'claim' : 'pass'} precedes full casualties and exact original continuation`, () => { + const f = fixture({ kind }); nativeSource(f); + const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + const game = prevented(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + reject(game, f.holder, { type: 'semutaCommit', event }); + reject(game, f.holder, { type: 'semutaPass', event }); + reject(game, f.spender, f.action); + }); + +for (const kind of kinds) for (const elite of [0, 1]) void test( + `native Advanced ${kind} allowance keeps its real half-casualty chooser and ${elite} elite loss`, () => { + const f = fixture({ kind }); nativeSource(f); + let game = allow(structuredClone(f.game)); + assert.equal(game.pendingTreacheryDiscard ?? null, null); + assert.equal(game.decision?.kind, 'stormLosses'); + if (game.decision?.kind !== 'stormLosses') throw Error('Use the actual typed casualty decision'); + assert.equal(game.decision.player, f.owner); assert.equal(game.decision.key, f.key); + assert.equal(game.decision.amount, 2); assert.equal(game.decision.minElite, 0); assert.equal(game.decision.maxElite, 1); + assert.equal(game.decision.resume, kind === 'traversal' ? 'storm' : 'shipment'); + assert.equal(player(game, f.owner).forces[f.key], 3); assert.equal(player(game, f.owner).tanks, 0); + assert.equal(player(game, f.owner).elites?.forces[f.key], 1); + if (kind === 'traversal') { + assert.equal(game.stormResolution?.traversed, 1); assert.deepEqual(game.stormResolution?.pending, []); + } else assert.deepEqual(game.pendingArrivalReaction, f.originalArrival); + game = act(game, f.owner, { type: 'decision', elite }); + game = finish(game, kind); + physicalOutcome(game, f, 2, elite); + assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost)); + assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta)); + reject(game, f.owner, { type: 'decision', elite }); + }); + +void test('fresh disaster costs reject stale and underlying replies without consuming the original traversal or arrival', () => { + for (const kind of kinds) { + const f = fixture({ kind }), game = open(f), event = game.pendingTreacheryDiscard!.batch.event; + reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` }); + reject(game, f.spender, { type: 'semutaCommit', event }); + reject(game, f.owner, { type: 'passResponse' }); reject(game, f.spender, f.action); + reject(game, f.owner, { type: 'decision', elite: 0 }); + reject(game, f.owner, kind === 'shipment' ? f.nativeAction : { type: 'ready' }); + assert.deepEqual(physical(game), physical(f.game)); + prevented(pass(game), f, false); + } +}); + +void test('the offered Fremen Storm cost rejects a mismatched queued arrival before any Semuta reply', () => { + const f = fixture({ kind: 'shipment' }); nativeSource(f); + // Negative receipt control after actual cost admission: the earned journey + // and physical cohort stay intact; this does not widen native pre-cost rules. + const game = structuredClone(open(f)), event = game.pendingTreacheryDiscard!.batch.event; + assert.equal(game.pendingArrivalReaction?.length, 1); + game.pendingArrivalReaction![0].amount++; + reject(game, f.holder, { type: 'semutaCommit', event }); + reject(game, f.holder, { type: 'semutaPass', event }); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta)); + assert.equal(game.discard.filter(card => card.id === f.cost).length, 1); + assert.equal(game.pendingTreacheryDiscard?.batch.event, event); +}); + +void test('all four minimal policies answer both actual disaster-cost sources legally', () => { + for (const kind of kinds) { + const f = fixture({ kind }), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + for (const difficulty of DIFFICULTIES) { + const own = viewGame(offered, f.holder); own.players.find(player => player.id === f.holder)!.bot = difficulty; + const before = structuredClone(own), action = botActions(own)[0]; assert.ok(action, difficulty); + assert.deepEqual(own, before); + assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.equal(action.event, event); + let game = act(structuredClone(offered), f.holder, action); + const claimed = action.type === 'semutaCommit'; + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const choosing = viewGame(game, f.holder); choosing.players.find(player => player.id === f.holder)!.bot = difficulty; + const selected = botActions(choosing)[0]; assert.ok(selected); + assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); + game = act(game, f.holder, selected); + } else if (game.pendingTreacheryDiscard) game = pass(game); + prevented(game, f, claimed); + } + } +}); diff --git a/tests/semuta-disaster-worm-engine.test.ts b/tests/semuta-disaster-worm-engine.test.ts new file mode 100644 index 00000000..476588db --- /dev/null +++ b/tests/semuta-disaster-worm-engine.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { validateWormCancellation } from '../game/disaster-preflight'; +import { + createSemutaDisasterWormFixture as fixture, actSemutaDisasterWorm as act, + passSemutaDisasterWorm as pass, claimSemutaDisasterWorm as claim, + allowSemutaDisasterWorm as allow, finishSemutaDisasterWorm as finish, + semutaDisasterWormPlayer as player, semutaDisasterWormInventory as inventory, + semutaDisasterWormPhysical as physical, type SemutaDisasterWormFixture, +} from './fixture-semuta-disaster-worm'; + +const variants = [ + { kind: 'wormSurvival' as const, advanced: false }, + { kind: 'wormSurvival' as const, advanced: true }, + { kind: 'wormAllyProtection' as const, advanced: false }, + { kind: 'wormAllyProtection' as const, advanced: true }, + { kind: 'wormPlacement' as const, advanced: true }, + { kind: 'wormSurvival' as const, advanced: true, summoned: true }, +]; +const clock = (game: Game) => ({ turn: game.turn, phase: game.phase, ready: game.ready, active: game.active, + storm: game.storm, spiceDeck: game.spiceDeck, spiceDiscard: game.spiceDiscard, + spiceSequence: game.spiceSequence, spiceResolution: game.spiceResolution, spiceWindow: game.spiceWindow, + wormRides: game.wormRides, summonedWorm: game.summonedWorm, summonedBeforeBlow: game.summonedBeforeBlow, + nexus: game.nexus, wormPlacementCanceledTurn: game.wormPlacementCanceledTurn }); +const appearances = (game: Game, territory: string) => { + const name = territory === 'funeral_plain' ? 'Funeral Plain' : 'The Great Flat'; + return game.log.filter(row => row.text === `Shai-Hulud appeared in ${name}.`).length; +}; +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function nativeSource(f: SemutaDisasterWormFixture) { + assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, f.advanced); assert.equal(f.setup.semutaPreview, true); + assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0)); + assert.deepEqual(f.game.players.map(p => p.faction), ['richese', 'choam', 'fremen', 'guild', 'emperor']); + assert.ok(player(f.afterSetup, f.spender).hand.some(card => card.id === f.cost && card.effect === 'karama')); + assert.ok(player(f.afterSetup, f.owner).hand.some(card => card.id === f.summonCost && card.effect === 'karama')); + assert.notEqual(f.cost, f.summonCost); + assert.equal(player(f.afterSetup, f.owner).forces['sietch_tabr:14'], 10); + if (f.advanced) assert.equal(player(f.afterSetup, f.owner).elites?.reserves, 3); + assert.equal(f.acquisition.bid.actor, f.holder); assert.equal(f.acquisition.bid.action.amount, 1); + assert.equal(player(f.acquisition.before, f.holder).spice - player(f.acquired, f.holder).spice, 1); + assert.equal(player(f.acquired, f.richese).spice - player(f.acquisition.before, f.richese).spice, 1); + assert.equal(f.shipments.length, 2); + for (const shipment of f.shipments) { + assert.ok(shipment.steps.some(step => step.actor === shipment.actor && step.action.type === 'ship' && + step.action.amount === 1 && step.action.elite === 0)); + assert.equal(player(shipment.before, shipment.actor).spice - player(shipment.after, shipment.actor).spice, shipment.cost); + assert.equal(player(shipment.after, shipment.actor).forces[shipment.location], 1); + assert.equal(player(shipment.before, shipment.actor).reserves - player(shipment.after, shipment.actor).reserves, 1); + } + assert.equal(player(f.game, f.owner).forces[f.fremenLocation], 1); + assert.equal(player(f.game, f.holder).forces[f.allyLocation], 1); + assert.equal(f.game.turn, f.kind === 'wormAllyProtection' ? 3 : 2); assert.equal(f.game.phase, 1); + assert.equal(f.game.response?.kind, f.kind); assert.deepEqual(f.game.response, f.originalResponse); + assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.decision ?? null, null); + assert.equal(f.game.pendingTreacheryDiscard ?? null, null); validateWormCancellation(f.game, f.game.response!); + assert.ok(player(f.game, f.holder).hand.some(card => card.id === f.semuta)); + assert.ok(player(f.game, f.spender).hand.some(card => card.id === f.cost)); + if (f.kind === 'wormAllyProtection') { + assert.ok(f.alliance); assert.equal(f.alliance.steps.length, 2); + assert.ok(f.alliance.steps.every(step => step.action.type === 'alliance')); + assert.equal(player(f.game, f.owner).ally, f.holder); assert.equal(player(f.game, f.holder).ally, f.owner); + assert.equal(player(f.game, f.owner).forces[f.location] ?? 0, 0, 'The protector is genuinely remote'); + assert.equal(f.recipient, f.holder); assert.equal(f.priorSurvivals.length, 1); + } else { + assert.equal(f.alliance, null); assert.equal(f.recipient, null); + assert.equal(player(f.game, f.owner).ally ?? null, null); + } + if (f.kind === 'wormPlacement') { + assert.equal(f.advanced, true); assert.equal(f.beforeDeclaration?.decision?.kind, 'wormPlacement'); + assert.deepEqual(f.nativeAction, { type: 'decision', accept: true, territory: 'funeral_plain' }); + assert.equal(f.priorSurvivals.length, 1); assert.equal(appearances(f.game, f.territory), 1); + assert.deepEqual(f.originalRides, ['funeral_plain']); + } + for (const previous of f.priorSurvivals) { + assert.equal(previous.before.response?.kind, 'wormSurvival'); + assert.ok(previous.steps.every(step => step.action.type === 'passResponse')); + } + if (f.summoned) { + assert.ok(f.originalSummoned); assert.deepEqual(f.originalSummoned.resume, f.game.summonedWorm!.resume); + assert.ok(f.summonDiscard, 'The original summon cost really passed its earlier fresh batch'); + assert.ok(f.summonDiscard.steps.every(step => step.action.type === 'semutaPass')); + assert.equal(player(f.game, f.owner).specialKaramaUsed, true); + assert.equal(f.game.discard.filter(card => card.id === f.summonCost).length, 1); + assert.equal(f.game.spiceSequence ?? null, null); assert.equal(f.game.spiceResolution ?? null, null); + } else assert.equal(f.originalSummoned, null); + assert.equal(f.originalInventory.length, 45); assert.deepEqual(inventory(f.game), f.originalInventory); + assert.equal(f.sourceActionCount, f.actions.length); +} +function open(f: SemutaDisasterWormFixture) { + const game = act(structuredClone(f.game), f.spender, f.action), pending = game.pendingTreacheryDiscard; + assert.ok(pending); assert.equal(pending.continuation.kind, 'karamaDisasterDiscard'); + if (pending.continuation.kind !== 'karamaDisasterDiscard') throw Error('Expected the original worm cancellation cost'); + assert.equal(pending.batch.cause, `karama:${f.kind}`); assert.equal(pending.reaction?.stage, 'offer'); + assert.deepEqual(pending.batch.entries.map(entry => ({ card: entry.card.id, by: entry.discardedBy, face: entry.publicFace })), + [{ card: f.cost, by: f.spender, face: true }]); + assert.deepEqual(pending.continuation.use.response, f.originalResponse); + assert.deepEqual(pending.continuation.resume.response, f.originalResponse); + assert.equal(game.response ?? null, null); assert.equal(game.decision ?? null, null); + assert.deepEqual(physical(game), f.originalPhysical); assert.deepEqual(clock(game), clock(f.game)); + assert.equal(appearances(game, f.territory), appearances(f.game, f.territory)); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(game.discard.filter(card => card.id === f.cost).length, 1); + assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function outcome(state: Game, f: SemutaDisasterWormFixture, canceled: boolean) { + const beforeFinish = structuredClone(state), finishSteps: { actor: string; action: Action }[] = []; + const game = finish(state, finishSteps), victim = f.kind === 'wormAllyProtection' ? f.holder : f.owner; + const loss = Number(canceled && f.kind !== 'wormPlacement'); + assert.equal(game.turn, f.game.turn); assert.equal(game.phase, 2); assert.equal(game.response ?? null, null); + assert.equal(game.decision ?? null, null); assert.equal(game.pendingTreacheryDiscard ?? null, null); + assert.equal(game.spiceSequence ?? null, null); assert.equal(game.spiceResolution ?? null, null); + assert.equal(game.spiceWindow ?? null, null); assert.equal(game.nexus, false); assert.deepEqual(game.wormRides, []); + assert.equal(game.summonedWorm ?? null, null); + assert.equal(player(game, victim).forces[f.location] ?? 0, 1 - loss); + assert.equal(player(game, victim).tanks, player(f.game, victim).tanks + loss); + assert.equal(player(game, victim).elites?.tanks ?? 0, player(f.game, victim).elites?.tanks ?? 0); + assert.equal(player(game, f.owner).forces['sietch_tabr:14'], 10); + for (const p of f.game.players) { + assert.equal(player(game, p.id).reserves, p.reserves); + assert.equal(player(game, p.id).elites?.reserves, p.elites?.reserves); + if (p.id !== victim) { + assert.deepEqual(player(game, p.id).forces, p.forces); assert.equal(player(game, p.id).tanks, p.tanks); + } + } + assert.equal(appearances(game, f.territory) - appearances(f.game, f.territory), + Number(!(canceled && f.kind === 'wormPlacement'))); + if (f.kind === 'wormPlacement') { + assert.equal(game.wormPlacementCanceledTurn ?? null, canceled ? game.turn : f.game.wormPlacementCanceledTurn ?? null); + if (!canceled) assert.ok(beforeFinish.response?.kind === 'wormSurvival' || + finishSteps.some(step => step.action.type === 'passResponse'), 'The later real survival window receives actual passes'); + } + if (f.kind === 'wormAllyProtection') { + assert.equal(player(game, f.owner).ally, f.holder); assert.equal(player(game, f.holder).ally, f.owner); + assert.equal(player(game, f.owner).forces[f.fremenLocation], 1); + } + if (f.summoned) { + assert.equal(player(game, f.owner).specialKaramaUsed, true); + assert.equal(game.discard.filter(card => card.id === f.summonCost).length, 1); + } + assert.ok(finishSteps.every(step => step.action.type !== 'ship' && step.action.type !== 'card')); + assert.deepEqual(inventory(game), f.originalInventory); return game; +} +function denied(state: Game, f: SemutaDisasterWormFixture, claimed: boolean) { + const game = outcome(state, f, true); + assert.equal(player(game, f.spender).hand.some(card => card.id === f.cost), false); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.cost).length, Number(claimed)); + assert.equal(game.discard.filter(card => card.id === f.cost).length, Number(!claimed)); + assert.equal(player(game, f.holder).hand.filter(card => card.id === f.semuta).length, Number(!claimed)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, Number(claimed)); + reject(game, f.spender, f.action); return game; +} + +for (const variant of variants) for (const claimed of [false, true]) void test( + `${variant.advanced ? 'Advanced' : 'Basic'} native ${variant.summoned ? 'summoned ' : ''}${variant.kind} cost Semuta ${claimed ? 'claim' : 'pass'} resumes the original worm once`, () => { + const f = fixture(variant); nativeSource(f); const offered = open(f); + denied(claimed ? claim(offered, f.holder) : pass(offered), f, claimed); + }, +); + +void test('native worm allowances retain protection and resolve actual additional survival and summoned parents', () => { + for (const variant of variants) { + const f = fixture(variant); nativeSource(f); const game = outcome(allow(structuredClone(f.game)), f, false); + assert.ok(player(game, f.spender).hand.some(card => card.id === f.cost)); + assert.ok(player(game, f.holder).hand.some(card => card.id === f.semuta)); + } +}); + +void test('native worm cost reactions reject stale foreign and underlying replies before any devouring or draw', () => { + for (const variant of variants) { + const f = fixture(variant), game = open(f), event = game.pendingTreacheryDiscard!.batch.event; + reject(game, f.holder, { type: 'semutaCommit', event: `${event}:stale` }); + reject(game, f.spender, { type: 'semutaCommit', event }); reject(game, f.spender, f.action); + reject(game, f.owner, { type: 'passResponse' }); reject(game, f.owner, { type: 'ready' }); + if (f.nativeAction) reject(game, f.owner, f.nativeAction); + if (f.summonDiscard) reject(game, f.holder, { type: 'semutaCommit', event: f.summonDiscard.before.pendingTreacheryDiscard!.batch.event }); + const done = denied(pass(game), f, false); reject(done, f.holder, { type: 'semutaCommit', event }); + } +}); + +void test('all four minimal profiles legally claim or pass native survival ally placement and summoned worm costs', () => { + for (const variant of variants) { + const f = fixture(variant), offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + for (const difficulty of DIFFICULTIES) { + const own = viewGame(offered, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty; + const proposed = botActions(own)[0]; assert.ok(proposed, difficulty); + assert.ok(proposed.type === 'semutaPass' || proposed.type === 'semutaCommit'); assert.equal(proposed.event, event); + let game = act(structuredClone(offered), f.holder, proposed); + const claimed = proposed.type === 'semutaCommit'; + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const selecting = viewGame(game, f.holder); selecting.players.find(p => p.id === f.holder)!.bot = difficulty; + const selected = botActions(selecting)[0]; assert.ok(selected); + assert.equal(selected.type, 'semutaSelect'); assert.equal(selected.card, f.cost); + game = act(game, f.holder, selected); + } else if (game.pendingTreacheryDiscard) game = pass(game); + denied(game, f, claimed); + } + } +});