Offer Semuta after clean CHOAM Charity Karama cost

This commit is contained in:
Quintine committed 2026-09-30 11:56:02 +10:00
1 parent 32d9ef4ec9
commit d9c67ca992
10 files changed
+300 -45

No files matched your search

+23 -17
View File
@@ -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,
+27
View File
@@ -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
+9 -8
View File
@@ -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
+7
View File
@@ -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
+13 -6
View File
@@ -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;
+1 -1
View File
@@ -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;
+76 -6
View File
@@ -1190,6 +1190,14 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'karamaCharityDiscard';
player: string;
card: string;
use: Extract<KaramaUse, { kind: 'cancel' }>;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'thumperDiscard';
player: string;
@@ -4850,6 +4858,22 @@ function residualPoisonDiscardSignature(g: Game, c: ResidualPoisonDiscardContinu
})),
});
}
type KaramaCharityDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['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<Game['pendingTreacheryDiscard']>['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.');
+5 -5
View File
@@ -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',
+74
View File
@@ -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);
+65 -2
View File
@@ -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(); }
});