Offer Semuta before private Ixian ally replacement

This commit is contained in:
Quintine committed 2026-09-30 14:08:48 +10:00
1 parent 8f6b1c8463
commit 9bfc3ec037
8 files changed
+246 -34

No files matched your search

+24 -16
View File
@@ -9,14 +9,15 @@ Expansion starts and full rules/publication acceptance remain gated.**
The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-september-2026--bounded-clean-discard-runtime)
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, printed Karama canceling clean
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.
Ornithopter, used Distrans, final Truthtrance, four bounded Sapho,
pre-leader Residual Poison, printed Karama canceling clean CHOAM
Charity, Inflation or Bene Gesserit Charity or settling a clean
normal-auction lot, battle-card and Ix-deck Thumper/Amal discards
open the same neutral offer regardless of hidden Semuta custody.
A clean **private** normal-auction Ixian ally replacement discard
also pauses before its draw; no unknown face is shown before a
holder commits. 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,19 +33,26 @@ full-hand ordering, normal Richese starts and deployed acceptance
remain unfinished. Neutral passes and free-slot guard are product
choices.
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 private normal-auction Ixian ally reaction passed genuine-setup
engine, authenticated SQLite and direct engine smoke checks. The full
`npm run check` passed types, lint and 5,839 offline tests; build and
phone-width guide inspection passed. The isolated built-worker HTTP
suite passed 49/55 tests: five returned 503 instead of expected
authorization/validation/success statuses, and one Advanced-lobby
request exceeded its 15-second limit. Full HTTP acceptance remains open.
The previous printed Karama purchase/payment checkpoints passed
engine and authenticated SQLite checks; their local HTTP caveats
are recorded in the implementation log.
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.
The following paid Karama revision `8f6b1c8` also passed container
workflow `36665999972` through isolated storage/HTTP verification
and published its image. Neither checkpoint has been deployed to the NAS.
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 before private Ixian ally replacement
The explicit Richese preview with the Ix expansion now interrupts a
clean normal-auction Ixian ally card replacement at its original
private discard, before the replacement draw. The buyer paid for the
lot and accepted the alliance; every seat sees the same neutral
Semuta offer without its card face or a named claimant. A committed
holder can take the exact fresh card; claim or all-seat decline
then draws one replacement for the buyer and finishes the saved
sale/Richese cache continuation. The original buyer is the
discarder, not the Ixians. Cache and Black Market sales, nested
interactions and other private discard producers remain guarded.
An engine scenario through genuine three-seat setup checked hidden
Semuta-holder parity in both outsider views, unspent deck order,
physical custody, one replacement and legal AI claim. An authenticated
SQLite scenario checked restart, owner-only controls, stale retry and
the owed Richese cache choice. A separate direct engine smoke
exercised a paid lot, one private reaction and the replacement.
Normal expansion starts and full Semuta acceptance remain gated.
`npm run check` passed types, lint and 5,839 offline tests, and the
production build passed. A phone-width browser view of the built
worker showed the private-discard timing in the rules guide.
Independent review found no scoped defect. The isolated built-worker
HTTP suite passed 49/55; five origin/validation or ordinary-success
requests returned 503 and one Advanced-lobby request timed out at
15 seconds. Full HTTP acceptance remains open. This code was not
deployed to the NAS and no production game was modified.
## 30 September 2026 — Semuta after printed Karama winning-bid payment
The explicit Richese preview now stages a second normal-auction
+9
View File
@@ -64,6 +64,15 @@ a neutral claim or decline completes the same free settlement once.
Richese lots, Worthless conversion and nested responses retain their
previous automatic paths.
A clean private normal-auction Ixian ally replacement now stages the
existing `ixAllyCard` continuation as a neutral Semuta offer before
`draw(g)`. The original buyer owns the discarded card. Its face stays
unknown to rivals and uncommitted holders; a sole committed claim
moves that exact card before one saved replacement draw and ordinary
sale continuation. Richese cache and Black Market sales retain the
automatic path until their distinct custody and offer windows are
integrated.
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
+11 -2
View File
@@ -67,12 +67,21 @@ 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 settling a clean normal-auction purchase or
winning bid, battle and Ix-deck Thumper/Amal discards**. Other
Gesserit Charity or settling a clean normal-auction lot, battle and
Ix-deck Thumper/Amal discards**, plus a clean **private** normal-auction
Ixian ally replacement discard before its draw. Other
than the one explicitly suspended charity/Inflation response, the
parent transaction has no competing response. Every seat sees the
same neutral fresh-discard opportunity
regardless of hidden Semuta custody.
The Ixian ally discard belongs to the original buyer, not the Ixians.
Every seat sees the neutral offer without its face or a named claimant;
only a committed eligible holder takes the exact fresh card. A claim
removes it before the saved single replacement draw and subsequent
normal-auction settlement. Richese cache and Black Market replacement
discards, competing parents and other private producers remain separate.
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
+16 -9
View File
@@ -6180,6 +6180,7 @@ function semutaOfferSupported(
continuation.kind !== 'karamaPaymentDiscard' &&
continuation.kind !== 'thumperDiscard' &&
continuation.kind !== 'amalDiscard' &&
continuation.kind !== 'ixAllyCard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
@@ -6227,11 +6228,13 @@ function semutaOfferSupported(
return g.semutaPreview === true && g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') &&
entries.length > 0 &&
(continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved'
? entries.every(entry => entry.publicFace)
: entries.length === 1 && entries[0].publicFace) &&
(continuation.kind === 'ixAllyCard'
? entries.length === 1 && !entries[0].publicFace
: continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved'
? entries.every(entry => entry.publicFace)
: entries.length === 1 && entries[0].publicFace) &&
(continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard' ||
continuation.kind === 'karamaBgCharityDiscard'
@@ -6243,6 +6246,7 @@ function semutaOfferSupported(
(continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved' ||
continuation.kind === 'ixAllyCard' ||
(continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard' ||
continuation.kind === 'karamaBgCharityDiscard' ||
@@ -6252,10 +6256,13 @@ function semutaOfferSupported(
? continuation.resume.phaseOpening?.passed.length === 0
: !continuation.resume.phaseOpening)))) &&
!g.karamaShipping &&
(continuation.kind === 'karamaPurchaseDiscard' ||
continuation.kind === 'karamaPaymentDiscard'
? !!g.auction && !g.currentAuctionSale && !g.richeseAuction
: !g.auction) &&
(continuation.kind === 'ixAllyCard'
? !!g.auction && g.currentAuctionSale?.origin === 'normal' &&
continuation.sale.origin === 'normal' && !g.richeseAuction
: continuation.kind === 'karamaPurchaseDiscard' ||
continuation.kind === 'karamaPaymentDiscard'
? !!g.auction && !g.currentAuctionSale && !g.richeseAuction
: !g.auction) &&
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
(continuation.kind === 'saphoAggressorDiscard' ||
continuation.kind === 'residualPoisonDiscard' || !g.battle) &&
+4 -4
View File
@@ -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 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.'
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.'
: 'Reference component only: this Richese card’s game actions are not enabled.',
],
...(definition.card.effect === 'semutaDrug'
@@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const,
status: 'Partial' as const,
detail:
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, 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.',
'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. A private normal-auction Ixian ally replacement also pauses before its draw with a neutral, face-hidden offer. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, private discards, nested responses, pending rewards and full-hand exchange remain unfinished.',
},
{
area: 'Player controls' as const,
@@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Documentation' as const,
status: 'Partial' as const,
detail:
'The guide distinguishes clean 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.',
'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
},
{
area: 'Verification' as const,
status: 'Partial' as const,
detail:
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, 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, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, pending rewards, full games and live deployment remain unverified.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',
+71 -2
View File
@@ -22,10 +22,11 @@ function inventory(g: Game) {
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function fixture(ix = false) {
function fixture(ix = false, ixianAlly = false) {
let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), false, ix ? ['choam', 'ix'] : ['choam']);
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
joinGame(g, newPlayer('e', ixianAlly ? 'Ixians' : 'Emperor',
ixianAlly ? 'ixians' : 'emperor'));
for (const p of g.players) { p.bot = 'Medium'; p.ready = true; }
g = initializeSemutaGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 80; step++) {
@@ -48,6 +49,74 @@ function fixture(ix = false) {
return { g, semuta, hajr };
}
void test('private Ixian ally discard offers Semuta before replacement without revealing its face', () => {
const { g, semuta, hajr } = fixture(true, true);
player(g, 'a').hand = [];
g.deck.push(hajr);
player(g, 'a').ally = 'e';
player(g, 'e').ally = 'a';
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 absent = reload(g);
player(absent, 'r').hand = [take(absent, 'richese-distrans')];
absent.richeseCache!.push(semuta);
const original = [...inventory(g), lot.id].sort();
const offer = (state: Game) => {
let next = applyAction(state, 'a', { type: 'bid', amount: 2 });
for (const id of ['e', 'r']) next = applyAction(next, id, { type: 'passBid' });
if (next.decision?.kind === 'auctionPayment')
next = applyAction(next, 'a', { type: 'decision', karama: false });
assert.equal(next.decision?.kind, 'ixAllyCard');
next = applyAction(next, 'a', { type: 'decision', accept: true });
for (let i = 0; next.response?.kind === 'ixAllyCard' && i < 3; i++) {
const other = next.players.find(p => !next.response!.passed.includes(p.id))!;
next = applyAction(next, other.id, { type: 'passResponse' });
}
return next;
};
const pending = offer(g), neutral = offer(absent);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'ixAllyCard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.pendingTreacheryDiscard?.batch.entries[0].publicFace, false);
assert.equal(pending.discard.filter(card => card.id === lot.id).length, 1);
assert.equal(pending.deck[0].id, neutral.deck[0].id);
assert.equal(player(pending, 'a').hand.some(card => card.id === lot.id), false);
assert.deepEqual(viewGame(pending, 'a'), viewGame(neutral, 'a'));
assert.deepEqual(viewGame(pending, 'e'), viewGame(neutral, 'e'));
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
const corrupted = reload(pending);
corrupted.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forged face';
const before = JSON.stringify(corrupted);
assert.throws(() => applyAction(corrupted, 'r', { type: 'semutaCommit', event }));
assert.equal(JSON.stringify(corrupted), before);
let declined = reload(pending);
for (const id of ['a', 'e', 'r'])
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.deepEqual(inventory(state), original);
assert.equal(player(state, 'a').spice, player(pending, 'a').spice);
assert.equal(player(state, 'a').hand.length, 1);
assert.throws(() => applyAction(state, 'r', { type: 'semutaCommit', event }));
}
assert.equal(declined.discard.filter(card => card.id === lot.id).length, 1);
assert.equal(player(claimed, 'r').hand.filter(card => card.id === lot.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
});
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')];
+81 -1
View File
@@ -4,7 +4,7 @@ import { startPrototypeRoom } from '../tools/prototype-room';
import { botActions } from '../game/bots';
import { applyAction, createGame, newPlayer, viewGame, type Game, type Action } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { baseDeck, spiceDeck } from '../game/cards';
import { baseDeck, spiceDeck, treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { unitStore } from './fixture-nexus-room-store';
import { saphoAggressorGame, aggressorAction } from './fixture-sapho-aggressor';
@@ -728,6 +728,86 @@ void test('authenticated pre-plan Sapho discard restores one aggressor choice be
} finally { f.sqlite.close(); }
});
void test('authenticated private Ixian ally discard waits for Semuta before its replacement', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Ix ally', 'richese', false, ['choam', 'ix']);
const code = made.view.code;
const buyer = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
const ixians = await f.rooms.joinRoom(code, 'Ixians', 'ixians');
const auths = await Promise.all([made.token!, buyer.token!, ixians.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', 'ix']);
g.players.push(newPlayer(auths[1].playerId, 'Atreides', 'atreides'),
newPlayer(auths[2].playerId, 'Ixians', 'ixians'));
Object.assign(g, { status: 'playing', phase: 3, active: auths[1].playerId,
order: auths.map(auth => auth.playerId), deck: treacheryDeck(['ix']),
richeseCache: richeseCards(), semutaPreview: true });
for (const p of g.players) {
p.hand = [];
p.spice = 20;
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const semuta = take(g, SEMUTA_DRUG_ID), lot = g.deck.shift()!;
seat(g, auths[0].playerId).hand.push(semuta);
seat(g, auths[1].playerId).ally = auths[2].playerId;
seat(g, auths[2].playerId).ally = auths[1].playerId;
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 current = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], current.version, action, clock);
return f.restart().readRoom(code);
};
await act(1, { type: 'bid', amount: 2 });
await act(2, { type: 'passBid' });
let next = await act(0, { type: 'passBid' });
if (next.decision?.kind === 'auctionPayment')
next = await act(1, { type: 'decision', karama: false });
assert.equal(next.decision?.kind, 'ixAllyCard');
next = await act(1, { type: 'decision', accept: true });
for (let i = 0; next.response?.kind === 'ixAllyCard' && i < 3; i++) {
const index = auths.findIndex(auth => !next.response!.passed.includes(auth.playerId));
next = await act(index, { type: 'passResponse' });
}
const event = next.pendingTreacheryDiscard!.batch.event;
assert.equal(next.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(next.pendingTreacheryDiscard?.batch.entries[0].publicFace, false);
assert.equal(next.discard.filter(card => card.id === lot.id).length, 1);
const deckTop = next.deck[0]?.id;
const buyerView = await f.restart().readSeatView(code, auths[1]);
assert.equal(buyerView.semutaReaction?.canCommit, false);
assert.deepEqual(buyerView.semutaReaction?.candidates, []);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, next.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
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[0].playerId).hand.filter(card => card.id === lot.id).length, 1);
assert.equal(seat(claimed, auths[1].playerId).hand.filter(card => card.id === deckTop).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
await assert.rejects(f.restart().act(code, auths[0], next.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 auction payment resumes one saved Richese cache offer', async () => {
const f = unitStore();
try {