diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 37aee723..91a24a98 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -11,24 +11,30 @@ accepts a fresh Richese roster with CHOAM & Richese and optionally the physical Ix deck. Clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, Once Around, movement-order, between-battles chooser or pre-plan aggressor Sapho, -clean pre-leader Residual Poison, battle-card and Ix-deck Thumper/Amal -discards open the same neutral offer regardless of hidden Semuta custody. +clean pre-leader Residual Poison, printed Karama canceling clean +CHOAM Charity, battle-card and Ix-deck Thumper/Amal discards open +the same neutral offer regardless of hidden Semuta custody. Karama +waits after its public cost but before settling the canceled income. Residual Poison commits one random death before the offer; only an -uninspected battle with no preparation ready qualifies. Once Around -commits bidder order and a freed slot; other Sapho scopes commit -chooser or aggressor priority. Claim or decline resumes the same lot, -chooser queue or unsealed battle without repeating its prior effect; -a Black Market Semuta reserved as the offered card cannot Commit. -Thumper waits before its injected -worm/spice draw; Amal waits after halving personal spice and clearing -earlier opening passes. Mixed-owner battle cards require committed -owner-only selection of another player's fresh card; mandatory and -optional winner cleanup remain separate. Minimal legal AI, JSON/ -SQLite continuation and competing-room CAS are connected. Mixed -arrivals, queued or promise-bound Truthtrance, pending rewards, other -producers, full-hand ordering, normal Richese starts and deployed -acceptance remain unfinished. Neutral passes and free-slot guard are -product choices. +uninspected battle with nobody ready qualifies. Once Around commits +bidder order and frees a slot; other Sapho scopes commit chooser or +aggressor priority. Claim or decline resumes the saved effect once. +A Black Market Semuta reserved as the offered card cannot Commit. +Thumper waits before worm/spice draw; Amal waits after halving spice +and clearing opening passes. Mixed-owner battle cards require +committed owner-only selection; mandatory and optional winner cleanup +remain separate. Minimal legal AI, JSON/SQLite continuation and +competing-room CAS are connected. Mixed arrivals, queued or +promise-bound Truthtrance, other Karama uses, pending rewards, +full-hand ordering, normal Richese starts and deployed acceptance +remain unfinished. Neutral passes and free-slot guard are product +choices. + +The previous `32d9ef4` container workflow built successfully but +failed isolated storage/HTTP verification; its image was not published. +The public job exposes no assertion log. Local Docker/Podman and +authenticated GitHub logs are unavailable. A later checkpoint must +pass container verification before it is a deployable candidate. One [natural Advanced Moritani assassination](MORITANI_ASSASSINATE_LEADERS.md#29-september-2026--natural-revealed-card-game) now proceeds from genuine battle loss to a dead-target Traitor reveal, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 4a9fb5d8..c6832dd6 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,32 @@ # Dune implementation status +## 30 September 2026 — Semuta after printed CHOAM Charity Karama + +In the explicit Richese preview, a clean printed Karama cancellation +of CHOAM Charity now pauses after the public activating card reaches +the discard but before canceled income settles. Every seat receives +the same neutral offer; a different Semuta holder can claim the +spent Karama, or all seats may decline. The receipt binds the exact +suspended response, spent card and spice/hand custody. Completion +retires it, restores that response and cancels CHOAM income once. +Prior-turn charity receipts remain valid; no current-turn cancellation +is accepted twice. Worthless conversion, other Karama uses and nested +responses retain their automatic paths. + +Authenticated SQLite restart/CAS, owner-only projection, corrupted +receipt rejection, first- and later-turn cancellation and one-card +custody were exercised. A direct production-engine smoke run observed +the parent response, neutral offer and one cancellation with zero +CHOAM income. Two independent read-only reviews found the prior-turn +receipt and test type-narrowing defects; both were corrected. +`npm run check` passed typecheck, lint and 5,827 offline tests; +`npm run build` and 55 local HTTP tests passed. The phone-width reference +rendered the new scope. No deployed revision was exercised. The +previous `32d9ef4` container workflow built but failed its isolated +storage/HTTP step; public logs expose only exit code 1, not the +underlying assertion. Docker, Podman and authenticated GitHub logs +were unavailable locally. Do not treat that image as published. + ## 30 September 2026 — Semuta after clean Residual Poison In the explicit Richese preview, Residual Poison now pauses after diff --git a/docs/RICHESE_COMPONENTS.md b/docs/RICHESE_COMPONENTS.md index e677bd76..acf1c126 100644 --- a/docs/RICHESE_COMPONENTS.md +++ b/docs/RICHESE_COMPONENTS.md @@ -12,7 +12,7 @@ Audited 6 September 2026. The ten physical identities and their readable face in - Player controls show both original plans, exact undialed counts or honest possible totals, the mode choice and a complete internal guide. No victim/death confirmation is added. The card stays physically reserved until standard cleanup; stale events, duplicate requests and corrupted source custody fail before resources change. All four AI profiles use public force possibilities, legal low-dial plans and revealed leader/bounty information. They preserve leader value when Artillery suppresses bounty and leave traitor/explosion precedence intact. -- Semuta Drug has a bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, four Sapho scopes, unready pre-leader Residual Poison and three separate battle discard stages. A fresh Richese roster may include the physical Ix deck: Thumper pauses before worm/spice draw and Amal pauses after halving spice before phase initialization. Multi-owner battle batches reveal candidates only after holder commitment and transfer one other-player card. Private gifts, unresolved promises, reactive arrivals, inspected/partly ready battles and pending battle rewards remain outside scope. Other producers, full-hand ordering and normal Richese starts are unfinished. Mirror Weapon and Juice of Sapho retain separate bounded paths, not combined-mode certification. +- Semuta Drug has a bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling CHOAM Charity, and three separate battle discard stages. A fresh Richese roster may include the physical Ix deck: Thumper pauses before worm/spice draw and Amal pauses after halving spice before phase initialization. Multi-owner battle batches reveal candidates only after holder commitment and transfer one other-player card. Private gifts, unresolved promises, reactive arrivals, other Karama forms, inspected/partly ready battles and pending battle rewards remain outside scope. Other producers, full-hand ordering and normal Richese starts are unfinished. Mirror Weapon and Juice of Sapho retain separate bounded paths, not combined-mode certification. The complete integrated validation checkpoint is recorded in [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md). @@ -114,13 +114,14 @@ Neutral all-seat responses, physical one-card recovery, provisional free-slot policy, minimal legal AI and saved continuation work after clean ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, four Sapho scopes, clean pre-leader Residual Poison, -Ix-deck Thumper/Amal and three battle discard stages. Once Around -commits the lot order and hand-slot release before a neutral offer; -battle-order/pre-plan Sapho commit chooser/tie priority; Residual -Poison commits one random death while readiness remains unsealed. -Thumper pauses before worm/spice draw; Amal resumes its already-halved -opening once. Multi-owner batches require committed owner-only selection -of one other-player card. Queued questions, promises, reactive arrivals, +printed Karama canceling CHOAM Charity, Ix-deck Thumper/Amal and +three battle discard stages. Karama's cost pauses before the one +canceled response settles; Once Around commits lot order and hand-slot +release, battle-order/pre-plan Sapho commit priority, and Residual +Poison commits one random death before the neutral offer. Thumper +pauses before worm/spice draw; Amal resumes its already-halved opening +once. Multi-owner batches require committed owner-only selection. +Queued questions, promises, reactive arrivals, other Karama forms, inspected/partly ready battles, pending rewards and other sources stay unconnected. Full-hand ordering, ten-card inventory, cache custody and public diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 836c6840..fe4741c9 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -47,6 +47,13 @@ binds the victim, leader custody and unsealed battle. The neutral offer is limited to a pre-leader board with no inspection or ready combatant; other accepted plays retain automatic continuation. +Printed Karama canceling a clean CHOAM Charity response stages +`karamaCharityDiscard` after its public card cost but before the +accepted cancellation. The receipt binds its sole suspended response, +spent card and unchanged spice/hand custody; claim or decline restores +the original response and finishes its cancellation once. Other +Karama forms and nested responses remain automatic. + 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 d9174bfb..a7cc8918 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -65,12 +65,12 @@ The explicit `semuta` development profile starts a fresh Richese roster with CHOAM & Richese and optionally the physical Ix deck, without opening public expansion starts. It interrupts **clean public ordinary-card, paid Box, retired Ornithopter, used Distrans, final -Truthtrance, Once Around, movement-order, between-battles chooser -or pre-plan aggressor Sapho, pre-leader Residual Poison, battle and -Ix-deck Thumper/Amal discards**, provided the parent transaction -has no competing response. -Every seat sees the same neutral fresh-discard opportunity regardless -of hidden Semuta custody. +Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison, +printed Karama canceling clean CHOAM Charity, battle and Ix-deck +Thumper/Amal discards**. Other than the one explicitly suspended +CHOAM Charity response, the parent transaction has no competing +response. Every seat sees the same neutral fresh-discard opportunity +regardless of hidden Semuta custody. 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 @@ -93,6 +93,13 @@ card before the offer. Claim or decline resumes the same pre-leader preparation once; an inspected or partly ready battle retains the automatic death path without this preview interruption. +For clean printed Karama cancellation of CHOAM Charity, the spent +public card reaches the pile before its accepted cancellation runs. +The sole suspended response is bound to the saved receipt; claim or +decline resumes the same canceled income once. Worthless conversion, +other cancellations, shipping, purchases and nested Karama responses +retain their separate automatic paths. + For a genuine Ix-deck Thumper, its played physical card reaches the discard before an injected worm or actual spice-card draw. Semuta claim or all-seat decline resumes the one saved Spice Blow; diff --git a/game/card-presentation.ts b/game/card-presentation.ts index 40df6148..3660d477 100644 --- a/game/card-presentation.ts +++ b/game/card-presentation.ts @@ -40,7 +40,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null { if (definition?.card.effect === 'distrans') return 'Choose a recipient and card in the Distrans transfer panel.'; if (definition?.card.effect === 'semutaDrug') - return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, Once Around/movement/battle-order/pre-plan Sapho, unready pre-leader Residual Poison, battle-card and Ix-deck Thumper/Amal discards are connected; pending rewards, other producers and full-hand sequencing remain unfinished.'; + return 'Use the neutral fresh-discard response in the Semuta development preview. Clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, unready pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, battle-card and Ix-deck Thumper/Amal discards are connected; pending rewards, other producers and full-hand sequencing remain unfinished.'; return definition && definition.card.effect !== 'karama' ? 'This Richese card’s effect is not implemented yet. You can inspect its rules.' : null; diff --git a/game/engine.ts b/game/engine.ts index 35211a27..f2164e25 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1190,6 +1190,14 @@ export type Game = { resume: NonNullable['resume']; stateSignature: string; } + | { + kind: 'karamaCharityDiscard'; + player: string; + card: string; + use: Extract; + resume: NonNullable['resume']; + stateSignature: string; + } | { kind: 'thumperDiscard'; player: string; @@ -4850,6 +4858,22 @@ function residualPoisonDiscardSignature(g: Game, c: ResidualPoisonDiscardContinu })), }); } +type KaramaCharityDiscardContinuation = Extract< + NonNullable['continuation'], + { kind: 'karamaCharityDiscard' } +>; +function karamaCharityDiscardSignature(g: Game, c: KaramaCharityDiscardContinuation) { + return JSON.stringify({ + player: c.player, card: c.card, use: c.use, + parent: nullentropyParentSignature(g, c.resume, `karama:charity:${c.card}`), + turn: g.turn, phase: g.phase, advanced: g.advanced, + charity: g.choamCharity, 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, + })), + }); +} type ThumperDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'thumperDiscard' } @@ -5411,6 +5435,22 @@ function treacheryDiscardIntegrity(g: Game) { c.stateSignature === residualPoisonDiscardSignature(g, c), 'The used Residual Poison no longer matches its committed leader death.', ); + } else if (continuation?.kind === 'karamaCharityDiscard') { + const c = continuation, entry = batch.entries[0]; + requireRule( + g.phase === 2 && !g.response && g.choamCharity?.turn !== g.turn && + c.use.response.kind === 'choamCharity' && + c.use.response.owner !== c.player && + c.resume.response?.kind === 'choamCharity' && + JSON.stringify(c.resume.response) === JSON.stringify(c.use.response) && + !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening && + batch.cause === 'karama:choamCharity' && batch.entries.length === 1 && + entry.publicFace && entry.discardedBy === c.player && + entry.card.id === c.card && entry.card.effect === 'karama' && + g.players.some(p => p.id === c.player) && + c.stateSignature === karamaCharityDiscardSignature(g, c), + 'The spent Karama no longer matches its suspended CHOAM Charity cancellation.', + ); } else if (continuation?.kind === 'thumperDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( @@ -6068,6 +6108,7 @@ function semutaOfferSupported( continuation.kind !== 'saphoAuctionDiscard' && continuation.kind !== 'saphoBattleOrderDiscard' && continuation.kind !== 'residualPoisonDiscard' && + continuation.kind !== 'karamaCharityDiscard' && continuation.kind !== 'thumperDiscard' && continuation.kind !== 'amalDiscard' && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && @@ -6122,14 +6163,21 @@ function semutaOfferSupported( continuation.kind === 'battleResolved' ? entries.every(entry => entry.publicFace) : entries.length === 1 && entries[0].publicFace) && + (continuation.kind === 'karamaCharityDiscard' + ? continuation.resume.response?.kind === 'choamCharity' && + !continuation.resume.decision && !continuation.resume.pendingKarama && + !continuation.resume.phaseOpening && !g.pendingKarama && + (!g.response || JSON.stringify(g.response) === JSON.stringify(continuation.resume.response)) + : true) && (continuation.kind === 'battleCleanup' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleResolved' || - (!continuation.resume.response && !continuation.resume.decision && - !continuation.resume.pendingKarama && - (continuation.kind === 'amalDiscard' - ? continuation.resume.phaseOpening?.passed.length === 0 - : !continuation.resume.phaseOpening))) && + (continuation.kind === 'karamaCharityDiscard' || + (!continuation.resume.response && !continuation.resume.decision && + !continuation.resume.pendingKarama && + (continuation.kind === 'amalDiscard' + ? continuation.resume.phaseOpening?.passed.length === 0 + : !continuation.resume.phaseOpening)))) && !g.karamaShipping && !g.auction && (continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) && (continuation.kind === 'saphoAggressorDiscard' || @@ -6332,6 +6380,12 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string const battle = g.battle!; if (battle.preLeader && !battle.preLeader.closed) battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player); + } else if (next.kind === 'karamaCharityDiscard') { + g.response = next.resume.response; + g.decision = next.resume.decision; + g.pendingKarama = next.resume.pendingKarama; + g.phaseOpening = next.resume.phaseOpening; + completeKarama(g, getPlayer(g, next.player), next.use); } else if (next.kind === 'amalDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -10677,7 +10731,23 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) { requireRule(!blocked, blocked ?? 'This card cannot be spent as Karama.'); validateKaramaUse(g, p, use, card); assertKaramaPromiseFeasibility(g, p, use, card); - discard(g, p, card.id); + const used = discard(g, p, card.id); + if (g.semutaPreview && card.effect === 'karama' && + use.kind === 'cancel' && use.response.kind === 'choamCharity') { + const continuation: KaramaCharityDiscardContinuation = { + kind: 'karamaCharityDiscard', player: p.id, card: used.id, use, + resume: { response: g.response, decision: g.decision, + pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening }, + stateSignature: '', + }; + const entries = [{ card: used, discardedBy: p.id, publicFace: true }]; + if (semutaOfferSupported(g, continuation, entries)) { + g.response = null; + continuation.stateSignature = karamaCharityDiscardSignature(g, continuation); + stageTreacheryDiscard(g, 'karama:choamCharity', entries, continuation); + return; + } + } if (use.kind === 'shipment') use = { ...use, card: card.id }; if (card.kind === 'worthless') { requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.'); diff --git a/game/reference.ts b/game/reference.ts index 8a216e9d..16454abb 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1140,7 +1140,7 @@ export const RULE_TOPICS: RuleTopic[] = [ definition.card.effect === 'juiceOfSapho' ? 'Use the Juice of Sapho panel for an available order change or pre-plan aggressor. Later intervention and other timing modes remain unfinished; consult the timing guide.' : definition.card.effect === 'karama' - ? 'This physical Karama uses the existing generic handler in development fixtures. Full Richese starts and complete interaction verification remain unavailable.' + ? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, a clean printed Karama cancellation of CHOAM Charity can pause on its public discard before canceling that response. Full Richese starts and other Karama/Semuta interactions remain unavailable.' : definition.card.effect === 'distrans' ? 'Use the Distrans transfer panel to choose another player and a separate held card. Open-bid timing and self-transfer remain guarded pending clarification.' : definition.card.effect === 'nullentropyBox' @@ -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 public 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 sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, clean Once Around/movement/battle-order/pre-plan Sapho, clean pre-leader Residual Poison and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. The committed death or order resumes once. 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 public 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 sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other Karama uses, 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 bounded Sapho scopes, pre-leader Residual Poison, Ix-deck Thumper/Amal and three battle discard stages. Residual Poison commits one random leader death before the neutral offer and restores an uninspected, unready battle once; Once Around commits bidder order, the chooser commits battle priority and pre-plan Sapho commits aggressor priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Neutral offers, owner-only candidates and one physical Semuta transfer retain privacy. Inspected/partly ready battles, pending rewards, other producers 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, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer, then the original response is canceled once. Residual Poison commits one random leader death; Sapho commits its applicable finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Neutral offers, owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, nested responses, pending rewards, other producers 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 ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, Once Around/movement/battle-order/pre-plan Sapho, pre-leader Residual Poison, Ix-deck Thumper/Amal and three battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.', + 'The guide distinguishes clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Ix-deck Thumper/Amal and three battle batches from 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 CAS and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, Ix-deck Thumper/Amal and three battle discard stages. Inspected/partly ready battles, pending rewards, full games and live deployment remain unverified.', + 'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite CAS 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, Ix-deck Thumper/Amal and three battle discard stages. Other Karama 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-charity.test.ts b/tests/choam-charity.test.ts index 05c86f3f..57afc66c 100644 --- a/tests/choam-charity.test.ts +++ b/tests/choam-charity.test.ts @@ -10,6 +10,8 @@ import { } from '../game/engine'; import { baseDeck, treacheryDeck } from '../game/cards'; import { createTechTokens } from '../game/tech-tokens'; +import { richeseCards } from '../game/richese-cards'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; @@ -77,6 +79,78 @@ function cancel(g: Game) { assert.ok(card, 'canceller must hold Karama before the declaration'); return applyAction(g, 'e', { type: 'card', card: card.id, mode: 'cancel' }); } +void test('clean printed Karama cancellation offers Semuta before CHOAM Charity is canceled', () => { + let g = fixture(); + g.players[1] = newPlayer('e', 'Richese', 'richese'); + g.richeseCache = richeseCards(); + g.semutaPreview = true; + const semuta = g.richeseCache.findIndex(c => c.id === SEMUTA_DRUG_ID); + assert.ok(semuta >= 0); + g.players[2].hand.push(g.richeseCache.splice(semuta, 1)[0]); + contest(g); + g = ready(g); + assert.equal(g.response?.kind, 'choamCharity'); + assert.equal(g.players[0].spice, 0); + g = cancel(g); + const event = g.pendingTreacheryDiscard!.batch.event; + assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(g.response, null); + assert.equal(g.choamCharity, undefined); + assert.equal(viewGame(g, 'b').semutaReaction?.canCommit, true); + assert.equal(viewGame(g, 'c').semutaReaction?.canCommit, false); + for (const change of [ + (saved: Game) => { + const continuation = saved.pendingTreacheryDiscard!.continuation; + assert.equal(continuation.kind, 'karamaCharityDiscard'); + if (continuation.kind === 'karamaCharityDiscard') + continuation.stateSignature = 'wrong'; + }, + (saved: Game) => { saved.players[1].spice = 1; }, + (saved: Game) => { saved.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; }, + ]) { + const corrupted: Game = JSON.parse(JSON.stringify(g)); + change(corrupted); + const snapshot = JSON.stringify(corrupted); + assert.throws(() => applyAction(corrupted, 'b', { type: 'semutaCommit', event })); + assert.equal(JSON.stringify(corrupted), snapshot); + } + let declined: Game = JSON.parse(JSON.stringify(g)); + for (const id of ['c', 'e', 'b']) + declined = applyAction(declined, id, { type: 'semutaPass', event }); + assert.equal(declined.pendingTreacheryDiscard, null); + assert.deepEqual(declined.choamCharity, { turn: 1, canceled: true }); + assert.equal(declined.players[0].spice, 0); + const claimed = applyAction(JSON.parse(JSON.stringify(g)), 'b', { type: 'semutaCommit', event }); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.deepEqual(claimed.choamCharity, declined.choamCharity); + assert.equal(claimed.players[2].hand.some(c => c.effect === 'karama'), true); + assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1); + assert.equal(claimed.players[0].spice, 0); +}); + +void test('prior-turn CHOAM Charity receipt does not block a fresh Karama Semuta response', () => { + let g = fixture(); + g.players[1] = newPlayer('e', 'Richese', 'richese'); + g.richeseCache = richeseCards(); + g.semutaPreview = true; + g.turn = 2; + g.choamCharity = { turn: 1, canceled: false }; + const semuta = g.richeseCache.findIndex(c => c.id === SEMUTA_DRUG_ID); + g.players[2].hand.push(g.richeseCache.splice(semuta, 1)[0]); + contest(g); + g = ready(g); + assert.equal(g.response?.kind, 'choamCharity'); + const pending = cancel(g); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.deepEqual(pending.choamCharity, { turn: 1, canceled: false }); + let resumed = pending; + const event = pending.pendingTreacheryDiscard!.batch.event; + for (const id of ['c', 'e', 'b']) + resumed = applyAction(resumed, id, { type: 'semutaPass', event }); + assert.deepEqual(resumed.choamCharity, { turn: 2, canceled: true }); + assert.equal(resumed.players[0].spice, 0); +}); + void test('CHOAM receives two spice per faction, including itself, only after its opening response', () => { const initial = contest(fixture()); let g = ready(initial); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index ddb8c682..0eb90431 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -2,9 +2,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { startPrototypeRoom } from '../tools/prototype-room'; import { botActions } from '../game/bots'; -import { viewGame, type Game, type Action } from '../game/engine'; +import { applyAction, createGame, newPlayer, viewGame, type Game, type Action } from '../game/engine'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; -import { spiceDeck } from '../game/cards'; +import { baseDeck, spiceDeck } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; import { unitStore } from './fixture-nexus-room-store'; import { saphoAggressorGame, aggressorAction } from './fixture-sapho-aggressor'; import { takeSaphoBattleCard } from './fixture-sapho-battle-order'; @@ -726,3 +727,65 @@ void test('authenticated pre-plan Sapho discard restores one aggressor choice be assert.equal(prepared.battle?.saphoAggressor?.uses.length, 1); } finally { f.sqlite.close(); } }); + +void test('authenticated printed Karama cost pauses and restores one canceled CHOAM Charity response', async () => { + const f = unitStore(); + try { + const made = await f.rooms.createRoom('Semuta Charity', '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); + let 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: 1, nexus: true, + order: g.players.map(p => p.id), deck: baseDeck(), + richeseCache: richeseCards(), semutaPreview: true, + }); + for (const player of g.players) { + player.spice = 0; + player.hand = []; + player.traitors = player.leaders.length ? [player.leaders[0].id] : []; + player.traitorChoices = []; + } + const karama = take(g, 'karama'); + g.players[1].hand.push(karama); + const semuta = take(g, SEMUTA_DRUG_ID); + g.players[2].hand.push(semuta); + for (const player of g.players) + g = applyAction(g, player.id, { type: 'ready' }); + while (g.decision?.kind === 'choamMarket') + g = applyAction(g, g.decision.player, { type: 'decision', done: true }); + assert.equal(g.response?.kind, 'choamCharity'); + 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 before = await f.restart().readRoom(code); + await f.restart().act(code, auths[index], before.version, action, clock); + return f.restart().readRoom(code); + }; + const pending = await act(1, { type: 'card', card: karama.id, mode: 'cancel' }); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(pending.choamCharity, undefined); + const event = pending.pendingTreacheryDiscard!.batch.event; + await f.restart().continueRoomAutomatic(code, clock); + assert.equal((await f.restart().readRoom(code)).version, pending.version); + assert.equal((await f.restart().readSeatView(code, auths[2])).semutaReaction?.canCommit, true); + assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, false); + const claimed = await act(2, { type: 'semutaCommit', event }); + assert.equal(claimed.pendingTreacheryDiscard, null); + assert.deepEqual(claimed.choamCharity, { turn: 1, canceled: true }); + assert.equal(claimed.players[0].spice, 0); + assert.equal(claimed.players[2].hand.filter(card => card.id === karama.id).length, 1); + assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1); + 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(); } +});