From 88bcb33342d3bde16faeb12675fb9653cd3feba7 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:48:14 +1000 Subject: [PATCH] Offer Semuta before clean CHOAM Kulon movement bonus --- docs/CURRENT_STATUS.md | 16 +++++- docs/IMPLEMENTATION_STATUS.md | 29 ++++++++++ docs/SEMUTA_DISCARD_CONTINUATIONS.md | 7 +++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 11 +++- game/engine.ts | 82 +++++++++++++++++++++++---- game/reference.ts | 8 +-- tests/choam-worthless.test.ts | 67 ++++++++++++++++++++++ tests/semuta-recovery.test.ts | 71 +++++++++++++++++++++++ 8 files changed, 273 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c082612e..40f0273e 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 9bfa5e93..b742d46d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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 diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index b4f029f6..c5d0ceed 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -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 diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 36c1b2b9..ecbd3bc5 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -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 diff --git a/game/engine.ts b/game/engine.ts index 46ffd085..e8512321 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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['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['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( diff --git a/game/reference.ts b/game/reference.ts index 63fcce65..c1eb83dd 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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', diff --git a/tests/choam-worthless.test.ts b/tests/choam-worthless.test.ts index c9cc0f51..3d8b4ca9 100644 --- a/tests/choam-worthless.test.ts +++ b/tests/choam-worthless.test.ts @@ -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/); diff --git a/tests/semuta-recovery.test.ts b/tests/semuta-recovery.test.ts index 3ea9c917..b267871f 100644 --- a/tests/semuta-recovery.test.ts +++ b/tests/semuta-recovery.test.ts @@ -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 {