Offer Semuta after clean private Distrans transfer
This commit is contained in:
1 parent
2ec0422b79
commit
162f01c160
9 files changed
+243
-40
No files matched your search
+14
-12
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
Reference in new issue
Block a user