Offer Semuta before clean CHOAM Kulon movement bonus

This commit is contained in:
Quintine committed 2026-09-30 14:48:14 +10:00
1 parent 8f67b39735
commit 88bcb33342
8 files changed
+273 -18

No files matched your search

+13 -3
View File
@@ -12,14 +12,16 @@ 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, a clean completed CHOAM card sale, battle-card
and Ix-deck Thumper/Amal discards open the same neutral offer
regardless of hidden Semuta custody.
normal-auction lot, a completed CHOAM sale or a clean Kulon
Worthless discard, 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. 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.
Kulon's public card is spent before this offer; its single movement
range bonus and same turn resume only after claim or all-seat decline.
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
@@ -35,6 +37,14 @@ full-hand ordering, normal Richese starts and deployed acceptance
remain unfinished. Neutral passes and free-slot guard are product
choices.
Clean Kulon-before-bonus continuation passed engine, authenticated
SQLite and genuine-setup targeted smoke checks. `npm run check`
passed types, lint and 5,845 offline tests; build and phone-width
guide inspection passed. Direct Wrangler-dev HTTP passed 48/55;
six tests saw HTTP 503 instead of their expected status, and one
Advanced-lobby request timed out at 15 seconds. The proxy-free
container run for this change remains to be verified after push.
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.
+29
View File
@@ -1,5 +1,34 @@
# Dune implementation status
## 30 September 2026 — Semuta after clean CHOAM Kulon cost
In the explicit Richese preview, a clean accepted Kulon Worthless
response now discards its public physical card and opens the same
neutral Semuta opportunity before adding CHOAM's turn-scoped
movement range. A claimant takes that exact card; all-seat decline
leaves it in the pile. Both paths add one range once and return
the still-active mover to the same movement turn. The saved receipt
binds the spent card, owner, prior bonus, hand and movement state.
Reactive movement, fixed Ornithopter range, Nexus and other
Worthless effects remain outside this bounded reaction.
Focused engine checks cover hidden-holder parity, pre-bonus blocking,
legal AI claim, physical custody, forged receipt rejection,
claim/decline and the resulting legal longer move. Authenticated
SQLite checks retain the offer and exact bonus through restart,
preserve seats/private views and reject stale replay. A direct engine
smoke exercised the accepted card cost, one claim and resumed mover.
Public expansion and complete Semuta acceptance remain gated.
`npm run check` passed types, lint and 5,845 offline tests; the
production build and phone-width guide inspection passed. A
targeted phase-five smoke after genuine Richese/CHOAM/BG setup also
restored the active mover and one range bonus after a claim.
Independent review found no scoped defect. Direct Wrangler-dev
HTTP passed 48/55: six tests saw HTTP 503 and one
15-second Advanced-lobby timeout. The proxy-free container/full
HTTP gate for this revision and live NAS acceptance are still open.
## 30 September 2026 — Semuta after completed CHOAM card sale
In the explicit Richese preview, a clean CHOAM market sale now pays
+7
View File
@@ -81,6 +81,13 @@ restores the same owner's next market decision without paying again.
Canceled/invalid sales have no batch; competing market parents retain
their automatic continuation.
Clean Kulon stages `choamKulonDiscard` after its accepted public
Worthless card cost but before adding one turn-scoped movement range.
The receipt binds the active CHOAM player, prior bonus, card and
unspent movement. A neutral claim or decline restores the same
one-range increment without replaying the response. Reactive,
Nexus and overlapping movement parents retain their automatic path.
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
+10 -1
View File
@@ -68,7 +68,8 @@ 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, a completed
CHOAM card sale, battle and Ix-deck Thumper/Amal discards**, plus
CHOAM sale or clean Kulon Worthless discard, 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
@@ -91,6 +92,14 @@ 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.
A clean accepted Kulon Worthless response now places its named public
card in discard before the neutral offer. No movement bonus accrues
until claim or decline retires that event; the saved receipt binds
the original active mover, prior turn bonus, card and hand custody.
The same one-range effect then applies exactly once. Fixed-range
Ornithopter combinations, reactive movement parents and other
Worthless powers retain their original automatic 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
+72 -10
View File
@@ -1214,6 +1214,13 @@ export type Game = {
spiceAfter: number;
stateSignature: string;
}
| {
kind: 'choamKulonDiscard';
player: string;
card: string;
before: number;
stateSignature: string;
}
| {
kind: 'thumperDiscard';
player: string;
@@ -4932,6 +4939,23 @@ function choamSaleDiscardSignature(g: Game, c: ChoamSaleDiscardContinuation) {
hand: owner.hand.map(card => card.id), spice: owner.spice,
});
}
type ChoamKulonDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'choamKulonDiscard' }
>;
function choamKulonDiscardSignature(g: Game, c: ChoamKulonDiscardContinuation) {
const owner = getPlayer(g, c.player);
return JSON.stringify({
player: c.player, card: c.card, before: c.before,
turn: g.turn, phase: g.phase, active: g.active, order: g.order,
movement: g.choamMovement, remaining: g.movementRemaining,
parent: nullentropyParentSignature(g,
{ response: null, decision: null, pendingKarama: null, phaseOpening: null },
`choam:kulon:${c.card}`),
hand: owner.hand.map(card => card.id), moved: owner.moved,
shipped: owner.shipped, spice: owner.spice,
});
}
type ThumperDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'thumperDiscard' }
@@ -5557,6 +5581,23 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === choamSaleDiscardSignature(g, c),
'The sold CHOAM card no longer matches its saved income and market.',
);
} else if (continuation?.kind === 'choamKulonDiscard') {
const c = continuation, entry = batch.entries[0];
const owner = g.players.find(p => p.id === c.player);
requireRule(
g.phase === 5 && g.active === c.player &&
owner?.faction === 'choam' && owner.moved < movesAllowed(g, owner) &&
!g.pendingChoamWorthless && !g.response && !g.decision &&
!g.phaseOpening && !g.ornithopter && !g.pendingChoamMove &&
batch.cause === 'choam:kulon' && batch.entries.length === 1 &&
entry.publicFace && entry.discardedBy === c.player &&
entry.card.id === c.card && entry.card.kind === 'worthless' &&
entry.card.name === 'Kulon' &&
Number.isSafeInteger(c.before) && c.before >= 0 &&
c.before === (g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) &&
c.stateSignature === choamKulonDiscardSignature(g, c),
'The used Kulon no longer matches its pending movement bonus.',
);
} else if (continuation?.kind === 'thumperDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
@@ -6223,6 +6264,7 @@ function semutaOfferSupported(
continuation.kind !== 'amalDiscard' &&
continuation.kind !== 'ixAllyCard' &&
continuation.kind !== 'choamSaleDiscard' &&
continuation.kind !== 'choamKulonDiscard' &&
!(continuation.kind === 'battleCleanup' && continuation.source === 'winner') &&
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleResolved' &&
@@ -6290,6 +6332,7 @@ function semutaOfferSupported(
continuation.kind === 'battleResolved' ||
continuation.kind === 'ixAllyCard' ||
continuation.kind === 'choamSaleDiscard' ||
continuation.kind === 'choamKulonDiscard' ||
(continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard' ||
continuation.kind === 'karamaBgCharityDiscard' ||
@@ -6309,6 +6352,9 @@ function semutaOfferSupported(
(continuation.kind === 'saphoAuctionDiscard' || !g.richeseAuction) &&
(continuation.kind === 'saphoAggressorDiscard' ||
continuation.kind === 'residualPoisonDiscard' || !g.battle) &&
(continuation.kind !== 'choamKulonDiscard' ||
(!g.ornithopter && !g.pendingChoamMove && !g.summonedWorm &&
g.wormRides.length === 0)) &&
!g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy &&
!g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment &&
!g.pendingHomeworldShipment && !g.pendingRicheseGift &&
@@ -6459,6 +6505,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
finishAmbassador(g);
} else if (next.kind === 'choamSaleDiscard') {
resumeChoamMarket(g);
} else if (next.kind === 'choamKulonDiscard') {
completeChoamKulon(g, getPlayer(g, next.player));
} else if (next.kind === 'nullentropyDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -20277,6 +20325,13 @@ function currentPlacementCancellationQuote(g: Game, response: ResponseWindow) {
throw error;
}
}
function completeChoamKulon(g: Game, owner: Player) {
g.choamMovement = {
turn: g.turn,
bonus: (g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) + 1,
};
log(g, `${owner.name} used Kulon to move one extra territory this turn.`);
}
function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) {
karamaConversionIntegrity(g);
currentFactionPayment(g);
@@ -20626,18 +20681,25 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
(pending.effect !== 'jubba' ||
choamStormOptions(g).some((t) => t.territory === pending.location))
) {
discard(g, choam, pending.card);
const used = discard(g, choam, pending.card);
finishNexusChoam(g,pending,'complete');
if (pending.effect === 'kulon') {
g.choamMovement = {
turn: g.turn,
bonus:
(g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0) + 1,
};
log(
g,
`${choam.name} used Kulon to move one extra territory this turn.`,
);
if (g.semutaPreview && used.name === 'Kulon' &&
!pending.storm && !pending.revival && !pending.movement &&
!pending.mentat && !pending.nexusEvent) {
const continuation: ChoamKulonDiscardContinuation = {
kind: 'choamKulonDiscard', player: choam.id, card: used.id,
before: g.choamMovement?.turn === g.turn ? g.choamMovement.bonus : 0,
stateSignature: '',
};
const entries = [{ card: used, discardedBy: choam.id, publicFace: true }];
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = choamKulonDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'choam:kulon', entries, continuation);
return;
}
}
completeChoamKulon(g, choam);
} else if (pending.effect === 'jubba') {
(g.stormResolution!.choamProtected ??= []).push(pending.location!);
log(
+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, 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.'
? '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, clean Kulon Worthless disposal before its movement bonus, 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, 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.',
'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 sale, clean Kulon Worthless use, 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; Kulon adds its one movement range only after the 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.',
},
{
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, 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.',
'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, clean Kulon Worthless, 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, 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.',
'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, clean Kulon Worthless, 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',
+67
View File
@@ -4,6 +4,7 @@ import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
RuleError,
type Game,
@@ -14,6 +15,8 @@ import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
import { forceRevivalQuote, newRevivalRules } from '../game/revival';
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(advanced = false) {
const g = createGame('CWORTH22', newPlayer('c', 'CHOAM', 'choam'), advanced, [
'choam',
@@ -127,6 +130,70 @@ void test('Kulon adds one range in basic and advanced play only after its Karama
assert.throws(() => move(g, 1), /movement is not available/);
}
});
void test('clean Kulon discard offers Semuta before the one earned movement bonus', () => {
const initial = movement();
initial.players[1] = newPlayer('e', 'Richese', 'richese');
initial.richeseCache = richeseCards();
initial.semutaPreview = true;
const index = initial.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(index >= 0);
const semuta = initial.richeseCache.splice(index, 1)[0];
player(initial, 'e').hand.push(semuta);
contest(initial, 'b');
const card = named(initial, 'Kulon');
const physical = (state: Game) => [
...state.deck, ...state.discard, ...state.richeseCache!,
...state.players.flatMap(p => p.hand),
].map(c => c.id).sort();
const original = physical(initial);
const absent = structuredClone(initial);
player(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]];
absent.richeseCache!.push(semuta);
const offered = (state: Game) => allow(play(state, 'Kulon'));
const pending = offered(initial), neutral = offered(absent);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamKulonDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.choamMovement, undefined);
assert.equal(pending.discard.filter(c => c.id === card).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.equal(botActions(viewGame(pending, 'e'))[0]?.type, 'semutaCommit');
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending);
assert.throws(() => move(pending, 2));
for (const change of [
(state: Game) => {
const c = state.pendingTreacheryDiscard!.continuation;
if (c.kind === 'choamKulonDiscard') c.before = 3;
},
(state: Game) => { state.choamMovement = { turn: state.turn, bonus: 1 }; },
(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',
{ type: 'semutaCommit', event });
for (const state of [declined, claimed]) {
assert.equal(state.pendingTreacheryDiscard, null);
assert.deepEqual(state.choamMovement, { turn: state.turn, bonus: 1 });
assert.deepEqual(physical(state), original);
assert.equal(player(move(state, 2), 'c').moved, 1);
assert.throws(() => applyAction(state, 'e', { type: 'semutaCommit', event }));
}
assert.equal(declined.discard.filter(c => c.id === card).length, 1);
assert.equal(claimed.players[1].hand.filter(c => c.id === card).length, 1);
assert.equal(claimed.discard.filter(c => c.id === semuta.id).length, 1);
});
void test('Kulon adds to ornithopters and keeps the usual storm and alliance entry restrictions', () => {
let g = movement(true);
assert.throws(() => move(g, 4), /more than 3/);
+71
View File
@@ -808,6 +808,77 @@ void test('authenticated private Ixian ally discard waits for Semuta before its
} finally { f.sqlite.close(); }
});
void test('authenticated Kulon Semuta offer restores one movement bonus across restart', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Kulon', '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);
const 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: 5, turn: 2, storm: 18,
active: auths[0].playerId, order: auths.map(auth => auth.playerId),
movementRemaining: auths.map(auth => auth.playerId), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true });
for (const p of g.players) {
p.spice = 10;
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 === 'Kulon');
assert.ok(index >= 0);
const kulon = g.deck.splice(index, 1)[0];
g.players[0].hand.push(kulon);
g.players[0].forces = { 'red_chasm:7': 3 };
g.players[0].reserves = 11;
g.players[1].hand.push(semuta);
g.players[2].hand.push(karama);
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: 'card', mode: 'choam', card: kulon.id });
assert.equal(current.response?.kind, 'choamWorthless');
for (let i = 0; current.response?.kind === 'choamWorthless' && 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?.continuation.kind, 'choamKulonDiscard');
assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(current.choamMovement, undefined);
assert.equal(current.discard.filter(card => card.id === kulon.id).length, 1);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, current.version);
assert.equal((await f.restart().readSeatView(code, auths[1])).semutaReaction?.canCommit, true);
const claimed = await act(1, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.deepEqual(claimed.choamMovement, { turn: 2, bonus: 1 });
assert.equal(claimed.players[1].hand.filter(card => card.id === kulon.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));
} finally { f.sqlite.close(); }
});
void test('authenticated CHOAM sale retains paid spice and market after a saved Semuta claim', async () => {
const f = unitStore();
try {