Offer Semuta after clean private Distrans transfer

This commit is contained in:
Quintine committed 2026-09-30 07:53:59 +10:00
1 parent 2ec0422b79
commit 162f01c160
9 files changed
+243 -40

No files matched your search

+14 -12
View File
@@ -8,18 +8,20 @@ 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)
now pauses a clean public ordinary-card discard, the used Box after
a completed paid search, or an Ornithopter retired on early movement
ending with the same neutral offer for every seat, independently of
hidden Semuta possession. A holder with a free hand slot can commit
to the one fresh other-player card; its physical claim and Semuta
disposal resume the original effect, Box parent or movement queue once.
All-seat decline, four minimal legal AI profiles, exact JSON/SQLite
continuation and competing-room CAS are connected. Phone width
showed the owned Commit control and the identical rival Continue
event without a private card face. Completed-flight arrival, multi-card
and most other discard producers, full-hand ordering, normal Richese
starts and deployed acceptance remain unfinished; neutral passes and
the free-slot guard are product safety choices, not publisher rulings.
a completed paid search, an Ornithopter retired on early movement
ending, or the public used Distrans after a clean private transfer.
Every seat sees the same neutral offer independently of hidden Semuta
possession; the transferred card remains private with its recipient.
A holder with a free hand slot can commit to the fresh other-player
discard; its physical claim and Semuta disposal resume the original
effect, Box parent or movement queue once. All-seat decline, four
minimal legal AI profiles, exact JSON/SQLite continuation and
competing-room CAS are connected. Phone width showed the owned
Commit control and identical rival Continue event without a private
card face. Completed-flight arrival, competing transactions,
multi-card and most other 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,
+32
View File
@@ -1,5 +1,37 @@
# Dune implementation status
## 30 September 2026 — Semuta after a completed private Distrans transfer
An explicitly opted-in Richese preview now pauses after a clean
Distrans action physically gives one privately selected card to its
recipient and publicly discards the used Distrans. Every seat sees
the same neutral fresh-discard event, not the transferred card. A
legal holder may recover only the public used Distrans, discarding
Semuta; all-seat decline leaves Distrans in the ordinary pile. The
recipient keeps its transferred card on either path. Existing
pending gifts, bid or response contexts remain outside this bounded
reaction; ordinary transfers retain their previous completion.
Genuine setup and isolated migrated SQLite room cases cover physical
custody, owner-only commit and legal AI participation, all-seat
decline, private recipient card projection, saved restart without
auto-advance, exact authenticated claim and unchanged seat rows.
Incomplete producer families, full-hand ordering, normal Richese
starts and deployed acceptance remain gated.
Independent privacy review found no scoped leak. Rules review found
that a duplicated transferred card in removed Richese custody escaped
the initial saved receipt check; a failing-before/passing-after
regression now requires the recipient's card to be unique across
all physical non-discard zones. Follow-up review found no remaining
scoped defect. A throwaway direct engine run completed genuine AI
setup, private transfer, neutral offer and bot claim; the nonrecipient
view omitted the given card and the recipient retained it. The
phone-width rules guide visibly marks this path Partial. `npm run check`
passed typecheck, lint and 5,804/5,804 offline cases after the custody
fix; `npm run build` and all 55 Vite development HTTP cases passed. No
production deployment was verified.
## 30 September 2026 — Semuta after an early-ended Ornithopter flight
The explicit Richese Semuta preview now interrupts the one public
+1 -1
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 now has an explicit bounded development preview for clean public ordinary-card, completed paid Box and early-ended Ornithopter discards; it does not implement other producers, full-hand ordering or normal Richese starts. Mirror Weapon and Juice of Sapho have separate bounded paths, not complete combined-mode certification. Other unfinished faction/module systems, mobile acceptance, difficulty-strength calibration and complete Advanced/expansion games still prevent goal completion. No start gate was removed.
- Semuta Drug now has an explicit bounded development preview for clean public ordinary-card, completed paid Box, early-ended Ornithopter and completed private Distrans transfer discards; the private transferred card is not a fresh target. Other producers, full-hand ordering and normal Richese starts remain unfinished. Mirror Weapon and Juice of Sapho have separate bounded paths, not complete combined-mode certification. Other unfinished faction/module systems, mobile acceptance, difficulty-strength 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).
+10
View File
@@ -4,6 +4,16 @@ Read-only engine audit, 2026-09-07. Read the complete `docs/SEMUTA_DRUG_ENGINE_A
Update: the exchange producers, three battle producers, Sabotage, Robbery overflow and completed Nullentropy Box searches, Ornithopter escrow retirement, definite Truthtrance answers and the five ordinary completed-card effects below now have production automatic continuation frames; see [exchange checkpoint](TREACHERY_DISCARD_CONTINUATIONS.md), [battle checkpoint](BATTLE_DISCARD_CONTINUATIONS.md), [forced Terror checkpoint](TERROR_DISCARD_CONTINUATIONS.md), [Box checkpoint](BOX_DISCARD_CONTINUATIONS.md), [Ornithopter checkpoint](ORNITHOPTER_DISCARD_CONTINUATIONS.md), [Truthtrance checkpoint](TRUTHTRANCE_DISCARD_CONTINUATIONS.md) and [ordinary-card checkpoint](ORDINARY_CARD_DISCARD_CONTINUATIONS.md). The remaining table is an implementation inventory, not a claim that Semuta is active.
30 September 2026 checkpoint: the explicit Semuta development preview
now stages a `distransDiscard` receipt only after a clean completed
private transfer. Its fresh batch contains the public played Distrans,
not the private card already transferred to its recipient. Neutral
all-seat acknowledgement precedes closure; claim or decline retires
the saved receipt without redoing the hand transfer. Pending responses,
gifts and competing parents retain the original direct transfer
behavior without this Semuta reaction. The table below remains the
broader producer/continuation inventory, not a claim of complete coverage.
## 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.
+22 -19
View File
@@ -64,31 +64,34 @@ Do not ask again about normal targets, forced-discard owner bookkeeping, simulta
The explicit `semuta` development profile starts a fresh Richese roster
without opening ordinary expansion starts. It now interrupts **one public
ordinary-card discard, the used Box after a completed paid Nullentropy
search, or an Ornithopter retired by ending movement early**, provided
the parent transaction is clean, with a neutral fresh-discard opportunity
for every seat regardless of hidden Semuta possession. The flight's
already committed movement and physical escrow remain intact; a claim
or all-seat decline advances the saved movement queue exactly once.
Each seat can Continue; only the canonical holder with a free hand slot
and a card discarded by another player can commit. The sole eligible
fresh card transfers automatically in the same accepted action, then
Semuta goes to the normal discard pile. The saved ordinary effect or
Box parent resumes once; passing by every seat preserves the original
discard and effect. No face from an undisclosed event is projected
before commitment. Owner-only multi-candidate selection support exists
in the reaction component and bot adapter, but no multi-discard
producer is activated in this bounded runtime.
search, an Ornithopter retired by ending movement early, or the used
Distrans after a clean completed private transfer**, provided the parent
transaction is clean, with a neutral fresh-discard opportunity for every
seat regardless of hidden Semuta possession. The flight's already
committed movement and physical escrow remain intact; a claim or
all-seat decline advances the saved movement queue exactly once.
Distrans's transferred card stays privately with its recipient and
cannot be claimed; only the played public Distrans enters this fresh
batch. Each seat can Continue; only the canonical holder with a free
hand slot and a card discarded by another player can commit. The sole
eligible fresh card transfers automatically in the same accepted action,
then Semuta goes to the normal discard pile. The saved ordinary effect
or Box parent resumes once; passing by every seat preserves the
original discard and effect. No face from an undisclosed event is
projected before commitment. Owner-only multi-candidate selection
support exists in the reaction component and bot adapter, but no
multi-discard producer is activated in this bounded runtime.
This neutral all-seat acknowledgement is a conservative **application
policy**, not a newly discovered publisher timing rule. The explicit
free-slot requirement is a provisional safety guard, not the printed
resolution of full-hand take-before-discard. Auction, CHOAM market,
pending gifts, competing responses, completed Ornithopter flights with
arrival follow-up and other typed discard producers retain their existing
automatic continuation; Semuta cannot claim those events yet. The
completed Box's selected search card stays private and its paid shuffle
is not repeated when another seat takes the used Box. No source ruling
is inferred from this omission. The
arrival follow-up and other typed discard producers retain their
existing automatic continuation; Semuta cannot claim those events yet.
The completed Box's selected search card stays private and its paid
shuffle is not repeated when another seat takes the used Box. No source
ruling is inferred from this omission. The
separate [reaction privacy audit](SEMUTA_PRIVACY_REVIEW.md) explains why
auto-closing only when nobody secretly holds the card would leak
possession. The main game and public expansion gates remain closed.
+67 -2
View File
@@ -1134,6 +1134,15 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'distransDiscard';
owner: string;
recipient: string;
transferred: string;
card: string;
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'truthtranceDiscard';
consumed: TruthQueueEntry;
@@ -4658,6 +4667,20 @@ function stageOrdinaryCardDiscard(g: Game, p: Player, card: Card) {
next,
);
}
type DistransDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'distransDiscard' }
>;
function distransDiscardSignature(g: Game, c: DistransDiscardContinuation) {
const owner = getPlayer(g, c.owner), recipient = getPlayer(g, c.recipient);
return JSON.stringify({
owner: c.owner, recipient: c.recipient, transferred: c.transferred,
card: c.card, parent: nullentropyParentSignature(g, c.resume, `distrans:${c.card}`),
turn: g.turn, phase: g.phase, active: g.active,
ownerHand: owner.hand.map(card => card.id),
recipientHand: recipient.hand.map(card => card.id),
});
}
type TruthDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'truthtranceDiscard' }
@@ -5053,6 +5076,25 @@ function treacheryDiscardIntegrity(g: Game) {
: true,
'The completed ordinary-card outcome has changed.',
);
} else if (continuation?.kind === 'distransDiscard') {
const c = continuation, entry = batch.entries[0];
const owner = g.players.find(p => p.id === c.owner);
const recipient = g.players.find(p => p.id === c.recipient);
requireRule(
owner && recipient && owner !== recipient &&
batch.cause === 'distrans' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.owner &&
entry.card.id === c.card &&
richeseCardDefinition(entry.card)?.card.effect === 'distrans' &&
recipient.hand.filter(card => card.id === c.transferred).length === 1 &&
!owner.hand.some(card => card.id === c.transferred || card.id === c.card) &&
outside.filter(card => card.id === c.transferred).length === 1 &&
!g.discard.some(card => card.id === c.transferred) &&
!c.resume.response && !c.resume.decision &&
!c.resume.pendingKarama && !c.resume.phaseOpening &&
c.stateSignature === distransDiscardSignature(g, c),
'The completed private Distrans transfer no longer matches its fresh discard.',
);
} else if (continuation?.kind === 'truthtranceDiscard') {
const c = continuation,
entry = batch.entries[0],
@@ -5643,8 +5685,8 @@ function treacheryDiscardIntegrity(g: Game) {
} else throw new RuleError('Unknown saved discard continuation.');
}
/** Bounded public single-card reaction after a completed ordinary effect, paid
* Box search, or early-ended Ornithopter flight. A completed flight's arrival
* may open further entry windows, so that continuation remains automatic. */
* Box search, early-ended Ornithopter flight or clean Distrans transfer. A
* completed flight's arrival may open further entry windows and stays automatic. */
function semutaOfferSupported(
g: Game,
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
@@ -5652,7 +5694,11 @@ function semutaOfferSupported(
) {
if (continuation.kind !== 'ordinaryCardDiscard' &&
continuation.kind !== 'nullentropyDiscard' &&
continuation.kind !== 'distransDiscard' &&
!(continuation.kind === 'ornithopterDiscard' && continuation.source === 'end')) return false;
if (continuation.kind === 'distransDiscard' &&
(g.pendingChoamMove || g.pendingIxMove || g.pendingFremenMove ||
g.ornithopter || g.summonedWorm || g.wormRides.length > 0)) return false;
return g.semutaPreview === true && g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') &&
entries.length === 1 && entries[0].publicFace &&
@@ -5815,6 +5861,11 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
g.truthtrance = next.remaining;
} else if (next.kind === 'distransDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
} else if (next.kind === 'ornithopterDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -23697,6 +23748,20 @@ function applyActionInner(
`${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`,
{ faction: p.faction, name: 'Distrans' },
);
if (g.semutaPreview && !g.pendingTreacheryDiscard) {
const continuation: DistransDiscardContinuation = {
kind: 'distransDiscard', owner: p.id, recipient: recipient.id,
transferred: result.transferred.id, card: result.discarded.id,
resume: { response: g.response, decision: g.decision,
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
stateSignature: '',
};
const entries = [{ card: result.discarded, discardedBy: p.id, publicFace: true }];
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = distransDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'distrans', entries, continuation);
}
}
return g;
}
if (t === 'richeseGift') {
+5 -5
View File
@@ -690,7 +690,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A transfer must preserve binding battle answers, compulsory discards and both sides of a pending Harkonnen hand exchange. The recipient must retain room for its mandatory incoming return. Optional proposals that become unavailable can still finish through their existing decline or no-effect path.',
'A different legal transfer can fill or free space for a pending Richese gift. The ordinary Distrans transfer resolves first, and the pending gift rechecks capacity before moving its own card. A canceled alliance gift does not automatically prevent using this separate card effect.',
'All four AI profiles use their own legal projected cards. They may give an allied Bene Gesserit or CHOAM a Worthless card, or share a duplicate ordinary weapon, defense or Karama to free a full hand. They do not inspect the recipient’s private cards and preserve existing decision priority.',
'Semuta responses to a fresh Distrans discard, mobile acceptance, the unresolved bid and self-transfer cases, and full combined Richese games remain unfinished. The separate clean public ordinary-card Semuta preview does not enable this transfer composition, all remaining Richese cards or expansion starts.',
'The explicitly opted-in Semuta preview now offers a neutral all-seat response to the public used Distrans after a clean completed private transfer. The given card remains private and belongs to its recipient; only the used Distrans is claimable. Responses or decisions already in progress, unresolved bid and self-transfer rulings, full combined Richese games and ordinary expansion starts remain unfinished.',
],
related: [
'card-richese-distrans',
@@ -1155,7 +1155,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 an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard, a completed paid Box search, or early ending an Ornithopter flight. Continue or commit your held Semuta with a free hand slot; the sole eligible card transfers automatically before the saved turn advances. A completed flight with arrival follow-up, other discard producers, full-hand sequencing and normal Richese starts remain unfinished.'
? 'In an explicitly opted-in Richese development game, every seat sees a neutral response after a clean public ordinary-card discard, a completed paid Box search, early ending an Ornithopter flight, or completing a clean private Distrans transfer. Continue or commit your held Semuta with a free hand slot; only the freshly discarded public card transfers, never the private card given to its recipient. Completed-flight arrival, competing transactions, other discard producers, full-hand sequencing and normal Richese starts remain unfinished.'
: 'Reference component only: this Richese card’s game actions are not enabled.',
],
...(definition.card.effect === 'semutaDrug'
@@ -1165,7 +1165,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const,
status: 'Partial' as const,
detail:
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, completed paid Box and early-ended Ornithopter discards with a neutral all-seat offer. Committing held Semuta atomically claims the sole eligible other-player card, discards Semuta and resumes the saved ordinary-card, Box or movement-turn parent once. Completed-flight arrival and other typed discard producers and full-hand exchange remain unfinished.',
'An explicitly opted-in Richese profile interrupts clean public ordinary-card, completed paid Box, early-ended Ornithopter and completed private Distrans discards with a neutral all-seat offer. Committing held Semuta claims the sole eligible other-player discarded card and discards Semuta. The private Distrans gift remains with its recipient; the other saved effects and movement queue resume once. Completed-flight arrival, competing transactions, other typed producers and full-hand exchange remain unfinished.',
},
{
area: 'Player controls' as const,
@@ -1183,13 +1183,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Documentation' as const,
status: 'Partial' as const,
detail:
'The guide distinguishes bounded public ordinary-card, paid Box and early-ended Ornithopter previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.',
'The guide distinguishes bounded public ordinary-card, paid Box, early-ended Ornithopter and clean Distrans previews, privacy-neutral acknowledgements and conservative free-slot policy from unresolved full-hand sequencing and remaining discard sources.',
},
{
area: 'Verification' as const,
status: 'Partial' as const,
detail:
'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and all four legal AI profiles cover clean ordinary-card, paid Box and early-ended Ornithopter reactions. Other discard producers, full games and live deployment remain unverified.',
'Genuine setup, physical card custody, neutral hidden-hand parity, saved JSON, authenticated SQLite CAS and legal AI participation cover clean ordinary-card, paid Box, early-ended Ornithopter and private Distrans reactions. Other discard producers, full games and live deployment remain unverified.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',
+57
View File
@@ -263,3 +263,60 @@ void test('a retired Ornithopter can be claimed after one flight group without r
assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed));
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
});
void test('a completed private Distrans transfer offers only its public used card', () => {
const { g, semuta, hajr } = fixture();
const distrans = take(g, 'richese-distrans');
player(g, 'a').hand.push(distrans);
const original = inventory(g);
const without = reload(g);
player(without, 'r').hand = [];
without.richeseCache!.push(semuta);
player(without, 'r').hand.push(take(without, 'richese-nullentropy-box'));
const pending = applyAction(g, 'a', {
type: 'card', card: distrans.id, target: 'e', give: hajr.id,
});
const absent = applyAction(without, 'a', {
type: 'card', card: distrans.id, target: 'e', give: hajr.id,
});
assert.equal(absent.pendingTreacheryDiscard?.reaction?.stage, 'offer');
for (const id of ['a', 'e'])
assert.deepEqual(viewGame(pending, id), viewGame(absent, id));
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(entry => entry.card.id), [distrans.id]);
assert.equal(player(pending, 'e').hand.filter(card => card.id === hajr.id).length, 1);
assert.equal(player(pending, 'a').hand.some(card => card.id === hajr.id), false);
const event = pending.pendingTreacheryDiscard!.batch.event;
const stranger = JSON.stringify(viewGame(pending, 'r'));
assert.equal(stranger.includes(hajr.id), false);
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, false);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
const corrupt = reload(pending);
if (corrupt.pendingTreacheryDiscard?.continuation.kind !== 'distransDiscard')
throw Error('Missing saved private transfer');
corrupt.pendingTreacheryDiscard.continuation.transferred = semuta.id;
const before = JSON.stringify(corrupt);
assert.throws(() => viewGame(corrupt, 'r'));
assert.throws(() => applyAction(corrupt, 'r', { type: 'semutaCommit', event }));
assert.equal(JSON.stringify(corrupt), before);
const duplicate = reload(pending);
duplicate.richeseRemoved = [...(duplicate.richeseRemoved ?? []), structuredClone(hajr)];
assert.throws(() => viewGame(duplicate, 'r'));
let declined = reload(pending);
for (const id of ['a', 'e', 'r'])
declined = applyAction(declined, id, { type: 'semutaPass', event });
assert.equal(declined.pendingTreacheryDiscard, null);
assert.equal(declined.discard.filter(card => card.id === distrans.id).length, 1);
assert.deepEqual(inventory(declined), original);
const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!);
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(player(claimed, 'e').hand.filter(card => card.id === hajr.id).length, 1);
assert.equal(player(claimed, 'r').hand.filter(card => card.id === distrans.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(claimed.discard.some(card => card.id === distrans.id), false);
assert.deepEqual(inventory(claimed), original);
for (const p of claimed.players)
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
});
+35 -1
View File
@@ -16,7 +16,7 @@ function take(g: Game, identity: string) {
throw new Error('The genuine setup lost a physical card.');
}
void test('authenticated ordinary and paid-Box discards preserve one Semuta claim through restart and room CAS', async () => {
void test('authenticated ordinary, paid Box, retired flight and private Distrans claims survive restart and room CAS', async () => {
const f = unitStore();
try {
const created = await f.rooms.createRoom('Semuta QA', 'richese', false, ['choam']);
@@ -174,5 +174,39 @@ void test('authenticated ordinary and paid-Box discards preserve one Semuta clai
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(flown, auth.playerId));
const transfer = structuredClone(flown);
const finalSemuta = take(transfer, SEMUTA_DRUG_ID);
const distrans = take(transfer, 'richese-distrans');
transfer.richeseCache!.push(take(transfer, 'richese-nullentropy-box'));
seat(transfer, ids[0]).hand.push(finalSemuta);
seat(transfer, ids[1]).hand.push(distrans);
const given = seat(transfer, ids[1]).hand.find(card => card.id === selected)!;
assert.ok(given);
Object.assign(transfer, { turn: 5, phase: 5, active: ids[1], ready: [],
decision: null, response: null, phaseOpening: null, stormPending: null,
movementRemaining: [ids[1], ids[0], ids[2]], hajr: [] });
transfer.version = flown.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(transfer), transfer.version, code, flown.version).changes, 1);
const handed = await act(1, { type: 'card', card: distrans.id,
target: ids[2], give: given.id });
assert.equal(handed.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(seat(handed, ids[2]).hand.filter(card => card.id === given.id).length, 1);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, handed.version);
const observer = await f.restart().readSeatView(code, auths[0]);
assert.equal(observer.semutaReaction?.canCommit, true);
assert.equal(JSON.stringify(observer).includes(given.id), false);
const final = await act(0, { type: 'semutaCommit',
event: handed.pendingTreacheryDiscard!.batch.event });
assert.equal(final.pendingTreacheryDiscard, null);
assert.equal(seat(final, ids[2]).hand.filter(card => card.id === given.id).length, 1);
assert.equal(seat(final, ids[0]).hand.filter(card => card.id === distrans.id).length, 1);
assert.equal(final.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(final.discard.some(card => card.id === distrans.id), false);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(final, auth.playerId));
} finally { f.sqlite.close(); }
});