Offer Semuta after clean Karama auction purchase
This commit is contained in:
1 parent
5832f4353a
commit
eede77a3e3
8 files changed
+250
-16
No files matched your search
+11
-4
@@ -12,10 +12,11 @@ the physical Ix deck. Clean public ordinary-card, paid Box, retired
|
||||
Ornithopter, private Distrans, final Truthtrance, Once Around,
|
||||
movement-order, between-battles chooser or pre-plan aggressor Sapho,
|
||||
clean pre-leader Residual Poison, printed Karama canceling clean
|
||||
CHOAM Charity, Inflation or Bene Gesserit Charity, 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 settling the canceled response.
|
||||
CHOAM Charity, Inflation or Bene Gesserit Charity or purchasing a
|
||||
clean normal-auction lot, battle-card and Ix-deck Thumper/Amal discards
|
||||
open the same neutral offer regardless of hidden Semuta custody.
|
||||
Karama waits after its public cost but before cancellation or the
|
||||
reserved free purchase settles.
|
||||
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
|
||||
@@ -31,6 +32,12 @@ full-hand ordering, normal Richese starts and deployed acceptance
|
||||
remain unfinished. Neutral passes and free-slot guard are product
|
||||
choices.
|
||||
|
||||
The clean printed Karama normal-auction purchase passed engine,
|
||||
authenticated SQLite and guide/browser checks. `npm run check` passed
|
||||
5,835 offline tests and the build passed. An isolated built-worker
|
||||
HTTP suite passed 51/55 cases; four origin/malformed-request cases
|
||||
returned 503 instead of 403/400. The HTTP gate remains open.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 30 September 2026 — Semuta after printed Karama normal-auction purchase
|
||||
|
||||
The explicit Richese development preview now offers a neutral Semuta
|
||||
response after a clean printed Karama pays its public normal-auction
|
||||
purchase cost but before the reserved lot settles. A committed holder
|
||||
may exchange one physical Semuta for that newly spent card; all-seat
|
||||
decline leaves it in the discard. Both paths settle the original free
|
||||
purchase once, with the lot, round, cache, deck and hand custody bound
|
||||
to the saved receipt. A pending Richese cache offer after the normal
|
||||
auction remains owed. Richese lots, paid Karama bids, Worthless
|
||||
conversion, shipping and nested responses retain their automatic paths.
|
||||
Normal expansion starts remain gated.
|
||||
|
||||
Engine and authenticated SQLite tests cover the neutral offer, legal
|
||||
AI claim, reserved lot, spent-card custody, JSON/restart continuation,
|
||||
Richese cache suffix, stale exact retry and rejected altered receipts.
|
||||
An isolated engine smoke completed the purchase after the Semuta claim,
|
||||
placing the lot once, exchanging the printed card and closing Bidding.
|
||||
The wider Semuta discard inventory and deployed acceptance remain open.
|
||||
|
||||
`npm run check` passed types, lint and 5,835 offline tests; the
|
||||
production build passed. The isolated built Wrangler worker rendered
|
||||
the revised Semuta guide on a phone-width browser. Its HTTP integration
|
||||
suite passed 51/55 cases, including normal lobby and saved-seat flows;
|
||||
four existing origin and malformed-request cases received HTTP 503
|
||||
instead of their expected 403/400. Those failures are not attributed
|
||||
to this purchase path, and the HTTP gate is **not green**. A separate
|
||||
standard Vite server startup timed out before it could host a rerun.
|
||||
No production image or game database was changed.
|
||||
|
||||
## 30 September 2026 — Owner-only manual integrity check
|
||||
|
||||
Administration → Operations now has a separate **Run integrity check**
|
||||
|
||||
@@ -54,7 +54,14 @@ card cost but before accepted cancellation. The first two retain
|
||||
their existing saved receipt signatures. Each binds the sole
|
||||
suspended response, spent card and hand/spice custody; claim or
|
||||
decline restores that response and finishes cancellation once.
|
||||
Other Karama forms and nested responses remain automatic.
|
||||
Other cancellations and shipping retain their automatic paths.
|
||||
|
||||
Clean printed Karama normal-auction purchase now stages
|
||||
`karamaPurchaseDiscard` after the public cost and before the reserved
|
||||
lot is settled. The receipt binds the original lot, order, deck and
|
||||
hands; a neutral claim or decline completes the same free purchase
|
||||
once. Richese lots, paid auction Karama and nested responses retain
|
||||
their previous automatic paths.
|
||||
|
||||
Completed Ornithopter movement now offers the same retired public card
|
||||
before its already committed arrival suffix only in a roster without
|
||||
|
||||
@@ -67,7 +67,8 @@ opening public expansion starts. It interrupts **clean public
|
||||
ordinary-card, paid Box, retired Ornithopter, used Distrans, final
|
||||
Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison,
|
||||
printed Karama canceling clean CHOAM Charity, Inflation or Bene
|
||||
Gesserit Charity, battle and Ix-deck Thumper/Amal discards**. Other
|
||||
Gesserit Charity or buying a clean normal-auction lot, battle and
|
||||
Ix-deck Thumper/Amal discards**. Other
|
||||
than the one explicitly suspended charity/Inflation response, the
|
||||
parent transaction has no competing response. Every seat sees the
|
||||
same neutral fresh-discard opportunity
|
||||
@@ -100,8 +101,14 @@ its accepted cancellation runs. The sole suspended response is bound
|
||||
to the saved receipt; claim or decline resumes the same cancellation
|
||||
once. CHOAM income or BG's extra allowance is withheld, while
|
||||
Inflation retains its spent placement attempt without a token.
|
||||
Worthless conversion, other cancellations, shipping, purchases and
|
||||
nested Karama responses retain their separate automatic paths.
|
||||
Worthless conversion, other cancellations, shipping, auction payment
|
||||
and nested Karama responses retain their separate automatic paths.
|
||||
|
||||
A clean printed Karama purchase of an unresolved normal auction lot
|
||||
also pauses after its public card cost. The lot, deck and seating order
|
||||
remain bound while every seat sees the neutral offer. Claim or decline
|
||||
settles that same free purchase once; no second bid, payment or draw
|
||||
precedes the offer. Richese lots and other Karama forms are unchanged.
|
||||
|
||||
For a genuine Ix-deck Thumper, its played physical card reaches
|
||||
the discard before an injected worm or actual spice-card draw.
|
||||
|
||||
+62
-3
@@ -1198,6 +1198,13 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'karamaPurchaseDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'thumperDiscard';
|
||||
player: string;
|
||||
@@ -4885,6 +4892,24 @@ function karamaResponseDiscardSignature(g: Game, c: KaramaResponseDiscardContinu
|
||||
})),
|
||||
});
|
||||
}
|
||||
type KaramaPurchaseDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'karamaPurchaseDiscard' }
|
||||
>;
|
||||
function karamaPurchaseDiscardSignature(g: Game, c: KaramaPurchaseDiscardContinuation) {
|
||||
return JSON.stringify({
|
||||
player: c.player, card: c.card,
|
||||
parent: nullentropyParentSignature(g, c.resume, `karama:purchase:${c.card}`),
|
||||
turn: g.turn, phase: g.phase, active: g.active, auction: g.auction,
|
||||
richeseBidding: g.richeseBidding, richeseFunding: g.richeseFunding,
|
||||
richeseCache: g.richeseCache?.map(card => card.id),
|
||||
deck: g.deck.map(card => card.id), order: g.order,
|
||||
players: g.players.map(p => ({
|
||||
id: p.id, faction: p.faction, ally: p.ally,
|
||||
hand: p.hand.map(card => card.id), spice: p.spice,
|
||||
})),
|
||||
});
|
||||
}
|
||||
type ThumperDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'thumperDiscard' }
|
||||
@@ -5475,6 +5500,20 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
c.stateSignature === karamaResponseDiscardSignature(g, c),
|
||||
'The spent Karama no longer matches its suspended charity or Inflation cancellation.',
|
||||
);
|
||||
} else if (continuation?.kind === 'karamaPurchaseDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
g.phase === 3 && !g.response && !g.decision && !g.pendingKarama &&
|
||||
!g.phaseOpening && !c.resume.response && !c.resume.decision &&
|
||||
!c.resume.pendingKarama && !c.resume.phaseOpening &&
|
||||
batch.cause === 'karama:purchase' && batch.entries.length === 1 &&
|
||||
entry.publicFace && entry.discardedBy === c.player &&
|
||||
entry.card.id === c.card && entry.card.effect === 'karama' &&
|
||||
g.players.some(p => p.id === c.player) &&
|
||||
c.stateSignature === karamaPurchaseDiscardSignature(g, c),
|
||||
'The spent Karama no longer matches its reserved auction purchase.',
|
||||
);
|
||||
normalKaramaAuction(g, getPlayer(g, c.player), false);
|
||||
} else if (continuation?.kind === 'thumperDiscard') {
|
||||
const c = continuation, entry = batch.entries[0];
|
||||
requireRule(
|
||||
@@ -6135,6 +6174,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaCharityDiscard' &&
|
||||
continuation.kind !== 'karamaInflationDiscard' &&
|
||||
continuation.kind !== 'karamaBgCharityDiscard' &&
|
||||
continuation.kind !== 'karamaPurchaseDiscard' &&
|
||||
continuation.kind !== 'thumperDiscard' &&
|
||||
continuation.kind !== 'amalDiscard' &&
|
||||
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
|
||||
@@ -6208,7 +6248,10 @@ function semutaOfferSupported(
|
||||
(continuation.kind === 'amalDiscard'
|
||||
? continuation.resume.phaseOpening?.passed.length === 0
|
||||
: !continuation.resume.phaseOpening)))) &&
|
||||
!g.karamaShipping && !g.auction &&
|
||||
!g.karamaShipping &&
|
||||
(continuation.kind === 'karamaPurchaseDiscard'
|
||||
? !!g.auction && !g.currentAuctionSale && !g.richeseAuction
|
||||
: !g.auction) &&
|
||||
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
|
||||
(continuation.kind === 'saphoAggressorDiscard' ||
|
||||
continuation.kind === 'residualPoisonDiscard' || !g.battle) &&
|
||||
@@ -6412,12 +6455,14 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
battle.preLeader.ready = battle.preLeader.ready.filter(id => id !== next.player);
|
||||
} else if (next.kind === 'karamaCharityDiscard' ||
|
||||
next.kind === 'karamaInflationDiscard' ||
|
||||
next.kind === 'karamaBgCharityDiscard') {
|
||||
next.kind === 'karamaBgCharityDiscard' ||
|
||||
next.kind === 'karamaPurchaseDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
completeKarama(g, getPlayer(g, next.player), next.use);
|
||||
completeKarama(g, getPlayer(g, next.player),
|
||||
next.kind === 'karamaPurchaseDiscard' ? { kind: 'purchase' } : next.use);
|
||||
} else if (next.kind === 'amalDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
@@ -10787,6 +10832,20 @@ function spendKarama(g: Game, p: Player, card: Card, use: KaramaUse) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (g.semutaPreview && card.effect === 'karama' && use.kind === 'purchase') {
|
||||
const continuation: KaramaPurchaseDiscardContinuation = {
|
||||
kind: 'karamaPurchaseDiscard', player: p.id, card: used.id,
|
||||
resume: { response: g.response, decision: g.decision,
|
||||
pendingKarama: g.pendingKarama, phaseOpening: g.phaseOpening },
|
||||
stateSignature: '',
|
||||
};
|
||||
const entries = [{ card: used, discardedBy: p.id, publicFace: true }];
|
||||
if (semutaOfferSupported(g, continuation, entries)) {
|
||||
continuation.stateSignature = karamaPurchaseDiscardSignature(g, continuation);
|
||||
stageTreacheryDiscard(g, 'karama:purchase', entries, continuation);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (use.kind === 'shipment') use = { ...use, card: card.id };
|
||||
if (card.kind === 'worthless') {
|
||||
requireRule(!g.pendingKarama, 'A Worthless conversion is already pending.');
|
||||
|
||||
+5
-5
@@ -1140,7 +1140,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
definition.card.effect === 'juiceOfSapho'
|
||||
? 'Use the Juice of Sapho panel for an available order change or pre-plan aggressor. Later intervention and other timing modes remain unfinished; consult the timing guide.'
|
||||
: definition.card.effect === 'karama'
|
||||
? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, clean printed cancellations of CHOAM Charity, Inflation or Bene Gesserit Charity pause on the public cost before settling that response. Full Richese starts and other Karama-to-Semuta reactions remain unavailable.'
|
||||
? 'This physical Karama uses the existing generic handler in development fixtures. In the explicit Semuta preview, clean printed cancellations of CHOAM Charity, Inflation or Bene Gesserit Charity pause on the public cost before settling that response; a clean printed normal-auction purchase pauses before its reserved lot settles. Full Richese starts and other Karama-to-Semuta reactions remain unavailable.'
|
||||
: definition.card.effect === 'distrans'
|
||||
? 'Use the Distrans transfer panel to choose another player and a separate held card. Open-bid timing and self-transfer remain guarded pending clarification.'
|
||||
: definition.card.effect === 'nullentropyBox'
|
||||
@@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
: definition.card.effect === 'portableSnooper'
|
||||
? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.'
|
||||
: definition.card.effect === 'semutaDrug'
|
||||
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean public discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.'
|
||||
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean public discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase before lot settlement, and three battle batches. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.'
|
||||
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||
],
|
||||
...(definition.card.effect === 'semutaDrug'
|
||||
@@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation' as const,
|
||||
status: 'Partial' as const,
|
||||
detail:
|
||||
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer, then its original response is canceled 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 buying a normal auction lot, Ix-deck Thumper/Amal and three battle discard stages. Karama’s physical cost is discarded before a neutral offer; the original response is canceled or the reserved lot is bought once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Neutral offers, owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, nested responses, pending rewards, other producers and full-hand exchange remain unfinished.',
|
||||
},
|
||||
{
|
||||
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, Ix-deck Thumper/Amal and three battle batches from privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
|
||||
'The guide distinguishes clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase, 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, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity, 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, Ix-deck Thumper/Amal and three battle discard stages. Other Karama uses, pending rewards, full games and live deployment remain unverified.',
|
||||
evidence: [
|
||||
'tests/semuta-engine.test.ts',
|
||||
'tests/semuta-recovery.test.ts',
|
||||
|
||||
@@ -48,6 +48,67 @@ function fixture(ix = false) {
|
||||
return { g, semuta, hajr };
|
||||
}
|
||||
|
||||
void test('printed Karama purchase offers its spent card before one normal auction settlement', () => {
|
||||
const { g, semuta, hajr } = fixture();
|
||||
player(g, 'a').hand = [take(g, 'karama')];
|
||||
g.deck.push(hajr);
|
||||
const karama = player(g, 'a').hand[0];
|
||||
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 physical = (state: Game) => [
|
||||
...inventory(state), ...(state.auction?.cards.slice(state.auction.index) ?? []).map(card => card.id),
|
||||
].sort();
|
||||
const original = physical(g);
|
||||
const pending = applyAction(g, 'a', { type: 'card', card: karama.id, mode: 'purchase' });
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaPurchaseDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(pending.auction?.cards[0].id, lot.id);
|
||||
assert.equal(pending.auction?.bidder, null);
|
||||
assert.equal(player(pending, 'a').hand.some(card => card.id === lot.id), false);
|
||||
assert.equal(viewGame(pending, 'r').semutaReaction?.canCommit, true);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
|
||||
for (const mutate of [
|
||||
(state: Game) => { state.auction!.cards[0].name = 'Forged lot'; },
|
||||
(state: Game) => { state.richeseBidding!.position = 'first'; },
|
||||
(state: Game) => { state.players[1].spice += 1; },
|
||||
(state: Game) => {
|
||||
const c = state.pendingTreacheryDiscard!.continuation;
|
||||
if (c.kind === 'karamaPurchaseDiscard') c.stateSignature = 'wrong';
|
||||
},
|
||||
]) {
|
||||
const altered = reload(pending);
|
||||
mutate(altered);
|
||||
const before = JSON.stringify(altered);
|
||||
assert.throws(() => applyAction(altered, 'r', { type: 'semutaCommit', event }));
|
||||
assert.equal(JSON.stringify(altered), before);
|
||||
}
|
||||
let declined = reload(pending);
|
||||
for (const id of ['a', 'r', 'e'])
|
||||
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
||||
const claimed = applyAction(reload(pending), 'r', botActions(viewGame(pending, 'r'))[0]!);
|
||||
for (const state of [declined, claimed]) {
|
||||
assert.equal(state.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(state, 'a').hand.filter(card => card.id === lot.id).length, 1);
|
||||
assert.equal(state.auction, null);
|
||||
assert.equal(state.phase, 3);
|
||||
assert.equal(state.richeseBidding?.stage, 'cacheOffer');
|
||||
assert.equal(state.decision?.kind, 'richeseCache');
|
||||
assert.deepEqual(physical(state), original);
|
||||
assert.throws(() => applyAction(state, 'r', { type: 'semutaCommit', event }));
|
||||
}
|
||||
assert.equal(declined.discard.filter(card => card.id === karama.id).length, 1);
|
||||
assert.equal(player(claimed, 'r').hand.filter(card => card.id === karama.id).length, 1);
|
||||
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
|
||||
assert.equal(player(claimed, 'a').spice, player(declined, 'a').spice);
|
||||
});
|
||||
|
||||
void test('a clean public discard offers every seat the same event, then a committed sole Semuta claim resumes once across JSON', () => {
|
||||
const { g, semuta, hajr } = fixture();
|
||||
const original = inventory(g);
|
||||
|
||||
@@ -728,6 +728,69 @@ void test('authenticated pre-plan Sapho discard restores one aggressor choice be
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('authenticated printed Karama purchase preserves the lot across a saved Semuta claim', async () => {
|
||||
const f = unitStore();
|
||||
try {
|
||||
const made = await f.rooms.createRoom('Semuta Purchase', 'richese', false, ['choam']);
|
||||
const code = made.view.code;
|
||||
const buyer = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
|
||||
const third = await f.rooms.joinRoom(code, 'Emperor', 'emperor');
|
||||
const auths = await Promise.all([made.token!, buyer.token!, third.token!]
|
||||
.map(token => f.restart().authenticate(code, token)));
|
||||
const existing = await f.restart().readRoom(code);
|
||||
const g = createGame(code, newPlayer(auths[0].playerId, 'Richese', 'richese'), false, ['choam']);
|
||||
g.players.push(newPlayer(auths[1].playerId, 'Atreides', 'atreides'),
|
||||
newPlayer(auths[2].playerId, 'Emperor', 'emperor'));
|
||||
Object.assign(g, { status: 'playing', phase: 3, active: auths[1].playerId,
|
||||
order: auths.map(auth => auth.playerId), deck: baseDeck(),
|
||||
richeseCache: richeseCards(), semutaPreview: true });
|
||||
for (const p of g.players) {
|
||||
p.hand = [];
|
||||
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
|
||||
p.traitorChoices = [];
|
||||
}
|
||||
const karama = take(g, 'karama'), semuta = take(g, SEMUTA_DRUG_ID);
|
||||
const lot = g.deck.shift()!;
|
||||
seat(g, auths[1].playerId).hand.push(karama);
|
||||
seat(g, auths[0].playerId).hand.push(semuta);
|
||||
g.auction = { cards: [lot], index: 0, bid: 0, bidder: null,
|
||||
active: auths[1].playerId, passed: [], opener: 0 };
|
||||
g.richeseBidding = { owner: auths[0].playerId, event: 'richese-normal:1',
|
||||
turn: g.turn, stage: 'normal', position: 'last', normalCount: 1,
|
||||
blackMarketSold: false, cacheCanceled: false, opener: 0 };
|
||||
g.richeseFunding = {};
|
||||
g.version = existing.version + 1;
|
||||
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
|
||||
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
|
||||
await f.restart().act(code, auths[1], g.version,
|
||||
{ type: 'card', card: karama.id, mode: 'purchase' }, clock);
|
||||
const pending = await f.restart().readRoom(code);
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.equal(pending.auction?.cards[0].id, lot.id);
|
||||
assert.equal(seat(pending, auths[1].playerId).hand.some(card => card.id === lot.id), false);
|
||||
await f.restart().continueRoomAutomatic(code, clock);
|
||||
assert.equal((await f.restart().readRoom(code)).version, pending.version);
|
||||
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
await f.restart().act(code, auths[0], pending.version,
|
||||
{ type: 'semutaCommit', event }, clock);
|
||||
const claimed = await f.restart().readRoom(code);
|
||||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||
assert.equal(seat(claimed, auths[1].playerId).hand.filter(card => card.id === lot.id).length, 1);
|
||||
assert.equal(seat(claimed, auths[0].playerId).hand.filter(card => card.id === karama.id).length, 1);
|
||||
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
|
||||
assert.equal(claimed.auction, null);
|
||||
assert.equal(claimed.richeseBidding?.stage, 'cacheOffer');
|
||||
assert.equal(claimed.decision?.kind, 'richeseCache');
|
||||
await assert.rejects(f.restart().act(code, auths[0], pending.version,
|
||||
{ type: 'semutaCommit', event }, clock));
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
|
||||
for (const auth of auths)
|
||||
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('authenticated printed Karama cost pauses and restores one canceled CHOAM Charity response', async () => {
|
||||
const f = unitStore();
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user