From 79fc12c1c10d5304e5526a93d0f5606b833a434b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:34:18 +1100 Subject: [PATCH] Support Semuta after market Ghola --- docs/CURRENT_STATUS.md | 12 ++ docs/RULE_DECISIONS.md | 8 + docs/SEMUTA_DISCARD_CONTINUATIONS.md | 18 +++ docs/SEMUTA_SOURCE_UPDATE_20260907.md | 3 +- game/engine.ts | 8 +- game/reference.ts | 4 +- game/semuta-market-ghola.ts | 52 +++++++ tests/fixture-semuta-market-ghola.ts | 100 +++++++++++++ tests/semuta-market-ghola-controls.test.tsx | 110 ++++++++++++++ tests/semuta-market-ghola-quote.test.ts | 91 ++++++++++++ tests/semuta-market-ghola-runtime.test.ts | 156 ++++++++++++++++++++ 11 files changed, 557 insertions(+), 5 deletions(-) create mode 100644 game/semuta-market-ghola.ts create mode 100644 tests/fixture-semuta-market-ghola.ts create mode 100644 tests/semuta-market-ghola-controls.test.tsx create mode 100644 tests/semuta-market-ghola-quote.test.ts create mode 100644 tests/semuta-market-ghola-runtime.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 2dba927a..5bf7fd2f 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,18 @@ open mode gates. ## Current checkpoint and work +**Basic Semuta after market Ghola — 29 October. Prototyped / Partial.** A +Ghola played during a clean CHOAM sale may offer its physical discard to +Semuta while preserving the committed revival, optional Tleilaxu income and +original sale. Claim or pass resumes each remaining effect once; existing +controls and minimal bot choices are reused. Six related files pass 37 cases, +including 14 changed-path cases; type checking, lint and production build +pass. Runtime journeys use public setup and the market, with conserved card +and tank staging. Old pending discards without a reaction retain their original +continuation. The default Kull preview must be turned off; other optional +modules, nested market parents and deployed acceptance remain open. +[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#basic-ghola-during-a-choam-sale--29-october-2026). + **Classic Basic bribes during pending choices — 29 October. Prototyped / Partial.** Direct ordinary bribes can interrupt a clean normal auction-payment decision, Atreides auction inspection or Fremen allied-revival grant. The diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 33211d63..7919932d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,13 @@ # Rule decision index +## Semuta and Ghola during a CHOAM sale — 29 October 2026 + +The existing printed Semuta, Ghola and Tleilaxu income rules are composed +provisionally during a Basic CHOAM sale: the committed Ghola return precedes +the Semuta offer, the original income response follows it, and the parked sale +continues once. This ordering is an online interpretation, not a publisher +clarification. [Scope](SEMUTA_DISCARD_CONTINUATIONS.md#basic-ghola-during-a-choam-sale--29-october-2026). + ## Classic Basic bribes during pending auction and revival — 29 October 2026 The publisher's base rulebook, printed p. 12, permits nonallied bribes but does diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 5bcfea31..6dec0975 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -1,5 +1,23 @@ # Semuta discard producers and continuation inventory +## Basic Ghola during a CHOAM sale — 29 October 2026 + +In supported Basic Richese games, a player can offer the physical Ghola card +to Semuta after playing it during a pending CHOAM sale. The force or leader +return is already committed; the original sale remains parked while the +existing neutral Semuta offer resolves. When Tleilaxu is seated, the same +one-spice Ghola income response resumes next and may be allowed or canceled; +afterward the original sale response continues once. Without income, the sale +resumes directly. No extra market, revival or card transfer is created. + +This is a provisional online composition of the existing printed effects and +discard protocol, not a publisher timing ruling. Older saved Ghola discards +without a Semuta reaction retain their original continuation. The Kull preview, +enabled by default in this setup, must be turned off for this interaction; +other optional modules, nested market parents and complete faction acceptance +remain open. +[Source and earlier scope](SEMUTA_SOURCE_UPDATE_20260907.md#ghola-and-conditional-native-income--24-october-2026). + ## Basic Ixian ally replacement with independent Ecaz cards — 29 October 2026 In a supported Basic Richese/Ix game with the independent Ecaz Treachery cards, diff --git a/docs/SEMUTA_SOURCE_UPDATE_20260907.md b/docs/SEMUTA_SOURCE_UPDATE_20260907.md index 52ddfa22..6b24288a 100644 --- a/docs/SEMUTA_SOURCE_UPDATE_20260907.md +++ b/docs/SEMUTA_SOURCE_UPDATE_20260907.md @@ -1575,7 +1575,8 @@ All seats retain the neutral persistent Continue offer; a legal holder can Commit, spending its physical Semuta once and acquiring the actual used Ghola. The already returned piece and original conditional income are independent of which player takes that card. Other nested parents, CHOAM market Ghola, -unfinished producers and complete-mode/combination acceptance remain separate. +unfinished producers and complete-mode/combination acceptance remained separate +at this checkpoint; the later [Basic market-Ghola prototype](SEMUTA_DISCARD_CONTINUATIONS.md#basic-ghola-during-a-choam-sale--29-october-2026) extends that boundary. This composes printed Ghola, native income and Semuta with the existing saved discard protocol; it makes no new publisher timing or disposal ruling. diff --git a/game/engine.ts b/game/engine.ts index 5b9fc389..d5a12ae7 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -551,6 +551,7 @@ import { import { transferDistrans } from './distrans'; import { distransAuctionBlock } from './distrans-timing'; import { SEMUTA_DRUG_ID, committedSemutaCandidates, resolveSemutaDrug, type FreshDiscardBatch, type SemutaContext } from './semuta-drug'; +import { semutaMarketGholaSupported } from './semuta-market-ghola'; import { prepareRicheseGift, transferRicheseGift, @@ -9614,6 +9615,9 @@ function semutaOfferSupported( ['alliance', 'allianceExpression', 'mixedAlliance'].includes(continuation.record.question.kind) && (!continuation.allianceBinding || !bareBasicSemutaProfile(g) || !alliancePromiseModeSupported(g))) return false; const richeseGiftDiscard = continuation.kind === 'karamaRicheseGiftDiscard'; + const marketGholaDiscard = continuation.kind === 'ordinaryCardDiscard' && continuation.effect === 'ghola' && + g.semutaPreview === true && bareBasicSemutaProfile(g) && ordinaryDiscardIncomeMatches(g, continuation) && + semutaMarketGholaSupported(g, continuation, entries); const richeseDiscard = richeseGiftDiscard || continuation.kind === 'karamaRicheseAuctionDiscard'; const auctionAdvantageDiscard = continuation.kind === 'karamaAuctionAdvantageDiscard'; const terminalDiscard = continuation.kind === 'karamaTerminalDiscard'; @@ -10046,13 +10050,13 @@ function semutaOfferSupported( (richeseGiftDiscard || !g.pendingRicheseGift) && (richeseGiftDiscard || (continuation.kind === 'karamaIncomeDiscard' && continuation.use.response.kind === 'richesePurchaseIncome') || !g.pendingRichesePurchaseIncome) && - (richeseGiftDiscard || saleCostDiscard || (continuation.kind === 'choamSaleDiscard' + (richeseGiftDiscard || saleCostDiscard || marketGholaDiscard || (continuation.kind === 'choamSaleDiscard' ? !!g.choamMarket && !g.choamMarket.sale && !g.choamMarket.trade && !g.biddingEnd && !g.currentAuctionSale : continuation.kind === 'kaitainDiscard' ? g.biddingEnd?.event === continuation.event && biddingEndError(g) === null : !g.choamMarket)) && - (richeseGiftDiscard || !g.pendingChoamMarketGhola); + (richeseGiftDiscard || marketGholaDiscard || !g.pendingChoamMarketGhola); } function semutaContext(g: Game, owner: Player): SemutaContext { const pending = g.pendingTreacheryDiscard!; diff --git a/game/reference.ts b/game/reference.ts index 8cc9995b..e700cc04 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2945,7 +2945,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A Basic ground-movement promise can also require Ghola force preparation. The guide returns physical Tanks to reserves, then preserves an unused affordable shipment and one later matching ground move. Ghola itself does not fulfill movement or consume normal revival allowances. A known discarded Ghola may be recovered with the paid Box and played again.', 'Returned forces go to reserves. A leader revived with this card can fight again this turn, including in another territory. Atreides can also select a dead Kwisatz Haderach; revive it separately from its accompanying leader. Returning a controlled foreign ghola does not transfer it back to its original faction. Additional advanced and expansion interactions remain under audit.', 'An actual Ecaz native Ghola return uses that disc in its current ordinary cohort without consuming the ordinary phase slot. A repeat waits for the remaining native cohort and another opening. Independent Duke Ghola preserves a satisfied five-Tanks opening and returns him set aside; only Ecaz may revive him.', - 'Semuta may recover the used Ghola before its native Tleilaxu one-spice income resolves. Keep the returned piece and normal revival allowance; claim or Continue then restores the original income response. Taking the card never repeats its revival.', + 'Semuta may recover the used Ghola before its native Tleilaxu one-spice income resolves. Keep the returned piece and normal revival allowance; claim or Continue then restores the original income response. This also works during a supported Basic CHOAM sale: the original sale waits for the Semuta choice and any income response, then continues once. Taking the card never repeats its revival.', 'A saved play preserves the revived pieces, any earned technology income and the discarded card. Recovery restores any pending Tleilaxu income response without reviving or paying twice. Private preparation guidance waits for recovery to finish.', ], checklist: [ @@ -2954,7 +2954,7 @@ export const RULE_TOPICS: RuleTopic[] = [ status: 'Partial', detail: 'Ordinary and typed forces, controlled leaders and Kwisatz retain original use and custody. Actual Ecaz native Ghola/foreign returns now record completed native cohort identity; independent Duke resurrection preserves its five-Tanks opening without aligning death counters. Ecaz-only living set-aside custody remains; broader combinations and assurance stay unfinished.', - evidence: ['game/ordinary-discard-income.ts', 'game/ghola-force-preparation.ts', 'game/engine.ts', 'game/ecaz-duke-revival.ts', 'game/ecaz-revival-cohort.ts', 'game/revival.ts'], + evidence: ['game/ordinary-discard-income.ts', 'game/semuta-market-ghola.ts', 'game/ghola-force-preparation.ts', 'game/engine.ts', 'game/ecaz-duke-revival.ts', 'game/ecaz-revival-cohort.ts', 'game/revival.ts'], }, { area: 'Player controls', diff --git a/game/semuta-market-ghola.ts b/game/semuta-market-ghola.ts new file mode 100644 index 00000000..ccab1636 --- /dev/null +++ b/game/semuta-market-ghola.ts @@ -0,0 +1,52 @@ +import type { Game } from './engine'; +import type { FreshDiscardBatch } from './semuta-drug'; +import { choamGholaEvent, choamMarketGholaError } from './choam-market-ghola'; +import { quoteChoamSaleCancellation } from './choam-sale-cancellation'; + +type OrdinaryDiscard = Extract['continuation'], + { kind: 'ordinaryCardDiscard' }>; + +/** Qualify the original Ghola discard while its CHOAM sale is suspended. + * The caller separately owns the Basic Semuta profile and the ordinary + * revival-income qualification. This never changes a saved frame. */ +export function semutaMarketGholaSupported( + game: Game, continuation: OrdinaryDiscard, entries: FreshDiscardBatch['entries'], +): boolean { + const pending = game.pendingChoamMarketGhola, discard = game.pendingTreacheryDiscard; + if (game.status !== 'playing' || game.advanced || game.semutaPreview !== true || + !game.expansions.includes('choam') || game.homeworlds || game.nexusCards || + game.leaderSkills || game.techTokens || game.strongholdCards || game.ecazTreachery || + game.kullPreview || game.nexusKullPreview || + game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || + game.sandtrout || game.coalitionState || !pending || !game.choamMarket || + continuation.kind !== 'ordinaryCardDiscard' || continuation.effect !== 'ghola' || + pending.stage !== 'discard' || pending.turn !== game.turn || pending.phase !== game.phase || + pending.player !== continuation.player || pending.card !== continuation.card || + pending.discardSequence !== game.treacheryDiscardSequence || pending.event !== choamGholaEvent(pending) || + JSON.stringify(pending.market) !== JSON.stringify(game.choamMarket) || + pending.response?.kind !== 'choamSale' || pending.response.owner !== game.choamMarket.owner || + !Array.isArray(pending.response.passed) || new Set(pending.response.passed).size !== pending.response.passed.length || + pending.response.passed.some(id => !game.players.some(player => player.id === id)) || + continuation.resume.decision || continuation.resume.pendingKarama || continuation.resume.phaseOpening || + game.response || game.decision || game.pendingKarama || game.phaseOpening || + discard && (!discard.reaction || discard.sequence !== pending.discardSequence)) return false; + + const entry = entries[0], cards = game.discard.filter(card => card.id === pending.card); + if (entries.length !== 1 || !entry || !entry.publicFace || entry.discardedBy !== pending.player || + entry.card.id !== pending.card || entry.card.kind !== 'special' || entry.card.effect !== 'ghola' || + cards.length !== 1 || cards[0].name !== entry.card.name || cards[0].kind !== entry.card.kind || + cards[0].effect !== entry.card.effect) return false; + const income = continuation.resume.response; + if (income && (income.kind !== 'revivalIncome' || income.intent !== pending.event || + income.recipient !== pending.player || income.amount !== 1 || !Array.isArray(income.passed) || income.passed.length !== 0 || + !game.players.some(player => player.id === income.owner && player.faction === 'tleilaxu'))) return false; + try { + if (!quoteChoamSaleCancellation(game, pending.response)) return false; + // Before the discard is installed, the sequence is already incremented; + // the ordinary frame validator can only run after installation. + if (discard && choamMarketGholaError(game)) return false; + return true; + } catch { + return false; + } +} diff --git a/tests/fixture-semuta-market-ghola.ts b/tests/fixture-semuta-market-ghola.ts new file mode 100644 index 00000000..e20f3736 --- /dev/null +++ b/tests/fixture-semuta-market-ghola.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { actSemutaNativeMarkets as act, advanceSemutaNativeMarkets as advance, + semutaNativeMarketsInventory as inventory, semutaNativeMarketsPlayer as player } from './fixture-semuta-native-markets'; + +export const actSemutaMarketGhola = act; +export const semutaMarketGholaPlayer = player; +export const semutaMarketGholaInventory = inventory; + +/** Public Basic setup and real CHOAM market declaration. The original + * starting card order and Spice positions are selected before they are drawn. + * After setup, the real Ghola and Semuta cards are moved from their respective + * decks to their owners' hands and one CHOAM reserve is moved to Tanks. Those + * conserved staging choices establish the relevant holdings and casualty; no + * response, market, discard receipt, wallet, revival or income is staged. */ +export function createSemutaMarketGholaFixture(options: {fullHand?: boolean; withoutTleilaxu?: boolean} = {}) { + const owner = 'choam', claimant = 'richese', + incomeOwner = options.withoutTleilaxu ? 'atreides' : 'tleilaxu', counterOwner = 'emperor'; + let game = createGame('SEMUTAMARKETGHOLA', newPlayer(claimant, 'Richese', 'richese'), false, ['choam', 'ix']); + for (const id of [owner, incomeOwner, counterOwner] as const) joinGame(game, newPlayer(id, id, id)); + game = act(game, claimant, {type:'moduleOption', module:'kullPreview', enabled:false}); + for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); + game = act(game, claimant, {type:'start'}); + assert.equal(game.status, 'setup'); + assert.equal(game.semutaPreview, true); + assert.ok(!game.leaderSkills && !game.nexusCards && !game.homeworlds); + const originalInventory = inventory(game); + const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2); + const sale = game.deck.find(card => card.kind === 'worthless'); + const counter = game.deck.find(card => card.effect === 'karama'); + assert.equal(ordinary.length, 2); assert.ok(sale); assert.ok(counter); + for (const [position, card] of [ordinary[0], sale, ordinary[1], counter].entries()) { + const index = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(index >= position); + [game.deck[position], game.deck[index]] = [game.deck[index], game.deck[position]]; + } + game = advance(game, state => state.status === 'playing'); + assert.ok(player(game, owner).hand.some(card => card.id === sale.id)); + assert.ok(player(game, counterOwner).hand.some(card => card.id === counter.id)); + const front = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); + assert.ok(front >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(front, 1)[0]); + + const semutaIndex = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID); + assert.ok(semutaIndex >= 0); + player(game, claimant).hand.push(game.richeseCache!.splice(semutaIndex, 1)[0]); + const gholaIndex = game.deck.findIndex(card => card.effect === 'ghola'); assert.ok(gholaIndex >= 0); + const ghola = game.deck.splice(gholaIndex, 1)[0]; player(game, owner).hand.push(ghola); + const choam = player(game, owner); assert.ok(choam.reserves > 0); + choam.reserves--; choam.tanks++; + if (options.fullHand) while (player(game, claimant).hand.length < 4) { + const index = game.deck.findIndex(card => ['projectile','poison','shield','snooper','worthless'].includes(card.kind)); + assert.ok(index >= 0); player(game, claimant).hand.push(game.deck.splice(index, 1)[0]); + } + assert.deepEqual(inventory(game), originalInventory); + game = advance(game, state => state.decision?.kind === 'choamMarket' && !state.response && !state.phaseOpening); + assert.equal(game.decision?.player, owner); + game = act(game, owner, {type:'decision', mode:'sell', card:sale.id}); + assert.equal(game.response?.kind, 'choamSale'); + const source = structuredClone(game), market = structuredClone(game.choamMarket), response = structuredClone(game.response); + const gholaAction:Action = {type:'card', card:ghola.id, amount:1, elite:0}; + game = act(game, owner, gholaAction); + assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer'); + const event = game.pendingTreacheryDiscard!.batch.event; + assert.deepEqual(inventory(game), originalInventory); + return {game, source, owner, claimant, incomeOwner, counterOwner, ghola:ghola.id, + semuta:SEMUTA_DRUG_ID, counter:counter.id, sale:sale.id, event, originalInventory, + market, response, gholaAction, fullHand:!!options.fullHand}; +} + +/** Pass only the current discard offer, without consuming a later response. */ +export function passSemutaMarketGhola(source:Game):Game { + const pending = source.pendingTreacheryDiscard; + assert.equal(pending?.reaction?.stage, 'offer'); + const event = pending!.batch.event; + let game = source; + for (const seat of source.players) { + if (game.pendingTreacheryDiscard?.reaction?.stage !== 'offer') break; + if (!game.pendingTreacheryDiscard.reaction.passed.includes(seat.id)) + game = act(game, seat.id, {type:'semutaPass', event}); + } + return game; +} + +export function claimSemutaMarketGhola(source:Game, actor:string, card:string):Game { + const event = source.pendingTreacheryDiscard!.batch.event; + let game = act(source, actor, {type:'semutaCommit', event}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = act(game, actor, {type:'semutaSelect', event, card}); + return game; +} + +export function allowSemutaMarketGholaIncome(source:Game):Game { + let game = source; + for (let count = 0; game.response?.kind === 'revivalIncome' && count < game.players.length; count++) { + const seat = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); assert.ok(seat); + game = act(game, seat.id, {type:'passResponse'}); + } + assert.notEqual(game.response?.kind, 'revivalIncome'); + return game; +} diff --git a/tests/semuta-market-ghola-controls.test.tsx b/tests/semuta-market-ghola-controls.test.tsx new file mode 100644 index 00000000..84565942 --- /dev/null +++ b/tests/semuta-market-ghola-controls.test.tsx @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SemutaReaction } from '../components/semuta-reaction'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { + createSemutaMarketGholaFixture, actSemutaMarketGhola as act, + allowSemutaMarketGholaIncome, passSemutaMarketGhola, semutaMarketGholaPlayer as player, +} from './fixture-semuta-market-ghola'; + +type Props = Parameters[0]; +type Control = { children?: unknown; disabled?: boolean; onClick: () => void }; +function controls(node: unknown): Control[] { + if (Array.isArray(node)) return node.flatMap(controls); + if (!node || typeof node !== 'object' || !('props' in node)) return []; + const props = (node as ReactElement<{children?: unknown; disabled?: boolean; onClick?: () => void}>).props; + return [...(props.onClick ? [{...props, onClick: props.onClick}] : []), ...controls(props.children)]; +} +const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props)); +function control(game: Game, actor: string, label: 'Continue' | 'Commit Semuta Drug', busy = false) { + const sent: Action[] = [], props = {game:viewGame(game, actor), busy, act(action: Action) {sent.push(action);}}; + const button = controls(SemutaReaction(props)).find(candidate => candidate.children === label); + assert.ok(button, `The actual fresh discard offers ${label}`); + assert.equal(button.disabled, busy); + button.onClick(); + if (busy) { assert.deepEqual(sent, []); return null; } + assert.equal(sent.length, 1); + assert.deepEqual(sent[0], {type:label === 'Continue' ? 'semutaPass' : 'semutaCommit', + event:props.game.semutaReaction!.event}); + return sent[0]; +} + +function parked(game: Game) { + const pending = game.pendingTreacheryDiscard, market = game.pendingChoamMarketGhola; + assert.ok(pending?.continuation.kind === 'ordinaryCardDiscard'); + assert.equal(pending.continuation.effect, 'ghola'); + assert.ok(market); + assert.equal(market.response.kind, 'choamSale'); + assert.deepEqual(game.choamMarket, market.market); + const income = pending.continuation.resume.response; + assert.ok(income?.kind === 'revivalIncome'); + assert.equal(game.response, null); + return {event:pending.batch.event, response:structuredClone(market.response), + market:structuredClone(market.market), income:structuredClone(income)}; +} + +void test('every seat sees a neutral persistent market-Ghola offer; a pass leaves the parent and income parked', () => { + const f = createSemutaMarketGholaFixture(), game = f.game; + const original = parked(game), before = structuredClone(game); + for (const seat of game.players) { + const view = viewGame(game, seat.id), reaction = view.semutaReaction; + assert.ok(reaction); assert.equal(reaction.stage, 'offer'); + assert.deepEqual(reaction.candidates, []); + const html = markup({game:view, busy:false, act() {}}); + assert.match(html, /Fresh Treachery discard/); + assert.match(html, /Continue when you are ready/); + assert.doesNotMatch(html, /Ghola/); + } + assert.deepEqual(game, before, 'Rendering does not acknowledge or resume the parent.'); + assert.equal(control(game, f.owner, 'Continue', true), null); + const pass = control(game, f.owner, 'Continue'); assert.ok(pass); + assert.throws(() => act(game, f.owner, {...pass,event:'stale-market-ghola'})); + assert.deepEqual(game, before, 'A stale event cannot change the pending response.'); + const next = act(game, f.owner, pass); + assert.deepEqual(parked(next), original); + assert.match(markup({game:viewGame(next, f.owner), busy:false, act() {}}), /You continued. Waiting for other seats/); + const continued = passSemutaMarketGhola(next); + assert.equal(continued.pendingTreacheryDiscard, null); + assert.deepEqual(continued.response, original.income); + assert.deepEqual(continued.pendingChoamMarketGhola?.response, original.response); + const restored = allowSemutaMarketGholaIncome(continued); + assert.equal(restored.pendingChoamMarketGhola, null); + assert.deepEqual(restored.response, original.response); + assert.deepEqual(restored.choamMarket, original.market); +}); + +for (const fullHand of [false, true]) void test( + `the market-Ghola claim resumes the same income and sale${fullHand ? ' with a full hand' : ''}`, () => { + const f = createSemutaMarketGholaFixture({fullHand}), game = f.game; + const original = parked(game), before = structuredClone(game); + const view = viewGame(game, f.claimant); + assert.equal(view.semutaReaction?.canCommit, true); + const html = markup({game:view,busy:false,act() {}}); + assert.match(html, /Commit Semuta Drug/); + assert.doesNotMatch(html, /Ghola/); + assert.equal(control(game, f.claimant, 'Commit Semuta Drug', true), null); + const commit = control(game, f.claimant, 'Commit Semuta Drug'); assert.ok(commit); + assert.throws(() => act(game, f.claimant, {...commit,event:'stale-market-ghola'})); + assert.deepEqual(game, before); + let next = act(game, f.claimant, commit); + if (next.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const selection = viewGame(next, f.claimant).semutaReaction; + assert.ok(selection && selection.stage === 'select'); + assert.deepEqual(selection.candidates.map(card => card.id), [f.ghola]); + assert.deepEqual(viewGame(next, f.owner).semutaReaction?.candidates, []); + next = act(next, f.claimant, {type:'semutaSelect',event:original.event,card:f.ghola}); + } + assert.equal(next.pendingTreacheryDiscard, null); + assert.deepEqual(next.response, original.income); + assert.deepEqual(next.choamMarket, original.market); + assert.deepEqual(next.pendingChoamMarketGhola?.response, original.response); + assert.ok(player(next, f.claimant).hand.some(card => card.id === f.ghola)); + assert.ok(!player(next, f.claimant).hand.some(card => card.id === f.semuta)); + next = allowSemutaMarketGholaIncome(next); + assert.equal(next.pendingChoamMarketGhola, null); + assert.deepEqual(next.response, original.response); + assert.deepEqual(next.choamMarket, original.market); + }, +); diff --git a/tests/semuta-market-ghola-quote.test.ts b/tests/semuta-market-ghola-quote.test.ts new file mode 100644 index 00000000..d61b9000 --- /dev/null +++ b/tests/semuta-market-ghola-quote.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, newPlayer, type Game } from '../game/engine'; +import { choamGholaEvent } from '../game/choam-market-ghola'; +import { semutaMarketGholaSupported as supported } from '../game/semuta-market-ghola'; +import type { FreshDiscardBatch } from '../game/semuta-drug'; + +type Ordinary = Extract['continuation'], + {kind:'ordinaryCardDiscard'}>; + +/** A staged frame isolates admission. The separate runtime suite exercises + * the actual Ghola, market, income and physical continuation. */ +function source(withIncome = true) { + const game = createGame('MARKETGHOLA', newPlayer('r','Richese','richese'), false, ['choam','ix']); + game.players.push(newPlayer('c','CHOAM','choam'), newPlayer('a','Atreides','atreides')); + if (withIncome) game.players.push(newPlayer('t','Tleilaxu','tleilaxu')); + game.status = 'playing'; game.turn = 2; game.phase = 2; game.semutaPreview = true; + game.response = null; game.decision = null; game.pendingKarama = null; game.phaseOpening = null; + game.choamMarket = {owner:'c',resume:'phase',blocked:[],sale:{card:'sale',price:2}}; + game.treacheryDiscardSequence = 7; + const card = {id:'physical-ghola',name:'Ghola',kind:'special',effect:'ghola'} as const; + game.discard = [{...card}]; + game.pendingChoamMarketGhola = {turn:game.turn,phase:game.phase,player:'a',card:card.id, + discardSequence:7,event:'',stage:'discard',response:{kind:'choamSale',owner:'c',passed:[]}, + market:structuredClone(game.choamMarket)}; + game.pendingChoamMarketGhola.event = choamGholaEvent(game.pendingChoamMarketGhola); + const continuation:Ordinary = {kind:'ordinaryCardDiscard',player:'a',card:card.id,effect:'ghola', + resume:{response:withIncome ? {kind:'revivalIncome',owner:'t',recipient:'a',amount:1,passed:[], + intent:game.pendingChoamMarketGhola.event} : null,decision:null,pendingKarama:null,phaseOpening:null}, + stateSignature:'staged'}; + const entries:FreshDiscardBatch['entries'] = [{card:{...card},discardedBy:'a',publicFace:true}]; + return {game,continuation,entries}; +} +function install(frame: ReturnType, reaction = true) { + const {game,continuation,entries} = frame; + game.pendingTreacheryDiscard = {sequence:7,batch:{event:'discard:2:2:7',turn:2,phase:2, + cause:'ordinary:ghola',entries:structuredClone(entries)},continuation:structuredClone(continuation), + ...(reaction ? {reaction:{stage:'offer' as const,passed:[]}} : {})}; + return frame; +} + +void test('a fresh actual market Ghola with and without native income is admitted without mutation', () => { + for (const withIncome of [true,false]) { + const {game,continuation,entries} = source(withIncome), before = structuredClone(game); + assert.equal(supported(game,continuation,entries),true); + assert.deepEqual(game,before); + } +}); + +void test('an installed reaction retains its same typed source, while an old unreacted discard stays closed', () => { + const fresh = install(source()); + const before = structuredClone(fresh.game); + assert.equal(supported(fresh.game,fresh.continuation,fresh.entries),true); + assert.deepEqual(fresh.game,before); + const old = install(source(),false); + assert.equal(supported(old.game,old.continuation,old.entries),false); +}); + +void test('market, sequence, source, income and nested-control changes are rejected', () => { + const changes: Array<(s:ReturnType) => void> = [ + s => { s.game.advanced = true; }, + s => { s.game.techTokens = {} as NonNullable; }, + s => { s.game.kullPreview = true; }, + s => { s.game.nexusKullPreview = true; }, + s => { s.game.semutaPreview = undefined; }, + s => { s.game.pendingChoamMarketGhola!.turn++; }, + s => { s.game.pendingChoamMarketGhola!.stage = 'income'; }, + s => { s.game.pendingChoamMarketGhola!.discardSequence++; }, + s => { s.game.pendingChoamMarketGhola!.event = 'stale'; }, + s => { s.game.pendingChoamMarketGhola!.card = 'other'; }, + s => { s.game.pendingChoamMarketGhola!.player = 'r'; }, + s => { s.game.pendingChoamMarketGhola!.market.sale!.price = 3; }, + s => { s.game.pendingChoamMarketGhola!.response.owner = 'a'; }, + s => { s.game.pendingChoamMarketGhola!.response.passed = ['a','a']; }, + s => { s.entries[0].publicFace = false; }, + s => { s.entries[0].discardedBy = 'r'; }, + s => { s.game.discard = []; }, + s => { s.continuation.resume.response!.intent = 'wrong'; }, + s => { s.continuation.resume.response!.amount = 2; }, + s => { s.continuation.resume.response!.passed = ['r']; }, + s => { s.continuation.resume.decision = {kind:'auctionPayment',player:'a'}; }, + s => { s.game.response = {kind:'choamSale',owner:'c',passed:[]}; }, + s => { s.game.pendingKarama = {} as NonNullable; }, + ]; + for (const change of changes) { + const candidate = source(); change(candidate); + const before = structuredClone(candidate.game); + assert.equal(supported(candidate.game,candidate.continuation,candidate.entries),false); + assert.deepEqual(candidate.game,before); + } +}); diff --git a/tests/semuta-market-ghola-runtime.test.ts b/tests/semuta-market-ghola-runtime.test.ts new file mode 100644 index 00000000..ccfc7798 --- /dev/null +++ b/tests/semuta-market-ghola-runtime.test.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { createSemutaMarketGholaFixture as fixture, actSemutaMarketGhola as act, + semutaMarketGholaPlayer as player, semutaMarketGholaInventory as inventory, + passSemutaMarketGhola as pass, claimSemutaMarketGhola as claim, + allowSemutaMarketGholaIncome as allowIncome } from './fixture-semuta-market-ghola'; + +function reject(game:Game, actor:string, action:Action) { + const before = structuredClone(game); + assert.throws(() => act(game, actor, action)); + assert.deepEqual(game, before); +} + +function allowSale(source:Game):Game { + let game = source; + for (let count = 0; game.response?.kind === 'choamSale' && count < game.players.length; count++) { + const actor = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(actor); + game = act(game, actor.id, {type:'passResponse'}); + } + assert.notEqual(game.response?.kind, 'choamSale'); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game); + return game; +} + +void test('market Ghola commits the actual return and parks its income and original sale through the Semuta offer', () => { + const f = fixture(), pending = f.game.pendingTreacheryDiscard; + assert.equal(f.source.response?.kind, 'choamSale'); + assert.equal(pending?.reaction?.stage, 'offer'); + assert.equal(pending?.continuation.kind, 'ordinaryCardDiscard'); + if (pending?.continuation.kind !== 'ordinaryCardDiscard') assert.fail('Retain the original Ghola discard.'); + assert.equal(pending.continuation.effect, 'ghola'); + assert.equal(pending.continuation.player, f.owner); + assert.equal(pending.continuation.card, f.ghola); + assert.equal(pending.continuation.resume.response?.kind, 'revivalIncome'); + assert.equal(pending.continuation.resume.response?.owner, f.incomeOwner); + assert.equal(pending.continuation.resume.response?.recipient, f.owner); + assert.equal(pending.continuation.resume.response?.amount, 1); + assert.equal(pending.continuation.resume.response?.intent, f.game.pendingChoamMarketGhola?.event); + assert.deepEqual(f.game.pendingChoamMarketGhola?.response, f.response); + assert.deepEqual(f.game.pendingChoamMarketGhola?.market, f.market); + assert.equal(f.game.pendingChoamMarketGhola?.stage, 'discard'); + assert.equal(f.game.response, null); + assert.equal(player(f.game, f.owner).reserves, player(f.source, f.owner).reserves + 1); + assert.equal(player(f.game, f.owner).tanks, player(f.source, f.owner).tanks - 1); + assert.equal(player(f.game, f.incomeOwner).spice, player(f.source, f.incomeOwner).spice); + assert.deepEqual(inventory(f.game), f.originalInventory); + for (const seat of f.game.players) { + const projected = viewGame(f.game, seat.id).semutaReaction; assert.ok(projected); + assert.deepEqual(projected.candidates, []); + assert.equal(projected.canCommit, seat.id === f.claimant); + } + reject(f.game, f.claimant, {type:'semutaCommit', event:'stale-market-ghola'}); +}); + +for (const claims of [false, true]) for (const cancelIncome of [false, true]) void test( + `market Ghola ${claims ? 'claim' : 'pass'} preserves ${cancelIncome ? 'canceled' : 'paid'} income and original sale`, () => { + const f = fixture(), beforeIncome = player(f.source, f.incomeOwner).spice, + beforeSale = player(f.source, f.owner).spice; + let game = claims ? claim(f.game, f.claimant, f.ghola) : pass(f.game); + assert.equal(game.pendingTreacheryDiscard ?? null, null); + assert.equal(game.response?.kind, 'revivalIncome'); + assert.equal(game.response?.intent, game.pendingChoamMarketGhola?.event); + assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response); + assert.deepEqual(game.choamMarket, f.market); + assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1); + assert.equal(player(game, f.incomeOwner).spice, beforeIncome); + assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims); + assert.equal(game.discard.some(card => card.id === f.ghola), !claims); + if (cancelIncome) { + game = act(game, f.counterOwner, {type:'card', card:f.counter, mode:'cancel'}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game); + } else game = allowIncome(game); + assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!cancelIncome)); + assert.equal(game.pendingChoamMarketGhola ?? null, null); + if (game.response?.kind === 'choamSale') { + assert.deepEqual(game.response, f.response); + assert.deepEqual(game.choamMarket, f.market); + assert.equal(player(game, f.owner).spice, beforeSale); + } else { + // When the counter consumed the last usable Karama, the restored sale + // may immediately advance through the existing automatic response path. + assert.equal(game.response ?? null, null); + assert.equal(player(game, f.owner).spice, beforeSale + 2); + assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false); + } + game = allowSale(game); + assert.equal(player(game, f.owner).spice, beforeSale + 2); + assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false); + assert.equal(game.discard.filter(card => card.id === f.sale).length, 1); + assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1); + assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!cancelIncome)); + assert.deepEqual(inventory(game), f.originalInventory); + reject(game, f.claimant, {type:'semutaCommit', event:f.event}); + }, +); + +void test('without Tleilaxu, Semuta claim or pass restores the original sale without income', () => { + for (const claims of [false, true]) { + const f = fixture({withoutTleilaxu:true}), beforeSale = player(f.source, f.owner).spice; + const pending = f.game.pendingTreacheryDiscard; + assert.equal(f.game.players.some(seat => seat.faction === 'tleilaxu'), false); + assert.equal(pending?.continuation.kind, 'ordinaryCardDiscard'); + if (pending?.continuation.kind !== 'ordinaryCardDiscard') assert.fail('Retain the original Ghola discard.'); + assert.equal(pending.continuation.resume.response ?? null, null); + assert.equal(player(f.game, f.owner).tanks, player(f.source, f.owner).tanks - 1); + let game = claims ? claim(f.game, f.claimant, f.ghola) : pass(f.game); + assert.equal(game.pendingChoamMarketGhola ?? null, null); + assert.deepEqual(game.response, f.response); + assert.deepEqual(game.choamMarket, f.market); + assert.equal(player(game, f.owner).spice, beforeSale); + assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims); + assert.equal(game.discard.some(card => card.id === f.ghola), !claims); + game = allowSale(game); + assert.equal(player(game, f.owner).spice, beforeSale + 2); + assert.equal(player(game, f.owner).hand.some(card => card.id === f.sale), false); + assert.equal(game.discard.filter(card => card.id === f.sale).length, 1); + assert.equal(player(game, f.owner).tanks, player(f.source, f.owner).tanks - 1); + assert.deepEqual(inventory(game), f.originalInventory); + } +}); + +void test('a full-hand Semuta claimant exchanges its physical card and still restores market income', () => { + const f = fixture({fullHand:true}); + assert.equal(player(f.game, f.claimant).hand.length, 4); + const game = claim(f.game, f.claimant, f.ghola); + assert.equal(player(game, f.claimant).hand.length, 4); + assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ghola)); + assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1); + assert.equal(game.response?.kind, 'revivalIncome'); + assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response); + assert.deepEqual(inventory(game), f.originalInventory); +}); + +void test('all minimal bot profiles can continue the preserved neutral offer', () => { + const f = fixture(); + for (const difficulty of DIFFICULTIES) { + let game = structuredClone(f.game); + const view = viewGame(game, f.claimant); + view.players.find(seat => seat.id === f.claimant)!.bot = difficulty; + const action = botActions(view).find(candidate => candidate.type === 'semutaPass' || candidate.type === 'semutaCommit'); + assert.ok(action, `${difficulty} needs a legal Semuta response`); + game = applyAction(game, f.claimant, action); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + const choice = botActions(viewGame(game, f.claimant)).find(candidate => candidate.type === 'semutaSelect'); + assert.ok(choice, `${difficulty} needs a legal discard selection`); + game = applyAction(game, f.claimant, choice); + } + if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = pass(game); + assert.equal(game.response?.kind, 'revivalIncome'); + assert.deepEqual(game.pendingChoamMarketGhola?.response, f.response); + assert.deepEqual(inventory(game), f.originalInventory); + } +});