From 756af19d18497bf163c70cb2386f6c48b1e15242 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:23:32 +1000 Subject: [PATCH] Offer Semuta after proactive CHOAM Baliset discard --- docs/CURRENT_STATUS.md | 23 +++++-- docs/IMPLEMENTATION_STATUS.md | 31 +++++++++ docs/SEMUTA_DISCARD_CONTINUATIONS.md | 9 +++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 13 +++- game/engine.ts | 76 +++++++++++++++++++--- tests/choam-baliset.test.ts | 74 ++++++++++++++++++++++ tests/semuta-recovery.test.ts | 90 +++++++++++++++++++++++++++ 7 files changed, 300 insertions(+), 16 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d4bf1a39..ba1e7067 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -12,8 +12,9 @@ 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 completed CHOAM sale or clean Kulon/La La La -Worthless discard, battle-card and Ix-deck Thumper/Amal discards +normal-auction lot, a completed CHOAM sale or clean +Kulon/La La La/Baliset Worthless, 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 @@ -22,9 +23,11 @@ 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. -Proactive La La La likewise spends its public card before the offer; -the selected target loses remaining free revival only after the -reaction, while paid revival remains available. +Proactive La La La likewise spends its public card before the +selected target loses remaining free revival; paid revival remains +available. Proactive Baliset spends its public card before the +selected player is barred from movement into CHOAM's occupied +territory; shipment stays legal. 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 @@ -40,6 +43,16 @@ full-hand ordering, normal Richese starts and deployed acceptance remain unfinished. Neutral passes and free-slot guard are product choices. +Clean proactive Baliset passes focused engine and authenticated +SQLite checks, a direct engine claim/restriction smoke and +independent review without findings. `npm run check` passed +types, lint and 5,849 offline tests; the production build +passed. Isolated built-worker HTTP passed 49/55: four POSTs +returned the Wrangler-dev proxy's 503 rather than app statuses, +and two requests timed out. This is **not** a green local HTTP +suite or production deployment; proxy-free CI remains pending +for this exact checkpoint. + Clean proactive La La La passes engine, authenticated SQLite, direct engine smoke and phone-width guide checks. `npm run check` passed types, lint and 5,847 offline tests; the build passed. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 11279efe..b1983756 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,36 @@ # Dune implementation status +## 30 September 2026 — Semuta after proactive CHOAM Baliset cost + +In the explicit Richese preview, an accepted proactive Baliset +response now discards the named public Worthless card before a +neutral Semuta offer. The chosen player's movement restriction +into the occupied CHOAM territory is installed only after claim +or all-seat decline; shipment remains possible. The saved receipt +binds target, territory, owner forces and hand, existing +restrictions, phase and public card cost. It cannot replay the +Karama response, restrict twice or remove the original mover's +forces. Declared-move responses, Nexus conversion and competing +arrival parents retain the prior automatic path. + +Engine tests cover hidden-holder parity, pre-restriction timing, +card/target/territory and occupancy custody, JSON normalization, +one saved block after claim or decline, rejection without mutation, +physical card conservation and legal AI claim. Authenticated +in-memory SQLite checks cover seat projections, restart, exact +stale retry, preserved seat rows and rejected restricted movement. +A direct engine smoke observed a public offer, one physical claim +and a turn-scoped restriction. Full Semuta and expansion gates +remain open. + +`npm run check` passed types, lint and 5,849 offline tests, +including 23 Baliset and 13 Semuta recovery cases. The +production build passed; independent review had no scoped +finding. Isolated built-worker HTTP passed 49/55: four +Wrangler-dev proxy 503s and two 15-second timeouts. This does +not certify the HTTP suite; proxy-free container CI for the +checkpoint is pending. No NAS deployment is claimed. + ## 30 September 2026 — Semuta after proactive CHOAM La La La cost In the explicit Richese preview, a clean proactive La La La diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 6c9de85b..200e7526 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -95,6 +95,15 @@ Revival rules and physical card. A neutral claim or decline applies the one saved block; paid revival remains legal. Reactive requests, Nexus and other Worthless effects retain their automatic paths. +Clean proactive Baliset stages `choamBalisetDiscard` after its +accepted public Worthless card cost but before restricting the +chosen player from movement into CHOAM's occupied territory. The +receipt binds the target, territory, CHOAM's current forces, +existing restrictions and physical card. A neutral claim or +decline installs the one saved restriction; shipment remains +possible. Declared moves, Nexus conversion and overlapping +arrival parents retain their automatic paths. + 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 diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index bdc454f1..8bc3c236 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -68,8 +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 sale or clean Kulon/proactive La La La Worthless discard, -battle and Ix-deck Thumper/Amal discards**, plus +CHOAM sale or clean Kulon/proactive La La La/Baliset 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 @@ -108,6 +108,15 @@ once without replaying the response. Paid revival remains legal. A reactive free-revival request, Nexus conversion and other Worthless effects retain their earlier automatic paths. +A clean proactive Baliset response pauses after its public +Worthless card cost but before restricting the selected player +from moving into the CHOAM-occupied territory. The saved +receipt binds that target, territory, current CHOAM occupancy +and existing restrictions. Claim or decline installs one +restriction without replaying the response. Shipment remains +legal. Reactive declared movement, Nexus conversion and +competing arrivals 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 diff --git a/game/engine.ts b/game/engine.ts index 324f56bb..559ef2b4 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1228,6 +1228,14 @@ export type Game = { target: string; stateSignature: string; } + | { + kind: 'choamBalisetDiscard'; + player: string; + card: string; + target: string; + territory: string; + stateSignature: string; + } | { kind: 'thumperDiscard'; player: string; @@ -4980,6 +4988,23 @@ function choamLaLaLaDiscardSignature(g: Game, c: ChoamLaLaLaDiscardContinuation) targetSpice: target.spice, }); } +type ChoamBalisetDiscardContinuation = Extract< + NonNullable['continuation'], + { kind: 'choamBalisetDiscard' } +>; +function choamBalisetDiscardSignature(g: Game, c: ChoamBalisetDiscardContinuation) { + const owner = getPlayer(g, c.player); + return JSON.stringify({ + player: c.player, card: c.card, target: c.target, territory: c.territory, + turn: g.turn, phase: g.phase, active: g.active, order: g.order, + restrictions: g.choamBaliset, remaining: g.movementRemaining, + parent: nullentropyParentSignature(g, + { response: null, decision: null, pendingKarama: null, phaseOpening: null }, + `choam:baliset:${c.card}`), + ownerHand: owner.hand.map(card => card.id), ownerForces: owner.forces, + ownerElites: owner.elites?.forces, ownerSpice: owner.spice, + }); +} type ThumperDiscardContinuation = Extract< NonNullable['continuation'], { kind: 'thumperDiscard' } @@ -5638,6 +5663,22 @@ function treacheryDiscardIntegrity(g: Game) { c.stateSignature === choamLaLaLaDiscardSignature(g, c), 'The used La La La no longer matches its pending revival block.', ); + } else if (continuation?.kind === 'choamBalisetDiscard') { + const c = continuation, entry = batch.entries[0]; + const owner = g.players.find(p => p.id === c.player); + requireRule( + g.phase === 5 && owner?.faction === 'choam' && + c.target !== c.player && g.players.some(p => p.id === c.target) && + at(owner, c.territory) > 0 && + !g.pendingChoamWorthless && !g.pendingChoamMove && + !g.response && !g.decision && !g.phaseOpening && !g.ornithopter && + batch.cause === 'choam:baliset' && batch.entries.length === 1 && + entry.publicFace && entry.discardedBy === c.player && + entry.card.id === c.card && entry.card.kind === 'worthless' && + entry.card.name === 'Baliset' && + c.stateSignature === choamBalisetDiscardSignature(g, c), + 'The used Baliset no longer matches its pending territory restriction.', + ); } else if (continuation?.kind === 'thumperDiscard') { const c = continuation, entry = batch.entries[0]; requireRule( @@ -6306,6 +6347,7 @@ function semutaOfferSupported( continuation.kind !== 'choamSaleDiscard' && continuation.kind !== 'choamKulonDiscard' && continuation.kind !== 'choamLaLaLaDiscard' && + continuation.kind !== 'choamBalisetDiscard' && !(continuation.kind === 'battleCleanup' && continuation.source === 'winner') && continuation.kind !== 'winnerMandatoryDiscard' && continuation.kind !== 'battleResolved' && @@ -6375,6 +6417,7 @@ function semutaOfferSupported( continuation.kind === 'choamSaleDiscard' || continuation.kind === 'choamKulonDiscard' || continuation.kind === 'choamLaLaLaDiscard' || + continuation.kind === 'choamBalisetDiscard' || (continuation.kind === 'karamaCharityDiscard' || continuation.kind === 'karamaInflationDiscard' || continuation.kind === 'karamaBgCharityDiscard' || @@ -6399,6 +6442,8 @@ function semutaOfferSupported( g.wormRides.length === 0)) && (continuation.kind !== 'choamLaLaLaDiscard' || (!g.pendingRevival && !g.pendingChoamMove && !g.ornithopter)) && + (continuation.kind !== 'choamBalisetDiscard' || + (!g.pendingChoamMove && !g.ornithopter && !g.pendingArrivalOverlap)) && !g.pendingExchange && !g.pendingIxAlly && !g.pendingNullentropy && !g.pendingAmbassador && !g.pendingTerrorEntry && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingRicheseGift && @@ -6553,6 +6598,8 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string completeChoamKulon(g, getPlayer(g, next.player)); } else if (next.kind === 'choamLaLaLaDiscard') { completeChoamLaLaLa(g, next.target); + } else if (next.kind === 'choamBalisetDiscard') { + completeChoamBaliset(g, next.target, next.territory); } else if (next.kind === 'nullentropyDiscard') { g.response = next.resume.response; g.decision = next.resume.decision; @@ -20383,6 +20430,10 @@ function completeChoamLaLaLa(g: Game, target: string) { (g.revivalRules.freeBlocked ??= []).push(target); log(g, `${getPlayer(g, target).name} cannot take free force revival this phase.`); } +function completeChoamBaliset(g: Game, target: string, location: string) { + (g.choamBaliset ??= []).push({ turn: g.turn, player: target, territory: location }); + log(g, `${getPlayer(g, target).name} cannot move into CHOAM's ${territory(location).name} this phase; shipment remains possible.`); +} function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) { karamaConversionIntegrity(g); currentFactionPayment(g); @@ -20758,15 +20809,22 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number) `${choam.name} protected its own forces in ${territory(pending.location!).name} for this storm movement with Jubba Cloak.`, ); } else if (pending.effect === 'baliset') { - (g.choamBaliset ??= []).push({ - turn: g.turn, - player: pending.target!, - territory: pending.location!, - }); - log( - g, - `${getPlayer(g, pending.target!).name} cannot move into CHOAM's ${territory(pending.location!).name} this phase; shipment remains possible.`, - ); + if (g.semutaPreview && used.name === 'Baliset' && + !pending.storm && !pending.revival && !pending.movement && + !pending.mentat && !pending.nexusEvent) { + const continuation: ChoamBalisetDiscardContinuation = { + kind: 'choamBalisetDiscard', player: choam.id, + card: used.id, target: pending.target!, + territory: pending.location!, stateSignature: '', + }; + const entries = [{ card: used, discardedBy: choam.id, publicFace: true }]; + if (semutaOfferSupported(g, continuation, entries)) { + continuation.stateSignature = choamBalisetDiscardSignature(g, continuation); + stageTreacheryDiscard(g, 'choam:baliset', entries, continuation); + return; + } + } + completeChoamBaliset(g, pending.target!, pending.location!); if (pending.movement) { const ambassador = g.pendingChoamMove?.source === 'ambassador'; if (ambassador) diff --git a/tests/choam-baliset.test.ts b/tests/choam-baliset.test.ts index 3a119ccc..3e6691e9 100644 --- a/tests/choam-baliset.test.ts +++ b/tests/choam-baliset.test.ts @@ -5,6 +5,7 @@ import { createGame, newPlayer, viewGame, + normalizeAutomaticGame, type Game, type Action, } from '../game/engine'; @@ -12,6 +13,8 @@ import { baseDeck } from '../game/cards'; import { TERRITORIES, gameDistance, splitLocation } from '../game/board'; 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'; const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const send = (g: Game, id: string, a: Action) => applyAction(g, id, a); function hold(g: Game, id: string, name: string) { @@ -183,6 +186,77 @@ void test('proactive Baliset restricts only the selected player and territory', g.active = 'e'; assert.throws(() => move(g), /Baliset prevents/); }); +void test('clean proactive Baliset discard offers Semuta before one territory restriction', () => { + const initial = fixture(); + initial.players[1] = newPlayer('e', 'Richese', 'richese'); + p(initial, 'e').forces = { 'red_chasm:7': 3 }; + p(initial, 'e').reserves = 17; + 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]; + p(initial, 'e').hand.push(semuta); + contest(initial, 'b'); + const card = p(initial, 'c').hand.find(card => card.name === 'Baliset')!.id; + const territory = dest(initial).territory; + const physical = (state: Game) => [ + ...state.deck, ...state.discard, ...state.richeseCache!, + ...state.players.flatMap(player => player.hand), + ].map(card => card.id).sort(); + const original = physical(initial); + const absent = structuredClone(initial); + p(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]]; + absent.richeseCache!.push(semuta); + const pending = allow(play(initial)), neutral = allow(play(absent)); + const event = pending.pendingTreacheryDiscard!.batch.event; + assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamBalisetDiscard'); + assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(pending.choamBaliset, undefined); + assert.equal(pending.discard.filter(used => used.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); + for (const change of [ + (state: Game) => { + const c = state.pendingTreacheryDiscard!.continuation; + if (c.kind === 'choamBalisetDiscard') c.territory = 'red_chasm'; + }, + (state: Game) => { + const c = state.pendingTreacheryDiscard!.continuation; + if (c.kind === 'choamBalisetDiscard') c.target = 'b'; + }, + (state: Game) => { p(state, 'c').forces = {}; }, + (state: Game) => { state.choamBaliset = [{ turn: state.turn, player: 'e', territory }]; }, + (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(() => send(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 = send(declined, id, { type: 'semutaPass', event }); + const claimed = send(JSON.parse(JSON.stringify(pending)), 'e', + { type: 'semutaCommit', event }); + for (const state of [declined, claimed]) { + assert.equal(state.pendingTreacheryDiscard, null); + assert.deepEqual(state.choamBaliset, [{ turn: state.turn, player: 'e', territory }]); + assert.equal(viewGame(state, 'e').balisetRestrictions.length, 1); + assert.throws(() => move(state), /Baliset prevents/); + assert.deepEqual(physical(state), original); + assert.throws(() => send(state, 'e', { type: 'semutaCommit', event })); + } + assert.equal(declined.discard.filter(used => used.id === card).length, 1); + assert.equal(p(claimed, 'e').hand.filter(used => used.id === card).length, 1); + assert.equal(claimed.discard.filter(used => used.id === semuta.id).length, 1); +}); + void test('Baliset permits ordinary shipment into the restricted territory', () => { let g = allow(play(fixture())); g = send(g, 'e', { type: 'ship', amount: 1, ...dest(g) }); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index 79790489..c5e7955c 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -9,6 +9,7 @@ import { richeseCards } from '../game/richese-cards'; import { newRevivalRules } from '../game/revival'; import { unitStore } from './fixture-nexus-room-store'; import { saphoAggressorGame, aggressorAction } from './fixture-sapho-aggressor'; +import { TERRITORIES, gameDistance, splitLocation } from '../game/board'; import { takeSaphoBattleCard } from './fixture-sapho-battle-order'; const clock = { now: () => 10000, sleep: async () => {} }; @@ -955,6 +956,95 @@ void test('authenticated Kulon Semuta offer restores one movement bonus across r } finally { f.sqlite.close(); } }); +void test('authenticated proactive Baliset Semuta claim restores only the selected movement restriction', async () => { + const f = unitStore(); + try { + const made = await f.rooms.createRoom('Semuta Baliset', '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[1].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 === 'Baliset'); + assert.ok(index >= 0); + const used = g.deck.splice(index, 1)[0]; + const key = TERRITORIES.flatMap(t => t.sectors.map(s => `${t.id}:${s}`)) + .find(key => splitLocation(key).territory !== 'red_chasm' && + splitLocation(key).sector !== 18 && + gameDistance(g, 'red_chasm:7', key, + k => splitLocation(k).sector === 18) === 1)!; + assert.ok(key); + const location = splitLocation(key); + g.players[0].hand.push(used); + g.players[0].forces = { [key]: 1 }; + g.players[0].reserves = 19; + g.players[1].hand.push(semuta); + g.players[1].forces = { 'red_chasm:7': 3 }; + g.players[1].reserves = 17; + 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: used.id, + target: auths[1].playerId, territory: location.territory, + }); + 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, 'choamBalisetDiscard'); + assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + assert.equal(current.choamBaliset, undefined); + assert.equal(current.discard.filter(card => card.id === used.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.choamBaliset, + [{ turn: 2, player: auths[1].playerId, territory: location.territory }]); + assert.equal(claimed.players[1].hand.filter(card => card.id === used.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 before = await f.restart().readRoom(code); + await assert.rejects(f.restart().act(code, auths[1], before.version, { + type: 'move', from: 'red_chasm:7', amount: 1, ...location, + }, clock), /Baliset prevents/); + assert.deepEqual(await f.restart().readRoom(code), before); + } finally { f.sqlite.close(); } +}); + void test('authenticated CHOAM sale retains paid spice and market after a saved Semuta claim', async () => { const f = unitStore(); try {