Offer Semuta after clean Karama auction purchase

This commit is contained in:
Quintine committed 2026-09-30 13:34:03 +10:00
1 parent 5832f4353a
commit eede77a3e3
8 files changed
+250 -16

No files matched your search

+11 -4
View File
@@ -12,10 +12,11 @@ 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, printed Karama canceling clean
CHOAM Charity, Inflation or Bene Gesserit 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 response.
CHOAM Charity, Inflation or Bene Gesserit Charity or purchasing a
clean normal-auction lot, 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 cancellation or the
reserved free purchase settles.
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
@@ -31,6 +32,12 @@ full-hand ordering, normal Richese starts and deployed acceptance
remain unfinished. Neutral passes and free-slot guard are product
choices.
The clean printed Karama normal-auction purchase passed engine,
authenticated SQLite and guide/browser checks. `npm run check` passed
5,835 offline tests and the build passed. An isolated built-worker
HTTP suite passed 51/55 cases; four origin/malformed-request cases
returned 503 instead of 403/400. The HTTP gate remains open.
Container workflow `36656043783` built `32d9ef4` but failed
isolated storage/HTTP verification; that image was not published and
the public job exposes no assertion log. The next checkpoint
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status
## 30 September 2026 — Semuta after printed Karama normal-auction purchase
The explicit Richese development preview now offers a neutral Semuta
response after a clean printed Karama pays its public normal-auction
purchase cost but before the reserved lot settles. A committed holder
may exchange one physical Semuta for that newly spent card; all-seat
decline leaves it in the discard. Both paths settle the original free
purchase once, with the lot, round, cache, deck and hand custody bound
to the saved receipt. A pending Richese cache offer after the normal
auction remains owed. Richese lots, paid Karama bids, Worthless
conversion, shipping and nested responses retain their automatic paths.
Normal expansion starts remain gated.
Engine and authenticated SQLite tests cover the neutral offer, legal
AI claim, reserved lot, spent-card custody, JSON/restart continuation,
Richese cache suffix, stale exact retry and rejected altered receipts.
An isolated engine smoke completed the purchase after the Semuta claim,
placing the lot once, exchanging the printed card and closing Bidding.
The wider Semuta discard inventory and deployed acceptance remain open.
`npm run check` passed types, lint and 5,835 offline tests; the
production build passed. The isolated built Wrangler worker rendered
the revised Semuta guide on a phone-width browser. Its HTTP integration
suite passed 51/55 cases, including normal lobby and saved-seat flows;
four existing origin and malformed-request cases received HTTP 503
instead of their expected 403/400. Those failures are not attributed
to this purchase path, and the HTTP gate is **not green**. A separate
standard Vite server startup timed out before it could host a rerun.
No production image or game database was changed.
## 30 September 2026 — Owner-only manual integrity check
Administration → Operations now has a separate **Run integrity check**
+8 -1
View File
@@ -54,7 +54,14 @@ card cost but before accepted cancellation. The first two retain
their existing saved receipt signatures. Each binds the sole
suspended response, spent card and hand/spice custody; claim or
decline restores that response and finishes cancellation once.
Other Karama forms and nested responses remain automatic.
Other cancellations and shipping retain their automatic paths.
Clean printed Karama normal-auction purchase now stages
`karamaPurchaseDiscard` after the public cost and before the reserved
lot is settled. The receipt binds the original lot, order, deck and
hands; a neutral claim or decline completes the same free purchase
once. Richese lots, paid auction Karama and nested responses retain
their previous automatic paths.
Completed Ornithopter movement now offers the same retired public card
before its already committed arrival suffix only in a roster without
+10 -3
View File
@@ -67,7 +67,8 @@ opening public expansion starts. It interrupts **clean public
ordinary-card, paid Box, retired Ornithopter, used Distrans, final
Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison,
printed Karama canceling clean CHOAM Charity, Inflation or Bene
Gesserit Charity, battle and Ix-deck Thumper/Amal discards**. Other
Gesserit Charity or buying a clean normal-auction lot, battle and
Ix-deck Thumper/Amal discards**. Other
than the one explicitly suspended charity/Inflation response, the
parent transaction has no competing response. Every seat sees the
same neutral fresh-discard opportunity
@@ -100,8 +101,14 @@ its accepted cancellation runs. The sole suspended response is bound
to the saved receipt; claim or decline resumes the same cancellation
once. CHOAM income or BG's extra allowance is withheld, while
Inflation retains its spent placement attempt without a token.
Worthless conversion, other cancellations, shipping, purchases and
nested Karama responses retain their separate automatic paths.
Worthless conversion, other cancellations, shipping, auction payment
and nested Karama responses retain their separate automatic paths.
A clean printed Karama purchase of an unresolved normal auction lot
also pauses after its public card cost. The lot, deck and seating order
remain bound while every seat sees the neutral offer. Claim or decline
settles that same free purchase once; no second bid, payment or draw
precedes the offer. Richese lots and other Karama forms are unchanged.
For a genuine Ix-deck Thumper, its played physical card reaches
the discard before an injected worm or actual spice-card draw.
+62 -3
View File
@@ -1198,6 +1198,13 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'karamaPurchaseDiscard';
player: string;
card: string;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'thumperDiscard';
player: string;
@@ -4885,6 +4892,24 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
})),
});
}
type KaramaPurchaseDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'karamaPurchaseDiscard' }
>;
function karamaPurchaseDiscardSignature(g: Game, c: KaramaPurchaseDiscardContinuation) {
return JSON.stringify({
player: c.player, card: c.card,
parent: nullentropyParentSignature(g, c.resume, `karama:purchase:${c.card}`),
turn: g.turn, phase: g.phase, active: g.active, auction: g.auction,
richeseBidding: g.richeseBidding, richeseFunding: g.richeseFunding,
richeseCache: g.richeseCache?.map(card => card.id),
deck: g.deck.map(card => card.id), 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' }
@@ -5475,6 +5500,20 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === karamaResponseDiscardSignature(g, c),
'The spent Karama no longer matches its suspended charity or Inflation cancellation.',
);
} else if (continuation?.kind === 'karamaPurchaseDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
g.phase === 3 && !g.response && !g.decision && !g.pendingKarama &&
!g.phaseOpening && !c.resume.response && !c.resume.decision &&
!c.resume.pendingKarama && !c.resume.phaseOpening &&
batch.cause === 'karama:purchase' && 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 === karamaPurchaseDiscardSignature(g, c),
'The spent Karama no longer matches its reserved auction purchase.',
);
normalKaramaAuction(g, getPlayer(g, c.player), false);
} else if (continuation?.kind === 'thumperDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
@@ -6135,6 +6174,7 @@ function semutaOfferSupported(
continuation.kind !== 'karamaCharityDiscard' &&
continuation.kind !== 'karamaInflationDiscard' &&
continuation.kind !== 'karamaBgCharityDiscard' &&
continuation.kind !== 'karamaPurchaseDiscard' &&
continuation.kind !== 'thumperDiscard' &&
continuation.kind !== 'amalDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
@@ -6208,7 +6248,10 @@ function semutaOfferSupported(
(continuation.kind === 'amalDiscard'
? continuation.resume.phaseOpening?.passed.length === 0
: !continuation.resume.phaseOpening)))) &&
!g.karamaShipping && !g.auction &&
!g.karamaShipping &&
(continuation.kind === 'karamaPurchaseDiscard'
? !!g.auction && !g.currentAuctionSale && !g.richeseAuction
: !g.auction) &&
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
(continuation.kind === 'saphoAggressorDiscard' ||
continuation.kind === 'residualPoisonDiscard' || !g.battle) &&
@@ -6412,12 +6455,14 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player);
} else if (next.kind === 'karamaCharityDiscard' ||
next.kind === 'karamaInflationDiscard' ||
next.kind === 'karamaBgCharityDiscard') {
next.kind === 'karamaBgCharityDiscard' ||
next.kind === 'karamaPurchaseDiscard') {
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);
completeKarama(g, getPlayer(g, next.player),
next.kind === 'karamaPurchaseDiscard' ? { kind: 'purchase' } : next.use);
} else if (next.kind === 'amalDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -10787,6 +10832,20 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) {
return;
}
}
if (g.semutaPreview && card.effect === 'karama' && use.kind === 'purchase') {
const continuation: KaramaPurchaseDiscardContinuation = {
kind: 'karamaPurchaseDiscard', player: p.id, card: used.id,
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)) {
continuation.stateSignature = karamaPurchaseDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'karama:purchase', 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. In the explicit Semuta preview, clean printed cancellations of CHOAM Charity, Inflation or Bene Gesserit Charity pause on the public cost before settling that response. Full Richese starts and other Karama-to-Semuta reactions remain unavailable.'
? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, clean printed cancellations of CHOAM Charity, Inflation or Bene Gesserit Charity pause on the public cost before settling that response; a clean printed normal-auction purchase pauses before its reserved lot settles. Full Richese starts and other Karama-to-Semuta reactions 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, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit 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.'
? '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, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase before lot settlement, 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 Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer, then its original response is canceled 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. 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.',
'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 buying a normal auction lot, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer; the original response is canceled or the reserved lot is bought 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. 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, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity, 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, Inflation or Bene Gesserit Charity or clean normal-auction purchase, 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, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity, Ix-deck Thumper/Amal and three battle discard stages. Other Karama 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, 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',
+61
View File
@@ -48,6 +48,67 @@ function fixture(ix = false) {
return { g, semuta, hajr };
}
void test('printed Karama purchase offers its spent card before one normal auction settlement', () => {
const { g, semuta, hajr } = fixture();
player(g, 'a').hand = [take(g, 'karama')];
g.deck.push(hajr);
const karama = player(g, 'a').hand[0];
const lot = g.deck.shift()!;
Object.assign(g, { phase: 3, active: 'a', order: ['a', 'e', 'r'],
movementRemaining: [], auction: { cards: [lot], index: 0, bid: 0,
bidder: null, active: 'a', passed: [], opener: 0 } });
g.richeseBidding = { owner: 'r', event: 'richese-normal:2', turn: g.turn,
stage: 'normal', position: 'last', normalCount: 1,
blackMarketSold: false, cacheCanceled: false, opener: 0 };
g.richeseFunding = {};
const physical = (state: Game) => [
...inventory(state), ...(state.auction?.cards.slice(state.auction.index) ?? []).map(card => card.id),
].sort();
const original = physical(g);
const pending = applyAction(g, 'a', { type: 'card', card: karama.id, mode: 'purchase' });
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaPurchaseDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.auction?.cards[0].id, lot.id);
assert.equal(pending.auction?.bidder, null);
assert.equal(player(pending, 'a').hand.some(card => card.id === lot.id), false);
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
for (const mutate of [
(state: Game) => { state.auction!.cards[0].name = 'Forged lot'; },
(state: Game) => { state.richeseBidding!.position = 'first'; },
(state: Game) => { state.players[1].spice += 1; },
(state: Game) => {
const c = state.pendingTreacheryDiscard!.continuation;
if (c.kind === 'karamaPurchaseDiscard') c.stateSignature = 'wrong';
},
]) {
const altered = reload(pending);
mutate(altered);
const before = JSON.stringify(altered);
assert.throws(() => applyAction(altered, 'r', { type: 'semutaCommit', event }));
assert.equal(JSON.stringify(altered), before);
}
let declined = reload(pending);
for (const id of ['a', 'r', 'e'])
declined = applyAction(declined, id, { type: 'semutaPass', event });
const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!);
for (const state of [declined, claimed]) {
assert.equal(state.pendingTreacheryDiscard, null);
assert.equal(player(state, 'a').hand.filter(card => card.id === lot.id).length, 1);
assert.equal(state.auction, null);
assert.equal(state.phase, 3);
assert.equal(state.richeseBidding?.stage, 'cacheOffer');
assert.equal(state.decision?.kind, 'richeseCache');
assert.deepEqual(physical(state), original);
assert.throws(() => applyAction(state, 'r', { type: 'semutaCommit', event }));
}
assert.equal(declined.discard.filter(card => card.id === karama.id).length, 1);
assert.equal(player(claimed, 'r').hand.filter(card => card.id === karama.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(player(claimed, 'a').spice, player(declined, 'a').spice);
});
void test('a clean public discard offers every seat the same event, then a committed sole Semuta claim resumes once across JSON', () => {
const { g, semuta, hajr } = fixture();
const original = inventory(g);
+63
View File
@@ -728,6 +728,69 @@ void test('authenticated pre-plan Sapho discard restores one aggressor choice be
} finally { f.sqlite.close(); }
});
void test('authenticated printed Karama purchase preserves the lot across a saved Semuta claim', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Purchase', 'richese', false, ['choam']);
const code = made.view.code;
const buyer = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
const third = await f.rooms.joinRoom(code, 'Emperor', 'emperor');
const auths = await Promise.all([made.token!, buyer.token!, third.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
const g = createGame(code, newPlayer(auths[0].playerId, 'Richese', 'richese'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Atreides', 'atreides'),
newPlayer(auths[2].playerId, 'Emperor', 'emperor'));
Object.assign(g, { status: 'playing', phase: 3, active: auths[1].playerId,
order: auths.map(auth => auth.playerId), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true });
for (const p of g.players) {
p.hand = [];
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const karama = take(g, 'karama'), semuta = take(g, SEMUTA_DRUG_ID);
const lot = g.deck.shift()!;
seat(g, auths[1].playerId).hand.push(karama);
seat(g, auths[0].playerId).hand.push(semuta);
g.auction = { cards: [lot], index: 0, bid: 0, bidder: null,
active: auths[1].playerId, passed: [], opener: 0 };
g.richeseBidding = { owner: auths[0].playerId, event: 'richese-normal:1',
turn: g.turn, stage: 'normal', position: 'last', normalCount: 1,
blackMarketSold: false, cacheCanceled: false, opener: 0 };
g.richeseFunding = {};
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();
await f.restart().act(code, auths[1], g.version,
{ type: 'card', card: karama.id, mode: 'purchase' }, clock);
const pending = await f.restart().readRoom(code);
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.auction?.cards[0].id, lot.id);
assert.equal(seat(pending, auths[1].playerId).hand.some(card => card.id === lot.id), false);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, pending.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const event = pending.pendingTreacheryDiscard!.batch.event;
await f.restart().act(code, auths[0], pending.version,
{ type: 'semutaCommit', event }, clock);
const claimed = await f.restart().readRoom(code);
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(seat(claimed, auths[1].playerId).hand.filter(card => card.id === lot.id).length, 1);
assert.equal(seat(claimed, auths[0].playerId).hand.filter(card => card.id === karama.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(claimed.auction, null);
assert.equal(claimed.richeseBidding?.stage, 'cacheOffer');
assert.equal(claimed.decision?.kind, 'richeseCache');
await assert.rejects(f.restart().act(code, auths[0], pending.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 printed Karama cost pauses and restores one canceled CHOAM Charity response', async () => {
const f = unitStore();
try {