Offer Semuta after completed CHOAM card sales

This commit is contained in:
Quintine committed 2026-09-30 14:26:05 +10:00
1 parent 9bfc3ec037
commit 4c07e1bbd1
8 files changed
+333 -16

No files matched your search

+19 -4
View File
@@ -12,12 +12,14 @@ the physical Ix deck. Clean public ordinary-card, paid Box, retired
Ornithopter, used Distrans, final Truthtrance, four bounded Sapho,
pre-leader Residual Poison, printed Karama canceling clean CHOAM
Charity, Inflation or Bene Gesserit Charity or settling a clean
normal-auction lot, battle-card and Ix-deck Thumper/Amal discards
open the same neutral offer regardless of hidden Semuta custody.
normal-auction lot, a clean completed CHOAM card sale, battle-card
and Ix-deck Thumper/Amal discards open the same neutral offer
regardless of hidden Semuta custody.
A clean **private** normal-auction Ixian ally replacement discard
also pauses before its draw; no unknown face is shown before a
holder commits. Karama waits after its public cost but before
cancellation or the reserved lot settles.
holder commits. A completed CHOAM sale pays its quoted bank spice
before the offer and resumes the same market afterward. Karama waits
after its public cost but before cancellation or the reserved lot settles.
Residual Poison commits one random death before the offer; only an
uninspected battle with nobody ready qualifies. Once Around commits
bidder order and frees a slot; other Sapho scopes commit chooser or
@@ -33,6 +35,15 @@ full-hand ordering, normal Richese starts and deployed acceptance
remain unfinished. Neutral passes and free-slot guard are product
choices.
The completed CHOAM card-sale reaction passed two- and three-spice
engine cases, canceled-sale exclusion, authenticated SQLite recovery,
direct engine smoke and rendered phone-width guide inspection.
`npm run check` passed types, lint and 5,843 offline tests; the build
passed. The isolated built-worker HTTP suite passed 50/55; four
origin/validation cases returned 503 instead of 403/400 and one
Advanced-lobby request timed out after 15 seconds. Full HTTP
acceptance remains open.
The private normal-auction Ixian ally reaction passed genuine-setup
engine, authenticated SQLite and direct engine smoke checks. The full
`npm run check` passed types, lint and 5,839 offline tests; build and
@@ -53,6 +64,10 @@ The following paid Karama revision `8f6b1c8` also passed container
workflow `36665999972` through isolated storage/HTTP verification
and published its image. Neither checkpoint has been deployed to the NAS.
The subsequent private Ixian ally revision `9bfc3ec` passed workflow
`36667534804` and published after isolated storage/HTTP verification.
It too is not a live NAS deployment.
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
+29
View File
@@ -1,5 +1,34 @@
# Dune implementation status
## 30 September 2026 — Semuta after completed CHOAM card sale
In the explicit Richese preview, a clean CHOAM market sale now pays
its printed two- or three-spice income and discards the one publicly
named physical card before a neutral Semuta offer. A canceled or
invalid sale leaves its card in hand and creates no fresh batch.
Claim or all-seat decline resumes the same CHOAM market choice
without repeating income or disposal. The saved receipt binds
owner, exact card, duplicate witness if used, earned balance and
the open market. Concurrent market obligations and other sale
families retain their earlier automatic paths.
Focused engine checks cover hidden Semuta-holder parity in outsider
views, a quoted Worthless sale, income once, exact card custody,
rejected altered receipts, legal AI claim and resumed market choice.
Authenticated SQLite checks cover restart, stale retry, preserved
seats and the next market decision. A direct engine smoke exercised
the committed income, one claim and same market return. The public
expansion and complete Semuta gates remain closed.
`npm run check` passed types, lint and 5,843 offline tests; the
production build passed. The phone-width built-worker guide showed
the sold-card timing. Independent review found no scoped defect.
The isolated built-worker HTTP suite passed 50/55: four origin/
validation requests returned 503 instead of expected 403/400,
and one Advanced-lobby request timed out after 15 seconds.
Full HTTP and production acceptance remain open; no production
game was touched.
## 30 September 2026 — Semuta before private Ixian ally replacement
The explicit Richese preview with the Ix expansion now interrupts a
+8
View File
@@ -73,6 +73,14 @@ sale continuation. Richese cache and Black Market sales retain the
automatic path until their distinct custody and offer windows are
integrated.
A clean completed CHOAM market sale stages `choamSaleDiscard` only after
the accepted public card disposal and its quoted two- or three-spice
income. The saved receipt binds the sold card, duplicate witness when
present, earned balance and still-open market; claim or decline
restores the same owner's next market decision without paying again.
Canceled/invalid sales have no batch; competing market parents retain
their automatic continuation.
Completed Ornithopter movement now offers the same retired public card
before its already committed arrival suffix only in a roster without
Bene Gesserit intrusion, Ecaz Ambassador or Moritani Terror response
+12 -3
View File
@@ -67,9 +67,10 @@ opening public expansion starts. It interrupts **clean public
ordinary-card, paid Box, retired Ornithopter, used Distrans, final
Truthtrance, four bounded Sapho scopes, pre-leader Residual Poison,
printed Karama canceling clean CHOAM Charity, Inflation or Bene
Gesserit Charity or settling a clean normal-auction lot, battle and
Ix-deck Thumper/Amal discards**, plus a clean **private** normal-auction
Ixian ally replacement discard before its draw. Other
Gesserit Charity or settling a clean normal-auction lot, a completed
CHOAM card sale, battle and Ix-deck Thumper/Amal discards**, plus
a clean **private** normal-auction Ixian ally replacement discard
before its draw. Other
than the one explicitly suspended charity/Inflation response, the
parent transaction has no competing response. Every seat sees the
same neutral fresh-discard opportunity
@@ -82,6 +83,14 @@ removes it before the saved single replacement draw and subsequent
normal-auction settlement. Richese cache and Black Market replacement
discards, competing parents and other private producers remain separate.
A clean CHOAM card sale commits its one named public discard and the
quoted two- or three-spice bank payment before the neutral offer.
Canceled or invalid sales leave the card in hand and have no fresh
batch. The saved receipt binds the exact sold card, optional duplicate
witness, earned spice and still-open market; claim or decline resumes
the market once without paying or discarding again. Nested card
responses and other sale families retain their previous paths.
An early-ended Ornithopter advances the saved queue; a completed
flight resumes one committed arrival only without BG, Ecaz or
Moritani entry reactions. Distrans's gift remains private with its
+70 -5
View File
@@ -1205,6 +1205,15 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'choamSaleDiscard';
owner: string;
card: string;
witness: string | null;
price: number;
spiceAfter: number;
stateSignature: string;
}
| {
kind: 'thumperDiscard';
player: string;
@@ -4910,6 +4919,19 @@ function karamaAuctionDiscardSignature(g: Game, c: KaramaAuctionDiscardContinuat
})),
});
}
type ChoamSaleDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'choamSaleDiscard' }
>;
function choamSaleDiscardSignature(g: Game, c: ChoamSaleDiscardContinuation) {
const owner = getPlayer(g, c.owner);
return JSON.stringify({
owner: c.owner, card: c.card, witness: c.witness, price: c.price,
spiceAfter: c.spiceAfter, turn: g.turn, phase: g.phase,
active: g.active, order: g.order, market: g.choamMarket,
hand: owner.hand.map(card => card.id), spice: owner.spice,
});
}
type ThumperDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'thumperDiscard' }
@@ -5516,6 +5538,25 @@ function treacheryDiscardIntegrity(g: Game) {
'The spent Karama no longer matches its reserved auction settlement.',
);
normalKaramaAuction(g, getPlayer(g, c.player), c.kind === 'karamaPaymentDiscard');
} else if (continuation?.kind === 'choamSaleDiscard') {
const c = continuation, entry = batch.entries[0];
const owner = g.players.find(p => p.id === c.owner);
requireRule(
g.choamMarket?.owner === c.owner && !g.choamMarket.sale &&
!g.choamMarket.trade && !g.response && !g.decision &&
!g.pendingKarama && !g.phaseOpening &&
owner?.faction === 'choam' &&
batch.cause === 'choamSale' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.owner &&
entry.card.id === c.card &&
(c.witness === null
? c.price === 2 && entry.card.kind === 'worthless'
: c.price === 3 && owner.hand.some(card =>
card.id === c.witness && card.name === entry.card.name)) &&
owner.spice === c.spiceAfter &&
c.stateSignature === choamSaleDiscardSignature(g, c),
'The sold CHOAM card no longer matches its saved income and market.',
);
} else if (continuation?.kind === 'thumperDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
@@ -6181,6 +6222,7 @@ function semutaOfferSupported(
continuation.kind !== 'thumperDiscard' &&
continuation.kind !== 'amalDiscard' &&
continuation.kind !== 'ixAllyCard' &&
continuation.kind !== 'choamSaleDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
@@ -6247,6 +6289,7 @@ function semutaOfferSupported(
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved' ||
continuation.kind === 'ixAllyCard' ||
continuation.kind === 'choamSaleDiscard' ||
(continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard' ||
continuation.kind === 'karamaBgCharityDiscard' ||
@@ -6269,7 +6312,11 @@ function semutaOfferSupported(
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
!g.pendingHomeworldShipment && !g.pendingRicheseGift &&
!g.pendingRichesePurchaseIncome && !g.choamMarket &&
!g.pendingRichesePurchaseIncome &&
(continuation.kind === 'choamSaleDiscard'
? !!g.choamMarket && !g.choamMarket.sale &&
!g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale
: !g.choamMarket) &&
!g.pendingChoamMarketGhola;
}
function semutaContext(g: Game, owner: Player): SemutaContext {
@@ -6410,6 +6457,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
// CHOAM's selected-count payout was already committed with its discard.
g.pendingAmbassador = next.entry;
finishAmbassador(g);
} else if (next.kind === 'choamSaleDiscard') {
resumeChoamMarket(g);
} else if (next.kind === 'nullentropyDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -20924,18 +20973,34 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
const saleBlock = saleCard
? homeworldRule(() => homeworldWorthlessSaleBlock(g, owner.id, saleCard))
: null;
if (quoteSale(owner.hand, sale.card, sale.witness) && !saleBlock) {
discard(g, owner, sale.card);
const quoted = quoteSale(owner.hand, sale.card, sale.witness);
if (quoted?.price === sale.price && !saleBlock) {
const used = discard(g, owner, sale.card);
owner.spice += sale.price;
log(g, `${owner.name} received ${sale.price} spice from the card sale.`);
} else
delete market.sale;
if (g.semutaPreview) {
const continuation: ChoamSaleDiscardContinuation = {
kind: 'choamSaleDiscard', owner: owner.id, card: used.id,
witness: sale.witness ?? null, price: sale.price,
spiceAfter: owner.spice, stateSignature: '',
};
const entries = [{ card: used, discardedBy: owner.id, publicFace: true }];
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = choamSaleDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'choamSale', entries, continuation);
return;
}
}
} else {
log(
g,
saleBlock
? 'Tupile is now high population, so the declared Worthless sale cannot finish. The card stays in hand and no spice is paid.'
: 'The declared card sale is no longer possible; no spice was paid.',
);
delete market.sale;
delete market.sale;
}
resumeChoamMarket(g);
} else if (response.kind === 'choamInflation') {
if (!canceled) {
+4 -4
View File
@@ -1156,7 +1156,7 @@ export const RULE_TOPICS: RuleTopic[] = [
: definition.card.effect === 'portableSnooper'
? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.'
: definition.card.effect === 'semutaDrug'
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.'
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, final Truthtrance, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, and three battle batches. A private normal-auction Ixian ally replacement also pauses before drawing, without showing its face until a holder commits. Commit held Semuta with a free hand slot; for multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, inspected/partly ready battles, nested responses, pending rewards, full-hand sequencing and public starts remain unfinished.'
: 'Reference component only: this Richese card’s game actions are not enabled.',
],
...(definition.card.effect === 'semutaDrug'
@@ -1166,7 +1166,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation' as const,
status: 'Partial' as const,
detail:
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement also pauses before its draw with a neutral, face-hidden offer. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, private discards, nested responses, pending rewards and full-hand exchange remain unfinished.',
'The Richese preview interrupts clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity or settling a normal auction purchase or winning bid, a completed CHOAM card sale, Ix-deck Thumper/Amal and three battle discard stages. A private normal-auction Ixian ally replacement pauses before its draw with a neutral, face-hidden offer. CHOAM sale income is committed before its neutral offer and the same market resumes once. Karama’s physical cost is discarded before its neutral offer; the original response is canceled or the reserved lot settles once. Residual Poison commits one random leader death; Sapho commits its finite priority. Thumper waits before worm/spice draw; Amal restores an already-halved opening. Owner-only candidates and one physical Semuta transfer retain privacy. Other Karama uses, private discards, nested responses, pending rewards and full-hand exchange remain unfinished.',
},
{
area: 'Player controls' as const,
@@ -1184,13 +1184,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Documentation' as const,
status: 'Partial' as const,
detail:
'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
'The guide distinguishes clean public ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, Ix-deck Thumper/Amal and three battle batches from a private normal-auction Ixian ally replacement, privacy-neutral acknowledgements, provisional free-slot policy and remaining discard sources.',
},
{
area: 'Verification' as const,
status: 'Partial' as const,
detail:
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, pending rewards, full games and live deployment remain unverified.',
'Genuine setup, custody, hidden-hand parity, saved JSON, authenticated SQLite continuation and legal AI participation cover clean ordinary-card, paid Box, Ornithopter, Distrans, Truthtrance, four Sapho scopes, pre-leader Residual Poison, printed Karama cancellation of CHOAM Charity, Inflation or Bene Gesserit Charity or clean normal-auction purchase or winning-bid payment, completed CHOAM card sales, Ix-deck Thumper/Amal, a private normal-auction Ixian ally replacement and three battle discard stages. Other private producers, Karama uses, pending rewards, full games and live deployment remain unverified.',
evidence: [
'tests/semuta-engine.test.ts',
'tests/semuta-recovery.test.ts',
+114
View File
@@ -4,6 +4,7 @@ import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
RuleError,
type Game,
@@ -13,6 +14,8 @@ import { saleOptions } from '../game/choam-market';
import { createTechTokens } from '../game/tech-tokens';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { richeseCards } from '../game/richese-cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
function fixture() {
const g = createGame('MARKET22', newPlayer('c', 'CHOAM', 'choam'), false, [
'choam',
@@ -129,6 +132,117 @@ void test('Worthless sale reveals its card but only discards and pays after the
assert.equal(g.phase, 2);
assert.deepEqual(live(g), live(initial));
});
void test('clean CHOAM sale offers Semuta after committed income before the market resumes', () => {
const g = fixture();
g.players[1] = newPlayer('e', 'Richese', 'richese');
g.players[1].spice = 20;
g.richeseCache = richeseCards();
g.semutaPreview = true;
const index = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(index >= 0);
const semuta = g.richeseCache.splice(index, 1)[0];
g.players[1].hand.push(semuta);
contest(g, 1, 'b');
const sold = hold(g, 'c', 'Baliset');
const original = live(g);
const absent = structuredClone(g);
absent.players[1].hand = [absent.richeseCache!.splice(0, 1)[0]];
absent.richeseCache!.push(semuta);
const offered = (state: Game) => allow(sell(ready(state), sold.id));
const pending = offered(g), neutral = offered(absent);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamSaleDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.choamMarket?.sale, undefined);
assert.equal(pending.decision, null);
assert.equal(pending.players[0].spice, 22);
assert.equal(pending.players[0].hand.some(card => card.id === sold.id), false);
assert.equal(pending.discard.filter(card => card.id === sold.id).length, 1);
assert.deepEqual(viewGame(pending, 'c'), viewGame(neutral, 'c'));
assert.deepEqual(viewGame(pending, 'b'), viewGame(neutral, 'b'));
assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true);
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending);
for (const change of [
(state: Game) => {
const next = state.pendingTreacheryDiscard!.continuation;
if (next.kind === 'choamSaleDiscard') next.price = 3;
},
(state: Game) => { state.players[0].spice += 2; },
(state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; },
]) {
const corrupt: Game = JSON.parse(JSON.stringify(pending));
change(corrupt);
const before = JSON.stringify(corrupt);
assert.throws(() => applyAction(corrupt, 'e', { type: 'semutaCommit', event }));
assert.equal(JSON.stringify(corrupt), before);
}
let declined: Game = JSON.parse(JSON.stringify(pending));
for (const id of ['c', 'b', 'e'])
declined = applyAction(declined, id, { type: 'semutaPass', event });
const claimed = applyAction(JSON.parse(JSON.stringify(pending)), 'e',
botActions(viewGame(pending, 'e'))[0]!);
for (const state of [declined, claimed]) {
assert.equal(state.pendingTreacheryDiscard, null);
assert.equal(state.decision?.kind, 'choamMarket');
assert.equal(state.players[0].spice, 22);
assert.deepEqual(live(state), original);
assert.throws(() => applyAction(state, 'e', { type: 'semutaCommit', event }));
}
assert.equal(declined.discard.filter(card => card.id === sold.id).length, 1);
assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
const resumed = done(claimed);
assert.equal(resumed.choamMarket, null);
assert.equal(resumed.phase, 3);
});
void test('CHOAM duplicate sale retains its witness and three spice through Semuta', () => {
const g = fixture();
g.players[1] = newPlayer('e', 'Richese', 'richese');
g.players[1].spice = 20;
g.richeseCache = richeseCards();
g.semutaPreview = true;
const semutaIndex = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
const semuta = g.richeseCache.splice(semutaIndex, 1)[0];
g.players[1].hand.push(semuta);
contest(g, 1, 'b');
const sold = hold(g, 'c', 'Snooper'), witness = hold(g, 'c', 'Snooper');
const pending = allow(sell(ready(g), sold.id, witness.id));
const continuation = pending.pendingTreacheryDiscard?.continuation;
assert.equal(continuation?.kind, 'choamSaleDiscard');
if (continuation?.kind === 'choamSaleDiscard') {
assert.equal(continuation.witness, witness.id);
assert.equal(continuation.price, 3);
}
assert.equal(pending.players[0].spice, 23);
assert.deepEqual(pending.players[0].hand.map(card => card.id), [witness.id]);
const event = pending.pendingTreacheryDiscard!.batch.event;
const claimed = applyAction(pending, 'e', { type: 'semutaCommit', event });
assert.equal(claimed.players[0].spice, 23);
assert.deepEqual(claimed.players[0].hand.map(card => card.id), [witness.id]);
assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(claimed.decision?.kind, 'choamMarket');
});
void test('canceled CHOAM sale creates no Semuta event or bank income', () => {
const g = fixture();
g.players[1] = newPlayer('e', 'Richese', 'richese');
g.richeseCache = richeseCards();
g.semutaPreview = true;
const semutaIndex = g.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
g.players[1].hand.push(g.richeseCache.splice(semutaIndex, 1)[0]);
contest(g, 1, 'b');
const sold = hold(g, 'c', 'Baliset');
const declaration = sell(ready(g), sold.id);
const karama = declaration.players[2].hand.find(card => card.effect === 'karama')!;
const canceled = applyAction(declaration, 'b',
{ type: 'card', card: karama.id, mode: 'cancel' });
assert.ok(!canceled.pendingTreacheryDiscard);
assert.equal(canceled.players[0].spice, 20);
assert.equal(canceled.players[0].hand.filter(card => card.id === sold.id).length, 1);
assert.equal(canceled.choamMarket?.sale, undefined);
assert.equal(canceled.decision?.kind, 'choamMarket');
});
void test('surplus sales keep one exact duplicate and permit selling several surplus copies', () => {
let g = fixture();
const cards = [
+77
View File
@@ -808,6 +808,83 @@ void test('authenticated private Ixian ally discard waits for Semuta before its
} finally { f.sqlite.close(); }
});
void test('authenticated CHOAM sale retains paid spice and market after a saved Semuta claim', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Sale', 'choam', false, ['choam']);
const code = made.view.code;
const richese = await f.rooms.joinRoom(code, 'Richese', 'richese');
const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit');
const auths = await Promise.all([made.token!, richese.token!, bg.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
let g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'),
newPlayer(auths[2].playerId, 'BG', 'beneGesserit'));
Object.assign(g, { status: 'playing', phase: 2, order: auths.map(auth => auth.playerId),
deck: baseDeck(), richeseCache: richeseCards(), semutaPreview: true,
choamCharity: { turn: 1, canceled: false } });
for (const p of g.players) {
p.spice = 20;
p.hand = [];
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const semuta = take(g, SEMUTA_DRUG_ID), karama = take(g, 'karama');
const index = g.deck.findIndex(card => card.name === 'Baliset');
assert.ok(index >= 0);
const sold = g.deck.splice(index, 1)[0];
g.players[0].hand.push(sold);
g.players[1].hand.push(semuta);
g.players[2].hand.push(karama);
for (const p of g.players)
g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'choamMarket');
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const current = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], current.version, action, clock);
return f.restart().readRoom(code);
};
let current = await act(0, { type: 'decision', mode: 'sell', card: sold.id });
assert.equal(current.response?.kind, 'choamSale');
for (let i = 0; current.response?.kind === 'choamSale' && i < 3; i++) {
const index = auths.findIndex(auth =>
!viewGame(current, auth.playerId).responseControls?.hasPassed &&
!!viewGame(current, auth.playerId).responseControls?.cancelCards.length);
assert.ok(index >= 0);
current = await act(index, { type: 'passResponse' });
}
const event = current.pendingTreacheryDiscard!.batch.event;
assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(current.players[0].spice, 22);
assert.equal(current.choamMarket?.sale, undefined);
assert.equal(current.decision, null);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, current.version);
const ownerView = await f.restart().readSeatView(code, auths[1]);
assert.equal(ownerView.semutaReaction?.canCommit, true);
assert.deepEqual((await f.restart().readSeatView(code, auths[0])).semutaReaction?.candidates, []);
const claimed = await act(1, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.decision?.kind, 'choamMarket');
assert.equal(claimed.players[0].spice, 22);
assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
await assert.rejects(f.restart().act(code, auths[1], current.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));
const resumed = await act(0, { type: 'decision', done: true });
assert.equal(resumed.phase, 3);
assert.equal(resumed.players[0].spice, 22);
} finally { f.sqlite.close(); }
});
void test('authenticated printed Karama auction payment resumes one saved Richese cache offer', async () => {
const f = unitStore();
try {