Offer Semuta on native Storm and worm cancellation costs

This commit is contained in:
Quintine committed 2026-10-26 02:18:55 +11:00
1 parent 4d09b6bffa
commit b3ea9f9c97
10 files changed
+1120 -6

No files matched your search

+38
View File
@@ -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
+40
View File
@@ -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,
+78
View File
@@ -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,
+62 -6
View File
@@ -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<KaramaUse, { kind: 'cancel' }>;
@@ -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<Game['pendingTreacheryDiscard']>['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'
+6
View File
@@ -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',
+23
View File
@@ -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);
}
+198
View File
@@ -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<typeof transition> | 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<typeof createSemutaDisasterStormFixture>;
+253
View File
@@ -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']> = 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']> = 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<typeof createSemutaDisasterWormFixture>;
+218
View File
@@ -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);
}
}
});
+204
View File
@@ -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);
}
}
});