Offer Semuta after clean Ix-deck Amal discard

This commit is contained in:
Quintine committed 2026-09-30 10:25:04 +10:00
1 parent f0eafc175e
commit dffbe7b9f7
9 files changed
+249 -26

No files matched your search

+12 -12
View File
@@ -10,19 +10,19 @@ The explicit [Semuta development preview](SEMUTA_SOURCE_UPDATE_20260907.md#30-se
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, movement-order
Sapho, battle-card and Ix-deck Thumper discards open the same
Sapho, battle-card and Ix-deck Thumper/Amal discards open the same
neutral offer regardless of hidden Semuta custody. Thumper pauses
before its injected worm or spice draw; claim or decline resumes
one saved Spice Blow. Mixed-owner battle cards require a committed,
owner-only selection of another player's freshly discarded card.
Mandatory and optional winner cleanup remain separate events.
Casualties, flight arrival, private gifts, answer history and Sapho
order resume without replay. Minimal legal AI, exact JSON/SQLite
continuation and competing-room CAS are connected. Mixed arrivals,
auction/battle Sapho, queued or promise-bound Truthtrance, pending
battle rewards, other discard producers, full-hand ordering, normal
Richese starts and deployed acceptance remain unfinished. Neutral
passes and free-slot guard are product safety choices, not rulings.
before its injected worm or spice draw; Amal pauses after halving
personal spice and clearing earlier opening passes, before phase
initialization. Claim or decline resumes each saved suffix once.
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, auction/battle
Sapho, queued or promise-bound Truthtrance, pending rewards, other
discard producers, full-hand ordering, normal Richese starts and
deployed acceptance remain unfinished. Neutral passes and free-slot
guard are product safety choices, not rulings.
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,
+31
View File
@@ -1,5 +1,36 @@
# Dune implementation status
## 30 September 2026 — Semuta after Ix-deck Amal
In the explicit Richese preview with the physical Ix deck, playing
Amal at a clean phase opening now publicly discards its one held
card, halves every faction's available personal spice and resets
earlier opening passes before a neutral all-seat Semuta offer. A
different holder with hand space may claim only that used Amal and
discard Semuta; all-seat decline leaves Amal in the ordinary pile.
The exact phase-opening window then resumes once. No auction card
draw, Bidding prelude or other opening effect runs ahead, and the
same spice is not halved again.
Genuine Ix-deck setup covers prior-pass reset, odd/even spice,
owner-only legal AI claim, all-seat decline, physical card census,
JSON continuation and later Richese Bidding prelude. An isolated
migrated SQLite room restores the paused reaction, commits an
authenticated Amal claim and completes the opening without changing
saved seats or double-paying. Independent rules/continuation and
privacy/recovery reviews found no scoped defect. Nested opening
interactions, other discard producers, full-hand ordering, normal
Richese starts and deployed acceptance remain gated.
A direct production-engine run completed genuine Ix-deck setup,
halved 11/9/7 personal spice to 5/4/3, showed a neutral offer,
let the legal bot recover used Amal and restored cleared opening
passes. Three real ready actions then opened Richese's Bidding
prelude without another halving. The phone-width rules deep link
rendered the boundary as Partial. `npm run check` passed typecheck,
lint and 5,814/5,814 offline cases; `npm run build` and all 55
Vite development HTTP cases passed. No deployed revision was exercised.
## 30 September 2026 — Semuta after Ix-deck Thumper
The explicit Richese Semuta development profile now permits a fresh
+7 -7
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, movement-order Sapho and separate initial/mandatory-winner/optional-winner battle batches. A fresh Richese roster may explicitly include the physical Ix deck, whose Thumper discard pauses before its worm and spice draw. Multiple public battle cards reveal candidates only after holder commitment and transfer one other-player card. Private gifts, unresolved promises, reactive arrivals, auction/battle Sapho 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. Other systems, mobile acceptance, difficulty calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed.
- Semuta Drug has a bounded preview for clean public ordinary-card, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho 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, auction/battle Sapho 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. Other systems, mobile acceptance, difficulty calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed.
The complete integrated validation checkpoint is recorded in [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md).
@@ -113,11 +113,11 @@ opted-in fresh Richese profile, optionally with the physical Ix deck.
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, movement-order Sapho, Ix-deck Thumper and three
battle discard stages. Thumper pauses before injecting its worm or
drawing a spice card; claim or decline resumes once. Multi-owner
batches require committed owner-only selection of another player's
card. Queued questions, promises, reactive arrivals, auction/battle
Sapho, pending battle rewards and other discard families remain
final Truthtrance, movement-order Sapho, Ix-deck Thumper/Amal and
three battle discard stages. Thumper pauses before its injected worm
or spice draw; Amal resumes its already-halved phase opening once.
Multi-owner batches require committed owner-only selection of one
other-player card. Queued questions, promises, reactive arrivals,
auction/battle Sapho, pending battle rewards and other sources stay
unconnected. Full-hand ordering, ten-card inventory, cache custody
and public expansion start gate are unchanged.
+8
View File
@@ -54,6 +54,14 @@ the undrawn decks, previous land/discard, forces and alliance state;
Semuta claim or all-seat decline retires it before one ordinary
Spice Blow suffix. A CHOAM-only preview has no Thumper identity.
The same Ix deck supplies Amal. In a clean phase opening, its
public disposal precedes halving every personal spice amount and
clearing prior opening passes. `amalDiscard` binds original balances,
the halved result and suspended opening; a neutral offer retires
before restoring that opening. Bidding pool draws, phase income and
other opening effects therefore still occur only after everyone
finishes the opening. Nested openings remain unintegrated.
## Main findings
A post-`applyAction` pile diff cannot implement immediate Semuta. Ix Ambassador and Ixian ally replacement discard then draw directly. Ordinary Karama cancellation/purchase can reach replacement, Harkonnen bonus or a new auction pool indirectly. A stopped frame must resume at a named stage, never rerun its original public action.
+6 -1
View File
@@ -65,7 +65,7 @@ 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, movement-order Sapho, battle and Ix-deck Thumper
Truthtrance, movement-order Sapho, 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.
@@ -81,6 +81,11 @@ Semuta claim or all-seat decline resumes the one saved Spice Blow;
it neither injects a second worm nor replays any earlier effect.
Thumper without the Ix deck is not a physical card in this profile.
An Ix-deck Amal is already publicly discarded after halving every
personal spice amount and resetting previous phase-opening passes.
Semuta claim or all-seat decline restores that exact opening; no
auction pool, phase income or other opening work runs ahead or twice.
The first battle mandatory batch may contain public cards belonging
to both combatants. Its accepted result and card custody precede
the neutral offer; casualty allocation and winner cleanup still
+77 -2
View File
@@ -1160,6 +1160,14 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'amalDiscard';
player: string;
card: string;
before: { player: string; spice: number }[];
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'truthtranceDiscard';
consumed: TruthQueueEntry;
@@ -4750,6 +4758,24 @@ function thumperDiscardSignature(g: Game, c: ThumperDiscardContinuation) {
})),
});
}
type AmalDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'amalDiscard' }
>;
function amalDiscardSignature(g: Game, c: AmalDiscardContinuation) {
return JSON.stringify({
player: c.player, card: c.card, before: c.before,
parent: nullentropyParentSignature(g, c.resume, `amal:${c.card}`),
turn: g.turn, phase: g.phase, advanced: g.advanced, expansions: g.expansions,
active: g.active, order: g.order, ready: g.ready, storm: g.storm,
deck: g.deck, richeseCache: g.richeseCache, auction: g.auction,
spice: g.spice, spiceDeck: g.spiceDeck,
players: g.players.map(p => ({
id: p.id, faction: p.faction, spice: p.spice, bribes: p.bribes,
forces: p.forces, reserves: p.reserves, tanks: p.tanks,
})),
});
}
type TruthDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'truthtranceDiscard' }
@@ -5207,6 +5233,29 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === thumperDiscardSignature(g, c),
'The used Thumper no longer matches its undrawn Spice Blow.',
);
} else if (continuation?.kind === 'amalDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
g.expansions.includes('ix') &&
g.players.some(p => p.id === c.player) &&
batch.cause === 'amal' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.name === 'Amal' &&
entry.card.kind === 'special' && entry.card.effect === 'amal' &&
c.resume && !c.resume.response && !c.resume.decision &&
!c.resume.pendingKarama &&
c.resume.phaseOpening &&
Array.isArray(c.resume.phaseOpening.passed) &&
c.resume.phaseOpening.passed.length === 0 &&
typeof c.resume.phaseOpening.initialize === 'boolean' &&
Array.isArray(c.before) && c.before.length === g.players.length &&
c.before.every((row, index) => row.player === g.players[index].id &&
Number.isSafeInteger(row.spice) && row.spice >= 0 &&
g.players[index].spice === Math.floor(row.spice / 2)) &&
c.stateSignature === amalDiscardSignature(g, c),
'The used Amal no longer matches its halved spice and saved phase opening.',
);
suspendedControlsIntegrity(g, c.resume);
} else if (continuation?.kind === 'truthtranceDiscard') {
const c = continuation,
entry = batch.entries[0],
@@ -5823,6 +5872,7 @@ function semutaOfferSupported(
continuation.kind !== 'distransDiscard' &&
continuation.kind !== 'saphoMovementDiscard' &&
continuation.kind !== 'thumperDiscard' &&
continuation.kind !== 'amalDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
@@ -5865,7 +5915,10 @@ function semutaOfferSupported(
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved' ||
(!continuation.resume.response && !continuation.resume.decision &&
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening)) &&
!continuation.resume.pendingKarama &&
(continuation.kind === 'amalDiscard'
? continuation.resume.phaseOpening?.passed.length === 0
: !continuation.resume.phaseOpening))) &&
!g.karamaShipping && !g.auction && !g.richeseAuction && !g.battle &&
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
@@ -6034,6 +6087,11 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
if (g.active !== g.movementRemaining?.[0]) movementTurn(g);
} else if (next.kind === 'amalDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
} else if (next.kind === 'thumperDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -24030,7 +24088,10 @@ function applyActionInner(
t === 'card' && card && !action.mode,
'Play Amal or pass the phase opening before taking other actions.',
);
discard(g, p, card.id);
const before = g.semutaPreview
? g.players.map(player => ({ player: player.id, spice: player.spice }))
: null;
const used = discard(g, p, card.id);
for (const player of g.players)
player.spice = Math.floor(player.spice / 2);
g.phaseOpening.passed = [];
@@ -24038,6 +24099,20 @@ function applyActionInner(
g,
`${p.name} played Amal. Each faction returned half its available spice to the bank, rounded up.`,
);
if (before) {
const continuation: AmalDiscardContinuation = {
kind: 'amalDiscard', player: p.id, card: used.id, before,
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.phaseOpening = null;
continuation.stateSignature = amalDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'amal', entries, continuation);
}
}
}
return g;
}
+5 -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 also include the physical Ix deck: using Thumper then pauses before its injected worm and Spice Blow draw. Other connected sources include ordinary cards, paid Box, retired Ornithopter, private Distrans, final Truthtrance, movement-order Sapho and three separate battle batches. Commit held Semuta with a free hand slot; for several eligible cards, inspect and select exactly one only after commitment. The original effect resumes once. Nested responses, unreconciled battle rewards, other sources, 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 its injected worm and spice draw; Amal pauses after halving everyone’s spice but before the opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, movement-order Sapho and three battle batches. Commit held Semuta with a free hand slot; for several eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Nested responses, pending rewards, other sources, 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 explicit Richese profile interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho, Ix-deck Thumper and three battle discard stages. Thumper pauses before any injected worm or spice-card draw; claim or decline resumes one normal Spice Blow. A neutral offer precedes each eligible batch. Only a committed holder sees multi-card candidates from other players; Semuta transfers one card, is discarded once and resumes the saved effect once. Competing responses, pending rewards, other producers and full-hand exchange remain unfinished.',
'The explicit Richese profile interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, movement-order Sapho, Ix-deck Thumper and Amal, and three battle discard stages. Thumper waits before injecting its worm or drawing spice. Amal has already halved each personal spice amount and reset prior opening passes; claim or decline restores the same phase opening once. A neutral offer precedes every eligible batch, owner-only committed candidates remain private, and Semuta transfers one physical card. Competing 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, movement-order Sapho, Ix-deck Thumper and initial/mandatory-winner/optional-winner 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, movement-order Sapho, 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, movement-order Sapho, Ix-deck Thumper and three battle discard stages. 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, movement-order Sapho, Ix-deck Thumper/Amal and three battle discard stages. Pending rewards, full games and live deployment remain unverified.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',
@@ -1831,6 +1831,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Ix tables open a public confirmation window before each phase. Play Amal or pass. Everyone receives this window regardless of who holds a card. Once everyone passes, the phase begins.',
'A pass commits until another opening card is played. Playing Amal resets the confirmations, so everyone can respond to the changed state before continuing.',
'Automatic auctions, collection and Mentat payments wait for confirmation. Unpaid bribes are not available spice until paid at Mentat. Income from the previous phase and unused ally credit are settled before the next opening.',
'In the explicitly opted-in Semuta preview with a physical Ix deck, a clean Amal discard pauses after the spice reduction and opening-pass reset. Another holder may claim only the used Amal card; all-seat decline leaves it discarded. Either path restores the same phase-opening confirmation window before bidding, draws or other opening effects begin.',
'The server, phase controls and AI support this timing. Full expansion starts remain gated. Printed card timing, conflicts among simultaneous beginning-of-phase powers and later expansion economy interactions still need the complete audit.',
],
related: ['bidding', 'collection', 'mentat', 'card-thumper', 'ix-modules'],
+56
View File
@@ -813,3 +813,59 @@ void test('a clean Thumper discard pauses before its injected worm and resumes o
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
});
void test('a clean Ix-deck Amal discard pauses after halving spice but before phase initialization', () => {
const { g, semuta, hajr } = fixture(true);
player(g, 'a').hand = [];
g.deck.push(hajr);
const amal = take(g, 'amal');
player(g, 'a').hand.push(amal);
player(g, 'r').spice = 11;
player(g, 'a').spice = 9;
player(g, 'e').spice = 7;
Object.assign(g, { phase: 2, active: null, ready: [],
decision: null, response: null, phaseOpening: null, auction: null });
const original = inventory(g);
let opening = g;
for (const id of ['a', 'e', 'r'])
opening = applyAction(opening, id, { type: 'ready' });
assert.equal(opening.phase, 3);
assert.ok(opening.phaseOpening);
opening = applyAction(opening, 'e', { type: 'ready' });
assert.deepEqual(opening.phaseOpening?.passed, ['e']);
const deck = reload(opening).deck;
const pending = applyAction(opening, 'a', { type: 'card', card: amal.id });
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.phaseOpening, null);
assert.equal(pending.auction, null);
assert.deepEqual(pending.deck, deck);
assert.deepEqual(pending.players.map(p => p.spice), [5, 4, 3]);
assert.equal(pending.discard.filter(card => card.id === amal.id).length, 1);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
let declined = reload(pending);
for (const id of ['e', 'a', 'r'])
declined = applyAction(declined, id, { type: 'semutaPass', event });
assert.equal(declined.pendingTreacheryDiscard, null);
assert.deepEqual(declined.phaseOpening?.passed, []);
assert.deepEqual(declined.players.map(p => p.spice), [5, 4, 3]);
assert.deepEqual(inventory(declined), original);
const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!);
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.deepEqual(claimed.phaseOpening?.passed, []);
assert.deepEqual(claimed.players.map(p => p.spice), [5, 4, 3]);
assert.equal(player(claimed, 'r').hand.filter(card => card.id === amal.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(claimed.discard.some(card => card.id === amal.id), false);
for (const p of claimed.players)
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
let after = reload(claimed);
for (const id of ['a', 'e', 'r'])
after = applyAction(after, id, { type: 'ready' });
assert.equal(after.phaseOpening, null);
assert.equal(after.richeseBidding?.turn, after.turn);
assert.deepEqual(after.players.map(p => p.spice), [5, 4, 3]);
assert.deepEqual(inventory(after), original);
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
});
+47
View File
@@ -618,5 +618,52 @@ void test('an authenticated Ix-deck Thumper pause retains the undrawn blow throu
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
const bidding = structuredClone(claimed);
const again = take(bidding, SEMUTA_DRUG_ID);
bidding.deck.push(take(bidding, thumper.id));
seat(bidding, ids[0]).hand.push(again);
const amal = take(bidding, 'amal');
seat(bidding, ids[1]).hand.push(amal);
seat(bidding, ids[0]).spice = 11;
seat(bidding, ids[1]).spice = 9;
seat(bidding, ids[2]).spice = 7;
Object.assign(bidding, { turn: 3, phase: 2, active: null, ready: [],
decision: null, response: null, phaseOpening: null, auction: null,
spiceWindow: null, spiceResolution: null, spiceSequence: null, nexus: false });
bidding.version = claimed.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(bidding), bidding.version, code, claimed.version).changes, 1);
for (let index = 0; index < auths.length; index++)
await act(index, { type: 'ready' });
const opening = await f.restart().readRoom(code);
assert.equal(opening.phase, 3);
assert.ok(opening.phaseOpening);
assert.deepEqual((await act(2, { type: 'ready' })).phaseOpening?.passed, [ids[2]]);
const deckBefore = structuredClone(opening.deck);
const offeredAmal = await act(1, { type: 'card', card: amal.id });
assert.equal(offeredAmal.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(offeredAmal.players.map(p => p.spice), [5, 4, 3]);
assert.deepEqual(offeredAmal.deck, deckBefore);
assert.equal(offeredAmal.phaseOpening, null);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, offeredAmal.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const recoveredAmal = await act(0, { type: 'semutaCommit',
event: offeredAmal.pendingTreacheryDiscard!.batch.event });
assert.equal(recoveredAmal.pendingTreacheryDiscard, null);
assert.deepEqual(recoveredAmal.phaseOpening?.passed, []);
assert.deepEqual(recoveredAmal.players.map(p => p.spice), [5, 4, 3]);
assert.equal(seat(recoveredAmal, ids[0]).hand.filter(card => card.id === amal.id).length, 1);
assert.equal(recoveredAmal.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
for (let index = 0; index < auths.length; index++)
await act(index, { type: 'ready' });
const resumedAmal = await f.restart().readRoom(code);
assert.equal(resumedAmal.phaseOpening, null);
assert.equal(resumedAmal.richeseBidding?.turn, resumedAmal.turn);
assert.deepEqual(resumedAmal.players.map(p => p.spice), [5, 4, 3]);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(resumedAmal, auth.playerId));
} finally { f.sqlite.close(); }
});