Offer Semuta after printed Karama auction payment

This commit is contained in:
Quintine committed 2026-09-30 13:47:37 +10:00
1 parent eede77a3e3
commit 8f6b1c8463
8 files changed
+230 -47

No files matched your search

+17 -10
View File
@@ -12,11 +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 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.
CHOAM Charity, Inflation or Bene Gesserit Charity or using a clean
normal-auction lot for a free purchase or winning-bid payment,
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 lot 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
@@ -32,11 +32,18 @@ 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.
Clean printed Karama normal-auction purchase and winning-bid payment
passed engine and authenticated SQLite checks. `npm run check` passed
types, lint and 5,837 offline tests; the build and phone-width guide
inspection passed. A focused built-worker Advanced-lobby HTTP test
timed out at its 15-second request limit; direct QA-room create/join
and two authenticated reads passed. The previous purchase build's
full HTTP suite passed 51/55 cases; four origin/malformed-request
cases returned 503 instead of 403/400. The full HTTP gate remains open.
The previous purchase revision `eede77a` passed container workflow
`36664985601`, including its isolated storage/HTTP gate, and published
an image. This does not deploy it to the live NAS.
Container workflow `36656043783` built `32d9ef4` but failed
isolated storage/HTTP verification; that image was not published and
+32
View File
@@ -1,5 +1,37 @@
# Dune implementation status
## 30 September 2026 — Semuta after printed Karama winning-bid payment
The explicit Richese preview now stages a second normal-auction
Karama discard when the winning bidder chooses its printed card
instead of a spice payment. The bid and bidder are already fixed;
the card enters the public pile before the same neutral Semuta offer.
Claim or all-seat decline settles the reserved lot once, without
charging bid spice, and preserves the following Richese cache offer.
The saved receipt binds the bid winner, round, cache, deck and hand
custody. Richese lots, Worthless conversion and nested responses stay
outside this bounded reaction.
Engine and authenticated SQLite tests cover the winning decision,
full bid above available spice, legal AI claim, JSON/restart
continuation, forged bidder/round rejection, exact stale retry,
physical card custody and the following cache choice. A direct engine
smoke settled the printed card, lot and cache sequence. Public
expansion starts and complete Semuta coverage remain gated.
`npm run check` passed types, lint and 5,837 offline tests; the
production build passed. A phone-width browser view of the rules
guide visibly included the winning-bid timing. Independent review
found no scoped defect. The prior purchase checkpoint passed its
container workflow's isolated storage/HTTP check and published an
image; it is not deployed to the NAS. On this paid-payment build, a
focused built-worker HTTP Advanced-lobby test timed out at its
15-second request limit; a separate direct QA-room create/join and
two authenticated seat reads returned 201/200/200/200 with distinct
seat identities. The prior full local HTTP suite passed 51/55;
four origin/malformed-request cases returned 503 instead of 403/400.
Full HTTP acceptance remains open. No production game was touched.
## 30 September 2026 — Semuta after printed Karama normal-auction purchase
The explicit Richese development preview now offers a neutral Semuta
+7 -6
View File
@@ -56,12 +56,13 @@ suspended response, spent card and hand/spice custody; claim or
decline restores that response and finishes cancellation once.
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.
Clean printed Karama normal-auction purchase or winning-bid payment
stages `karamaPurchaseDiscard` or `karamaPaymentDiscard` after the
public cost and before the reserved lot settles. The receipt binds
the original lot, round, cache, order, deck, hands and bid winner;
a neutral claim or decline completes the same free settlement once.
Richese lots, Worthless conversion 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
+9 -7
View File
@@ -67,8 +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 or buying a clean normal-auction lot, battle and
Ix-deck Thumper/Amal discards**. Other
Gesserit Charity or settling a clean normal-auction purchase or
winning bid, 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
@@ -101,14 +101,16 @@ 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, auction payment
and nested Karama responses retain their separate automatic paths.
Worthless conversion, other cancellations, shipping 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
or free payment of an already won bid pauses after its public card
cost. The lot, bidder, bid, Richese round, 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.
settles that same free transaction once; no second bid, spice payment
or draw precedes the offer. Richese lots and other Karama forms remain
unchanged.
For a genuine Ix-deck Thumper, its played physical card reaches
the discard before an injected worm or actual spice-card draw.
+27 -19
View File
@@ -1199,7 +1199,7 @@ export type Game = {
stateSignature: string;
}
| {
kind: 'karamaPurchaseDiscard';
kind: 'karamaPurchaseDiscard' | 'karamaPaymentDiscard';
player: string;
card: string;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
@@ -4892,14 +4892,14 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
})),
});
}
type KaramaPurchaseDiscardContinuation = Extract<
type KaramaAuctionDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'karamaPurchaseDiscard' }
{ kind: 'karamaPurchaseDiscard' | 'karamaPaymentDiscard' }
>;
function karamaPurchaseDiscardSignature(g: Game, c: KaramaPurchaseDiscardContinuation) {
function karamaAuctionDiscardSignature(g: Game, c: KaramaAuctionDiscardContinuation) {
return JSON.stringify({
player: c.player, card: c.card,
parent: nullentropyParentSignature(g, c.resume, `karama:purchase:${c.card}`),
parent: nullentropyParentSignature(g, c.resume, `karama:${c.kind === 'karamaPaymentDiscard' ? 'auctionPayment' : '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),
@@ -5500,20 +5500,22 @@ 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') {
} else if (continuation?.kind === 'karamaPurchaseDiscard' ||
continuation?.kind === 'karamaPaymentDiscard') {
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 &&
batch.cause === (c.kind === 'karamaPaymentDiscard' ? 'karama:auctionPayment' : '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.',
c.stateSignature === karamaAuctionDiscardSignature(g, c),
'The spent Karama no longer matches its reserved auction settlement.',
);
normalKaramaAuction(g, getPlayer(g, c.player), false);
normalKaramaAuction(g, getPlayer(g, c.player), c.kind === 'karamaPaymentDiscard');
} else if (continuation?.kind === 'thumperDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
@@ -6175,6 +6177,7 @@ function semutaOfferSupported(
continuation.kind !== 'karamaInflationDiscard' &&
continuation.kind !== 'karamaBgCharityDiscard' &&
continuation.kind !== 'karamaPurchaseDiscard' &&
continuation.kind !== 'karamaPaymentDiscard' &&
continuation.kind !== 'thumperDiscard' &&
continuation.kind !== 'amalDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
@@ -6249,7 +6252,8 @@ function semutaOfferSupported(
? continuation.resume.phaseOpening?.passed.length === 0
: !continuation.resume.phaseOpening)))) &&
!g.karamaShipping &&
(continuation.kind === 'karamaPurchaseDiscard'
(continuation.kind === 'karamaPurchaseDiscard' ||
continuation.kind === 'karamaPaymentDiscard'
? !!g.auction && !g.currentAuctionSale && !g.richeseAuction
: !g.auction) &&
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
@@ -6456,13 +6460,15 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
} else if (next.kind === 'karamaCharityDiscard' ||
next.kind === 'karamaInflationDiscard' ||
next.kind === 'karamaBgCharityDiscard' ||
next.kind === 'karamaPurchaseDiscard') {
next.kind === 'karamaPurchaseDiscard' ||
next.kind === 'karamaPaymentDiscard') {
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.kind === 'karamaPurchaseDiscard' ? { kind: 'purchase' } : next.use);
const use: KaramaUse = 'use' in next ? next.use :
{ kind: next.kind === 'karamaPurchaseDiscard' ? 'purchase' : 'auctionPayment' };
completeKarama(g, getPlayer(g, next.player), use);
} else if (next.kind === 'amalDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -10832,17 +10838,19 @@ 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,
if (g.semutaPreview && card.effect === 'karama' &&
(use.kind === 'purchase' || use.kind === 'auctionPayment')) {
const continuation: KaramaAuctionDiscardContinuation = {
kind: use.kind === 'purchase' ? 'karamaPurchaseDiscard' : 'karamaPaymentDiscard',
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);
continuation.stateSignature = karamaAuctionDiscardSignature(g, continuation);
stageTreacheryDiscard(g, `karama:${use.kind}`, entries, continuation);
return;
}
}
+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; a clean printed normal-auction purchase pauses before its reserved lot settles. 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 or winning-bid payment 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, 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.'
? '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 or winning-bid payment 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 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.',
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, 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 settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. 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 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.',
'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 or winning-bid payment, 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 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.',
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, 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',
+62
View File
@@ -48,6 +48,68 @@ function fixture(ix = false) {
return { g, semuta, hajr };
}
void test('printed Karama auction payment offers its card before the winning lot settles', () => {
const { g, semuta, hajr } = fixture();
player(g, 'a').hand = [take(g, 'karama')];
g.deck.push(hajr);
const karama = player(g, 'a').hand[0], 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 startingSpice = player(g, 'a').spice;
const original = [...inventory(g), lot.id].sort();
let bidding = applyAction(g, 'a', { type: 'bid', amount: startingSpice + 2 });
for (const id of ['e', 'r'])
bidding = applyAction(bidding, id, { type: 'passBid' });
assert.equal(bidding.decision?.kind, 'auctionPayment');
assert.equal(bidding.auction?.bidder, 'a');
const pending = applyAction(bidding, 'a',
{ type: 'decision', karama: true, card: karama.id });
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaPaymentDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(player(pending, 'a').spice, startingSpice);
assert.equal(player(pending, 'a').hand.some(card => card.id === lot.id), false);
assert.equal(pending.auction?.bidder, 'a');
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
for (const mutate of [
(state: Game) => { state.auction!.bidder = 'e'; },
(state: Game) => { state.richeseBidding!.position = 'first'; },
(state: Game) => {
const c = state.pendingTreacheryDiscard!.continuation;
if (c.kind === 'karamaPaymentDiscard') 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(state.auction, null);
assert.equal(state.richeseBidding?.stage, 'cacheOffer');
assert.equal(state.decision?.kind, 'richeseCache');
assert.equal(player(state, 'a').spice, startingSpice);
assert.equal(player(state, 'a').hand.filter(card => card.id === lot.id).length, 1);
assert.deepEqual([...inventory(state)].sort(), 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);
});
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')];
+71
View File
@@ -728,6 +728,77 @@ void test('authenticated pre-plan Sapho discard restores one aggressor choice be
} finally { f.sqlite.close(); }
});
void test('authenticated printed Karama auction payment resumes one saved Richese cache offer', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Payment', '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();
const act = async (index: number, action: Action) => {
const state = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], state.version, action, clock);
return f.restart().readRoom(code);
};
const initialSpice = seat(g, auths[1].playerId).spice;
await act(1, { type: 'bid', amount: initialSpice + 2 });
await act(2, { type: 'passBid' });
const payment = await act(0, { type: 'passBid' });
assert.equal(payment.decision?.kind, 'auctionPayment');
const pending = await act(1, { type: 'decision', karama: true, card: karama.id });
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaPaymentDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.auction?.cards[0].id, lot.id);
assert.equal(seat(pending, auths[1].playerId).spice, initialSpice);
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;
const claimed = await act(0, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.auction, null);
assert.equal(claimed.richeseBidding?.stage, 'cacheOffer');
assert.equal(claimed.decision?.kind, 'richeseCache');
assert.equal(seat(claimed, auths[1].playerId).spice, initialSpice);
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);
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 purchase preserves the lot across a saved Semuta claim', async () => {
const f = unitStore();
try {