diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 087c9a94..06c7b6e4 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -13,9 +13,10 @@ Ornithopter, used Distrans, final Truthtrance, four bounded Sapho, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a clean normal-auction lot, a completed CHOAM sale or clean -Kulon/La La La/Baliset/ordinary Trip to Gamont -Worthless, battle-card and Ix-deck Thumper/Amal discards -open the same neutral offer regardless of hidden Semuta custody. +Kulon/La La La/Baliset/ordinary Trip to Gamont, accepted +Jubba Cloak Worthless, battle-card and Ix-deck +Thumper/Amal discards open the same neutral offer regardless +of hidden Semuta custody. A clean **private** normal-auction Ixian ally replacement discard also pauses before its draw; no unknown face is shown before a holder commits. A completed CHOAM sale pays its quoted bank spice @@ -31,6 +32,9 @@ territory; shipment stays legal. Clean ordinary Trip to Gamont spends its public card before one opponent force returns to reserves; elite and concealed No-Field returns retain their earlier automatic path. +Accepted Jubba Cloak spends its public card before protecting +one CHOAM territory; the moving storm resolves only after a +claim or all-seat decline. Residual Poison commits one random death before the offer; only an uninspected battle with nobody ready qualifies. Once Around commits bidder order and frees a slot; other Sapho scopes commit chooser or @@ -46,6 +50,16 @@ full-hand ordering, normal Richese starts and deployed acceptance remain unfinished. Neutral passes and free-slot guard are product choices. +Accepted printed Jubba Cloak passes focused engine and +authenticated SQLite, a direct one-storm claim smoke and +independent review without findings. `npm run check` passed +types, lint and 5,853 offline tests; the production build +and phone-width in-app guide passed. Isolated built-worker +HTTP passed 50/55 with four Wrangler-dev proxy 503s and +one timeout. The local suite is not green; proxy-free CI +for this exact checkpoint is pending. No NAS deployment +or complete faction claim follows. + Clean ordinary Trip to Gamont passes focused engine and authenticated SQLite checks, a direct physical claim/force return smoke and independent review without findings. @@ -55,8 +69,10 @@ in-app text update, and the production build passed. The built phone-width rules guide displayed both new CHOAM discard scopes. Isolated built-worker HTTP passed 50/55: four Wrangler-dev proxy 503s and one timeout. It is not a -green local HTTP suite or a live deployment; proxy-free CI -for this exact checkpoint is pending. +green local HTTP suite or a live deployment. Proxy-free +workflow `36674543364` passed the full isolated HTTP suite +for `953be21` and published its image; this is not NAS +deployment. Clean proactive Baliset passes focused engine and authenticated SQLite checks, a direct engine claim/restriction smoke and diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index caf25d39..18f14385 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,36 @@ # Dune implementation status +## 30 September 2026 — Semuta after accepted CHOAM Jubba Cloak cost + +In the explicit Richese preview, CHOAM's printed moving-storm +Jubba Cloak now discards its named public Worthless card before +a neutral Semuta reaction. No territory is protected and no +storm casualties or spice clearing occurs before claim or +all-seat decline. The saved receipt binds the selected territory, +storm traversal, phase, CHOAM force custody and physical card. +Claim or decline applies one protection then resumes the +existing storm suffix once, including ordinary casualties and +the next CHOAM market. A canceled card, Nexus conversion or +competing parent retains its previous direct path. + +Focused engine checks cover hidden-holder parity, JSON +normalization, forged territory/storm/force/card rejection, +physical custody, legal AI, one claim or decline and exact +casualties/spice movement. Authenticated in-memory SQLite +verifies seat views, restart, stale retry and storm completion. +Direct engine smoke observed pre-protection card custody, +one claim, CHOAM survival and the opponent's three losses in +one traversed storm. Full Semuta and expansion gates remain open. + +`npm run check` passed types, lint and 5,853 offline +tests; the production build passed. Independent rule review +found no scoped defect. The built phone-width guide visibly +described Jubba's before-protection offer. Isolated +Wrangler-dev HTTP passed 50/55: four requests returned its +proxy-layer 503 and one discussion request timed out. This +is not a green local HTTP suite. Proxy-free CI and live NAS +deployment remain pending for this checkpoint. + ## 30 September 2026 — Semuta after proactive ordinary Trip cost In the explicit Richese preview, an accepted ordinary Trip to @@ -33,9 +64,10 @@ force return. The built phone-width rules guide visibly named Baliset and ordinary Trip before their effects. Isolated Wrangler-dev HTTP passed 50/55: four requests returned its proxy-layer 503 and one Advanced-preview request timed out. -This is not a green local HTTP suite. Proxy-free container -verification and live deployment remain pending for this -checkpoint; no complete expansion claim follows. +This is not a green local HTTP suite. Proxy-free workflow +`36674543364` passed the full isolated HTTP suite for +`953be21` and published its image. No NAS deployment or +complete expansion claim follows. ## 30 September 2026 — Semuta after proactive CHOAM Baliset cost diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 028218f2..29d05395 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -113,6 +113,14 @@ return without replaying the response. Elite, concealed No-Field, closing Mentat decision, Nexus and competing parents retain their automatic paths. +Accepted printed Jubba Cloak stages `choamJubbaDiscard` after +its public Worthless card cost but before protecting CHOAM's +selected territory or resolving the moving storm. The receipt +binds the current storm traversal and original CHOAM forces; +claim or decline marks the one protected territory and resumes +the same storm exactly once. Canceled cards, Nexus conversion +and competing parents retain their direct paths. + Completed Ornithopter movement now offers the same retired public card before its already committed arrival suffix only in a roster without Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index bae244de..6775d3bc 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -69,8 +69,8 @@ Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a clean normal-auction lot, a completed CHOAM sale or clean Kulon/proactive La La La/Baliset/ordinary -Trip to Gamont Worthless discard, battle and Ix-deck -Thumper/Amal discards**, plus +Trip to Gamont Worthless discard, accepted moving-storm Jubba +Cloak, battle and Ix-deck Thumper/Amal discards**, plus a clean **private** normal-auction Ixian ally replacement discard before its draw. Other than the one explicitly suspended charity/Inflation response, the @@ -126,6 +126,14 @@ decline returns that one force and reconciles occupation once. Elite, concealed No-Field, closing Mentat, Nexus and competing parents retain their prior automatic paths. +An accepted printed Jubba Cloak response pauses after its +public Worthless card cost but before the chosen CHOAM territory +is protected. The saved receipt binds the storm traversal, +territory and CHOAM forces. A claim or decline applies that +protection once and resumes the same storm casualty and +spice-clearance sequence. Canceled Jubba, Nexus conversion +and competing parents retain their original direct paths. + An early-ended Ornithopter advances the saved queue; a completed flight resumes one committed arrival only without BG, Ecaz or Moritani entry reactions. Distrans's gift remains private with its diff --git a/game/engine.ts b/game/engine.ts index 22c9d81c..863943c1 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1244,6 +1244,13 @@ export type Game = { location: string; stateSignature: string; } + | { + kind: 'choamJubbaDiscard'; + player: string; + card: string; + territory: string; + stateSignature: string; + } | { kind: 'thumperDiscard'; player: string; @@ -5031,6 +5038,24 @@ function choamGamontDiscardSignature(g: Game, c: ChoamGamontDiscardContinuation) targetElites: target.elites, targetNoField: target.noField, }); } +type ChoamJubbaDiscardContinuation = Extract< + NonNullable['continuation'], + { kind: 'choamJubbaDiscard' } +>; +function choamJubbaDiscardSignature(g: Game, c: ChoamJubbaDiscardContinuation) { + const owner = getPlayer(g, c.player); + return JSON.stringify({ + player: c.player, card: c.card, territory: c.territory, + turn: g.turn, phase: g.phase, active: g.active, order: g.order, + storm: g.storm, stormPending: g.stormPending, + resolution: g.stormResolution, + parent: nullentropyParentSignature(g, + { response: null, decision: null, pendingKarama: null, phaseOpening: null }, + `choam:jubba:${c.card}`), + ownerHand: owner.hand.map(card => card.id), + ownerForces: owner.forces, ownerElites: owner.elites, + }); +} type ThumperDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'thumperDiscard' } @@ -5721,6 +5746,22 @@ function treacheryDiscardIntegrity(g: Game) { c.stateSignature === choamGamontDiscardSignature(g, c), 'The used Trip to Gamont no longer matches its pending ordinary force return.', ); + } else if (continuation?.kind === 'choamJubbaDiscard') { + const c = continuation, entry = batch.entries[0]; + const owner = g.players.find(p => p.id === c.player); + requireRule( + g.phase === 0 && owner?.faction === 'choam' && + !!g.stormResolution && + choamStormOptions(g).some(option => option.territory === c.territory) && + !g.pendingChoamWorthless && !g.response && !g.decision && + !g.phaseOpening && + batch.cause === 'choam:jubba' && batch.entries.length === 1 && + entry.publicFace && entry.discardedBy === c.player && + entry.card.id === c.card && entry.card.kind === 'worthless' && + entry.card.name === 'Jubba Cloak' && + c.stateSignature === choamJubbaDiscardSignature(g, c), + 'The used Jubba Cloak no longer matches its pending storm protection.', + ); } else if (continuation?.kind === 'thumperDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( @@ -6391,6 +6432,7 @@ function semutaOfferSupported( continuation.kind !== 'choamLaLaLaDiscard' && continuation.kind !== 'choamBalisetDiscard' && continuation.kind !== 'choamGamontDiscard' && + continuation.kind !== 'choamJubbaDiscard' && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'battleResolved' && @@ -6462,6 +6504,7 @@ function semutaOfferSupported( continuation.kind === 'choamLaLaLaDiscard' || continuation.kind === 'choamBalisetDiscard' || continuation.kind === 'choamGamontDiscard' || + continuation.kind === 'choamJubbaDiscard' || (continuation.kind === 'karamaCharityDiscard' || continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaBgCharityDiscard' || @@ -6491,6 +6534,10 @@ function semutaOfferSupported( (continuation.kind !== 'choamGamontDiscard' || (!g.pendingChoamMove && !g.pendingArrivalOverlap && !g.pendingRevival && !g.ornithopter)) && + (continuation.kind !== 'choamJubbaDiscard' || + (!!g.stormResolution && !g.pendingChoamMove && + !g.pendingArrivalOverlap && !g.pendingRevival && + !g.ornithopter && !g.ecologicalStorm)) && !g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingRicheseGift && @@ -6650,6 +6697,9 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string } else if (next.kind === 'choamGamontDiscard') { completeChoamGamont(g, getPlayer(g, next.player), next.target, next.location, 0); + } else if (next.kind === 'choamJubbaDiscard') { + completeChoamJubba(g, getPlayer(g, next.player), next.territory); + offerChoamStorm(g); } else if (next.kind === 'nullentropyDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -20484,6 +20534,10 @@ function completeChoamBaliset(g: Game, target: string, location: string) { (g.choamBaliset ??= []).push({ turn: g.turn, player: target, territory: location }); log(g, `${getPlayer(g, target).name} cannot move into CHOAM's ${territory(location).name} this phase; shipment remains possible.`); } +function completeChoamJubba(g: Game, owner: Player, location: string) { + (g.stormResolution!.choamProtected ??= []).push(location); + log(g, `${owner.name} protected its own forces in ${territory(location).name} for this storm movement with Jubba Cloak.`); +} function completeChoamGamont( g: Game, owner: Player, targetId: string, from: string, elite: number, noFieldEvent?: string, @@ -20871,11 +20925,21 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) } completeChoamKulon(g, choam); } else if (pending.effect === 'jubba') { - (g.stormResolution!.choamProtected ??= []).push(pending.location!); - log( - g, - `${choam.name} protected its own forces in ${territory(pending.location!).name} for this storm movement with Jubba Cloak.`, - ); + if (g.semutaPreview && used.name === 'Jubba Cloak' && + pending.storm && !pending.revival && !pending.movement && + !pending.mentat && !pending.nexusEvent) { + const continuation: ChoamJubbaDiscardContinuation = { + kind: 'choamJubbaDiscard', player: choam.id, + card: used.id, territory: pending.location!, stateSignature: '', + }; + const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; + if (semutaOfferSupported(g, continuation, entries)) { + continuation.stateSignature = choamJubbaDiscardSignature(g, continuation); + stageTreacheryDiscard(g, 'choam:jubba', entries, continuation); + return; + } + } + completeChoamJubba(g, choam, pending.location!); } else if (pending.effect === 'baliset') { if (g.semutaPreview && used.name === 'Baliset' && !pending.storm && !pending.revival && !pending.movement && diff --git a/game/reference.ts b/game/reference.ts index 7845bdb9..d67a5f41 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [ : definition.card.effect === 'portableSnooper' ? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.' : definition.card.effect === 'semutaDrug' - ? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, proactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive Worthless uses, elite or concealed Trip returns, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.' + ? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive La La La disposal before blocking free revival, proactive Baliset before restricting movement, proactive ordinary Trip to Gamont before returning a force, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, reactive Worthless uses except printed Jubba, elite or concealed Trip returns, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', ], ...(definition.card.effect === 'semutaDrug' @@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation' as const, status: 'Partial' as const, detail: - 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement pauses before its draw with a neutral, face-hidden offer. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement and ordinary Trip returns one selected force only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, reactive Worthless uses, private discards, nested responses, pending rewards and full-hand exchange remain unfinished.', + 'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM sale, clean Kulon and proactive La La La/Baliset/ordinary Trip to Gamont Worthless use, accepted printed Jubba before storm protection, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement pauses before its draw with a neutral, face-hidden offer. CHOAM sale income is committed before its neutral offer and the same market resumes once; Kulon adds one movement range, La La La blocks one target’s free revival, Baliset restricts the chosen player’s movement, ordinary Trip returns one selected force, and Jubba protects one CHOAM territory only after their offers. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, other reactive Worthless uses, private discards, nested responses, pending rewards and full-hand exchange remain unfinished.', }, { area: 'Player controls' as const, @@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Documentation' as const, status: 'Partial' as const, detail: - 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', + 'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', }, { area: 'Verification' as const, status: 'Partial' as const, detail: - 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, reactive Worthless uses, pending rewards, full games and live deployment remain unverified.', + 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, clean Kulon/proactive La La La/Baliset/ordinary Trip to Gamont Worthless and accepted printed Jubba Cloak, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, other reactive Worthless uses, pending rewards, full games and live deployment remain unverified.', evidence: [ 'tests/semuta-engine.test.ts', 'tests/semuta-recovery.test.ts', diff --git a/tests/choam-jubba.test.ts b/tests/choam-jubba.test.ts index 935e1cc4..39bb3932 100644 --- a/tests/choam-jubba.test.ts +++ b/tests/choam-jubba.test.ts @@ -5,6 +5,7 @@ import { createGame, newPlayer, viewGame, + normalizeAutomaticGame, type Game, type Action, } from '../game/engine'; @@ -12,6 +13,8 @@ import { baseDeck } from '../game/cards'; import { MOBILE_LOCATION } from '../game/board'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; +import { richeseCards } from '../game/richese-cards'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const send = (g: Game, id: string, action: Action) => @@ -121,6 +124,82 @@ void test('Jubba protects only CHOAM in one territory in basic and advanced game conserve(g); } }); +void test('accepted Jubba discard offers Semuta before protection and resumes one storm', () => { + const initial = fixture(); + initial.players[1] = newPlayer('e', 'Richese', 'richese'); + forces(initial, 'e', { 'red_chasm:7': 3 }); + initial.richeseCache = richeseCards(); + initial.semutaPreview = true; + const index = initial.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID); + assert.ok(index >= 0); + const semuta = initial.richeseCache.splice(index, 1)[0]; + player(initial, 'e').hand.push(semuta); + contest(initial, 'b'); + const card = player(initial, 'c').hand.find(card => card.name === 'Jubba Cloak')!.id; + const physical = (state: Game) => [ + ...state.deck, ...state.discard, ...state.richeseCache!, + ...state.players.flatMap(p => p.hand), + ].map(card => card.id).sort(); + const original = physical(initial); + const absent = structuredClone(initial); + player(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]]; + absent.richeseCache!.push(semuta); + const pending = allow(play(begin(initial))); + const neutral = allow(play(begin(absent))); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamJubbaDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(pending.storm, 5); + assert.ok(pending.stormResolution); + assert.deepEqual(pending.stormResolution.choamProtected, undefined); + assert.equal(player(pending, 'c').forces['red_chasm:7'], 4); + assert.equal(player(pending, 'e').forces['red_chasm:7'], 3); + assert.equal(pending.discard.filter(used => used.id === card).length, 1); + assert.deepEqual(viewGame(pending, 'c'), viewGame(neutral, 'c')); + assert.deepEqual(viewGame(pending, 'b'), viewGame(neutral, 'b')); + assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true); + assert.equal(botActions(viewGame(pending, 'e'))[0]?.type, 'semutaCommit'); + assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending); + for (const change of [ + (state: Game) => { + const c = state.pendingTreacheryDiscard!.continuation; + if (c.kind === 'choamJubbaDiscard') c.territory = 'basin'; + }, + (state: Game) => { state.stormResolution!.distance++; }, + (state: Game) => { player(state, 'c').forces = {}; }, + (state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; }, + ]) { + const corrupt: Game = JSON.parse(JSON.stringify(pending)); + change(corrupt); + const before = JSON.stringify(corrupt); + assert.throws(() => send(corrupt, 'e', { type: 'semutaCommit', event })); + assert.equal(JSON.stringify(corrupt), before); + } + let declined: Game = JSON.parse(JSON.stringify(pending)); + for (const id of ['c', 'b', 'e']) + declined = send(declined, id, { type: 'semutaPass', event }); + const claimed = send(JSON.parse(JSON.stringify(pending)), 'e', + { type: 'semutaCommit', event }); + for (const state of [declined, claimed]) { + assert.equal(state.pendingTreacheryDiscard, null); + assert.equal(state.stormResolution, null); + assert.equal(state.storm, 8); + assert.equal(player(state, 'c').forces['red_chasm:7'], 4); + assert.equal(player(state, 'c').tanks, 0); + assert.equal(player(state, 'e').forces['red_chasm:7'], undefined); + assert.equal(player(state, 'e').tanks, 3); + assert.equal(state.spice['red_chasm:7'], undefined); + assert.equal(state.decision?.kind, 'choamMarket'); + assert.equal(state.log.filter(entry => entry.text.startsWith('Storm moved')).length, 1); + assert.deepEqual(physical(state), original); + assert.throws(() => send(state, 'e', { type: 'semutaCommit', event })); + } + assert.equal(declined.discard.filter(used => used.id === card).length, 1); + assert.equal(player(claimed, 'e').hand.filter(used => used.id === card).length, 1); + assert.equal(claimed.discard.filter(used => used.id === semuta.id).length, 1); +}); + void test('moving-storm decision and public projection do not reveal Jubba ownership', () => { const initial = fixture(), empty = structuredClone(initial); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index bd937a10..0164a7b2 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -1120,6 +1120,93 @@ void test('authenticated ordinary Trip Semuta claim returns one force after save } finally { f.sqlite.close(); } }); +void test('authenticated Jubba Semuta claim protects CHOAM before one saved storm', async () => { + const f = unitStore(); + try { + const made = await f.rooms.createRoom('Semuta Jubba', 'choam', false, ['choam']); + const code = made.view.code; + const richese = await f.rooms.joinRoom(code, 'Richese', 'richese'); + const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit'); + const auths = await Promise.all([made.token!, richese.token!, bg.token!] + .map(token => f.restart().authenticate(code, token))); + const existing = await f.restart().readRoom(code); + const g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']); + g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'), + newPlayer(auths[2].playerId, 'BG', 'beneGesserit')); + Object.assign(g, { status: 'playing', phase: 0, turn: 2, storm: 5, + stormPending: 3, active: auths[0].playerId, + order: auths.map(auth => auth.playerId), + deck: baseDeck(), richeseCache: richeseCards(), + spice: { 'red_chasm:7': 8 }, semutaPreview: true }); + for (const p of g.players) { + p.spice = 10; + p.hand = []; + p.forces = {}; + p.reserves = 20; + p.traitors = p.leaders.length ? [p.leaders[0].id] : []; + p.traitorChoices = []; + } + const semuta = take(g, SEMUTA_DRUG_ID), karama = take(g, 'karama'); + const index = g.deck.findIndex(card => card.name === 'Jubba Cloak'); + assert.ok(index >= 0); + const used = g.deck.splice(index, 1)[0]; + g.players[0].hand.push(used); + g.players[0].forces = { 'red_chasm:7': 4 }; + g.players[0].reserves = 16; + g.players[1].hand.push(semuta); + g.players[1].forces = { 'red_chasm:7': 3 }; + g.players[1].reserves = 17; + g.players[2].hand.push(karama); + g.version = existing.version + 1; + assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?') + .run(JSON.stringify(g), g.version, code, existing.version).changes, 1); + const seats = f.sqlite.prepare('SELECT * FROM seats').all(); + const act = async (index: number, action: Action) => { + const current = await f.restart().readRoom(code); + await f.restart().act(code, auths[index], current.version, action, clock); + return f.restart().readRoom(code); + }; + let current = await f.restart().readRoom(code); + for (let i = 0; i < auths.length; i++) + current = await act(i, { type: 'ready' }); + assert.equal(current.decision?.kind, 'choamStorm'); + current = await act(0, { type: 'card', mode: 'choam', + card: used.id, territory: 'red_chasm' }); + assert.equal(current.response?.kind, 'choamWorthless'); + for (let i = 0; current.response?.kind === 'choamWorthless' && i < 3; i++) { + const index = auths.findIndex(auth => + !viewGame(current, auth.playerId).responseControls?.hasPassed && + !!viewGame(current, auth.playerId).responseControls?.cancelCards.length); + assert.ok(index >= 0); + current = await act(index, { type: 'passResponse' }); + } + const event = current.pendingTreacheryDiscard!.batch.event; + assert.equal(current.pendingTreacheryDiscard?.continuation.kind, 'choamJubbaDiscard'); + assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(current.storm, 5); + assert.equal(current.stormResolution?.choamProtected, undefined); + assert.equal(current.players[0].forces['red_chasm:7'], 4); + assert.equal(current.players[1].forces['red_chasm:7'], 3); + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, current.version); + assert.equal((await f.restart().readSeatView(code, auths[1])).semutaReaction?.canCommit, true); + const claimed = await act(1, { type: 'semutaCommit', event }); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.equal(claimed.stormResolution, null); + assert.equal(claimed.storm, 8); + assert.equal(claimed.players[0].forces['red_chasm:7'], 4); + assert.equal(claimed.players[1].forces['red_chasm:7'], undefined); + assert.equal(claimed.players[1].tanks, 3); + assert.equal(claimed.players[1].hand.filter(card => card.id === used.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1); + await assert.rejects(f.restart().act(code, auths[1], current.version, + { type: 'semutaCommit', event }, clock)); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats); + for (const auth of auths) + assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId)); + } finally { f.sqlite.close(); } +}); + void test('authenticated CHOAM sale retains paid spice and market after a saved Semuta claim', async () => { const f = unitStore(); try {