diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 43c0e754..d917dfce 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 Ixian ally replacement with independent Ecaz cards — +29 October. Prototyped / Partial.** The existing neutral private-discard offer +now covers a supported paid ordinary Ixian ally replacement when the independent +Ecaz cards are selected. Claim or pass retains the original payment and draws +one replacement; an empty deck refills after the reaction. Actual public setup, +paid cache, Nexus alliance and purchase exercise an independent Ecaz card; only +the empty-deck variant rearranges conserved cards after that purchase. Related +controls and minimal bots pass. The five-file batch passes 34 cases, along with +type checking, lint and production build. The earlier Karama cost, other modules +and unsupported discard parents remain open; deployed acceptance is unverified. +[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#basic-ixian-ally-replacement-with-independent-ecaz-cards--29-october-2026). + **Basic bribes during pending openings and Extortion — 29 October. Prototyped / Partial.** Direct nonallied bribes now work during supported Basic Ix Amal openings and Moritani Extortion decisions without advancing the original diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 7eb51af1..3baa9d0b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,13 @@ # Rule decision index +## Basic Semuta after Ixian ally replacement with independent cards — 29 October 2026 + +The existing private Semuta exchange now applies to the discarded card from a +supported Basic paid Ixian allied replacement with independent Ecaz cards. +The original paid sale and replacement draw remain committed; other discard +parents remain open. +[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#basic-ixian-ally-replacement-with-independent-ecaz-cards--29-october-2026). + ## Basic bribes during pending openings and Extortion — 29 October 2026 The printed nonallied bribe rules do not prescribe digital interruption diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 7a396ea1..5bcfea31 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -1,5 +1,23 @@ # Semuta discard producers and continuation inventory +## Basic Ixian ally replacement with independent Ecaz cards — 29 October 2026 + +In a supported Basic Richese/Ix game with the independent Ecaz Treachery cards, +an allowed Ixian allied replacement now offers Semuta the discarded purchased +card before the original replacement draw. The neutral offer keeps the face +private until an eligible holder commits. Claim or pass preserves the paid sale +and performs the original replacement draw once; an exhausted deck refills only +after the reaction. The printed Semuta discard timing and existing private +exchange interpretation are unchanged. The earlier Karama cost used to cancel +replacement retains its prior independent-card behavior; other modules and +unhandled discard parents remain outside this slice. + +[Native runtime](../tests/semuta-independent-ix-ally.test.ts) starts a public +Basic game, acquires Semuta, forms the alliance at a real Nexus and pays for the +original auction card. An exhausted-deck case explicitly rearranges existing +physical cards after purchase. [Rendered controls](../tests/semuta-independent-ix-ally-controls.test.tsx) +cover the neutral offer and claimant's private outcome. + ## Basic Moritani Atomics — 29 October 2026 In supported Basic Richese/Moritani tables, a clean shipment or ground move diff --git a/game/engine.ts b/game/engine.ts index b450097d..d38b3512 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9798,7 +9798,7 @@ function semutaOfferSupported( // without a reaction. Keep its original automatic continuation; newly // staged offers and already-saved reactions retain the current rules. const independentNativeDiscard = ambassadorDiscard || continuation.kind === 'terrorDiscard' || - continuation.kind === 'terrorAtomics' || + continuation.kind === 'terrorAtomics' || continuation.kind === 'ixAllyCard' || continuation.kind === 'choamSaleDiscard' || continuation.kind === 'choamKulonDiscard' || continuation.kind === 'choamLaLaLaDiscard' || continuation.kind === 'choamBalisetDiscard' || continuation.kind === 'choamGamontDiscard' || continuation.kind === 'choamJubbaDiscard' || diff --git a/game/reference.ts b/game/reference.ts index 38597823..30e79911 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -6630,7 +6630,7 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.', 'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.', - 'In a fresh Basic game with Richese, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits or a used Hajr, Ghola, Harvester, Weather Control or Family Atomics immediately after another player discards it, including when other selected expansion factions are seated. With the Ix deck, clean Thumper and Amal discards can also be claimed before their original continuation. Supported battle cleanup, clean pre-leader Residual Poison, native Moritani allied retention, Ixian substitution, CHOAM sales and Worthless costs, clean Ambassador and Terror discards, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. The original effect stays committed; other Semuta opportunities remain unfinished.', + 'In a fresh Basic game with Richese, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits or a used Hajr, Ghola, Harvester, Weather Control or Family Atomics immediately after another player discards it, including when other selected expansion factions are seated. With the Ix deck, clean Thumper, Amal and an Ixian ally’s privately discarded ordinary-auction replacement card can also be claimed before their original continuation. Supported battle cleanup, clean pre-leader Residual Poison, native Moritani allied retention, Ixian substitution, CHOAM sales and Worthless costs, clean Ambassador and Terror discards, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. The original effect stays committed; other Semuta opportunities remain unfinished.', 'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.', 'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.', 'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.', diff --git a/tests/fixture-semuta-ix-ally-cost.ts b/tests/fixture-semuta-ix-ally-cost.ts index 0628c3ff..ffbde038 100644 --- a/tests/fixture-semuta-ix-ally-cost.ts +++ b/tests/fixture-semuta-ix-ally-cost.ts @@ -68,8 +68,11 @@ export function finishSemutaIxAllyCost(game: Game, actions: SemutaIxAllyCostStep /** Original R/C/I/F/G setup, cache1, actual worm Nexus pact and ordinary paid * auction. Neither the delivered card nor a replacement is installed. */ -export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } = {}) { - const advanced = options.advanced ?? false; +export function createSemutaIxAllyCostFixture(options: { advanced?: boolean; independent?: boolean; independentLot?: boolean } = {}) { + const advanced = options.advanced ?? false, independent = options.independent ?? false; + const independentLot = options.independentLot ?? false; + assert.ok(!independentLot || independent, 'The selected independent card needs the variant'); + assert.ok(!advanced || !independent, 'The independent native Semuta source is Basic only'); const richese = 'richese', holder = 'choam', owner = 'ixians', buyer = 'fremen', spender = 'guild'; let game = createGame('SEMUTAIXALLYCOST', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam', 'ix']); for (const faction of ['choam', 'ixians', 'fremen', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); @@ -80,16 +83,20 @@ export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } = const before = structuredClone(game), at = actions.length; run(); return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) }; }; + if (independent) act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true }); for (const p of game.players) act(p.id, { type: 'ready' }); const lobby = structuredClone(game); if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game)); else act(game.host, { type: 'start' }); const setup = structuredClone(game); - assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true); + assert.equal(game.status, 'setup'); + assert.equal(game.semutaPreview === true, !independent); + assert.equal(game.semutaRecruitsPreview === true, independent); + assert.equal(game.ecazTreachery === true, independent); assert.ok(game.players.every(p => p.hand.length === 0)); assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && - !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); - const originalInventory = semutaIxAllyCostInventory(game); assert.equal(originalInventory.length, 57); + !game.strongholdCards && !game.discoveryEnabled); + const originalInventory = semutaIxAllyCostInventory(game); assert.equal(originalInventory.length, independent ? 60 : 57); const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-')); const shield = game.deck.find(card => card.kind === 'shield'); const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 3); @@ -141,10 +148,20 @@ export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } = }); assert.equal(semutaIxAllyCostPlayer(game, owner).ally, buyer); assert.equal(semutaIxAllyCostPlayer(game, buyer).ally, owner); + // Optional source variation selects one still-unopened card into Ix's next + // inspected pool. Ix returns the first inspected card, and controlled scalar + // lottery leaves the rest in order, so Recruits becomes the first real lot. + let selectedIndependentLot: string | null = null; + if (independentLot) { + const index = game.deck.findIndex(card => card.id === 'ecaz-recruits'); assert.ok(index >= 0); + const [card] = game.deck.splice(index, 1); game.deck.splice(1, 0, card); + selectedIndependentLot = card.id; + } const beforeBidding = structuredClone(game), biddingAt = actions.length; reach(state => state.turn === 2 && state.phase === 3 && !!state.auction && state.auction.index === 0 && state.auction.active === buyer && semutaIxAllyCostClean(state)); const pool = { before: beforeBidding, steps: structuredClone(actions.slice(biddingAt)), after: structuredClone(game) }; + if (selectedIndependentLot) assert.equal(game.auction!.cards[game.auction!.index].id, selectedIndependentLot); let paymentBefore: Game | null = null, paymentAt = -1; const beforePurchase = structuredClone(game), purchaseAt = actions.length; const awaitingReplacement = (state: Game) => state.decision?.kind === 'ixAllyCard'; @@ -171,7 +188,7 @@ export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } = assert.ok(game.response?.kind === 'ixAllyCard'); assert.equal(game.response.owner, owner); assert.equal(game.response.recipient, buyer); assert.equal(game.decision ?? null, null); assert.ok(game.deck[0]); assert.deepEqual(semutaIxAllyCostInventory(game), originalInventory); - return { game, kind: 'ixAllyCard' as const, advanced, blackMarket: false as const, + return { game, kind: 'ixAllyCard' as const, advanced, independent, selectedIndependentLot, blackMarket: false as const, owner, holder, spender, buyer, recipient: buyer, richese, cost, karama: cost, semuta: SEMUTA_DRUG_ID, action: { type: 'card', card: cost, mode: 'cancel' } as Action, originalResponse: structuredClone(game.response), originalPending: structuredClone(game.pendingIxAlly!), @@ -182,10 +199,10 @@ export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } = acquisition, acquired, cache: acquisition, alliance, pact: alliance, pool, purchase, payment, request, actions, steps: actions, sourceActionCount: actions.length, staging: [ - 'Original public Basic start or original undealt Advanced initializer for R/C/I/F/G with native CHOAM/Ix decks. Only unopened deal/Spice positions and scalar lotteries are selected. Native Ix chooses its inspected Shield; Guild receives the printed Karama; CHOAM actually pays cache1 for Semuta.', + `Original public Basic start${independent ? ' with independent Ecaz cards' : ''} or original undealt Advanced initializer for R/C/I/F/G with native CHOAM/Ix decks. Only unopened deal/Spice positions and scalar lotteries are selected. Native Ix chooses its inspected Shield; Guild receives the printed Karama; CHOAM actually pays cache1 for Semuta.`, 'Original first-turn movement is unused. A real turn2 worm over the empty Funeral pile opens Nexus; Ix and Fremen actually propose and accept their pact. Native HMS, pool-return, optional Technology decline and other original controls remain real actions.', 'Fremen bids and pays one own spice for an actual ordinary auction card. There is no Emperor seat, so the printed payment goes to the Bank. The card is delivered into the buyer hand and native pendingIxAlly/currentAuctionSale remain; Fremen really requests replacement before the cancellation cost.', - 'No played hand, wallet, card, ally, force, clock, pool, purchase, pending control or replacement is installed. Cache replacement and absent-Ix Secret Ally are outside these ordinary native sources.', + `${independentLot ? 'One still-unopened Recruits was selected into Ix’s later inspected pool before bidding. ' : ''}No played hand, wallet, card, ally, force, clock, pool, purchase, pending control or replacement is installed. Cache replacement and absent-Ix Secret Ally are outside these ordinary native sources.`, ] }; } export type SemutaIxAllyCostFixture = ReturnType; diff --git a/tests/semuta-independent-ix-ally-controls.test.tsx b/tests/semuta-independent-ix-ally-controls.test.tsx new file mode 100644 index 00000000..6f969989 --- /dev/null +++ b/tests/semuta-independent-ix-ally-controls.test.tsx @@ -0,0 +1,70 @@ +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 { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { createSemutaIxAllyCostFixture, allowSemutaIxAllyCost } from './fixture-semuta-ix-ally-cost'; + +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)]; +} +function label(node: unknown): string { + if (Array.isArray(node)) return node.map(label).join(''); + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (!node || typeof node !== 'object' || !('props' in node)) return ''; + return label((node as ReactElement<{children?: unknown}>).props.children); +} +const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props)); +function buttonAction(game: Game, actor: string, name: string, expected: Action): Action { + const sent: Action[] = [], props: Props = {game:viewGame(game, actor), busy:false, + act(action) { sent.push(action); }}; + const button = controls(SemutaReaction(props)).find(item => label(item.children) === name); + assert.ok(button, `Expected the ${name} control`); assert.equal(button.disabled, false); + const busy = controls(SemutaReaction({...props, busy:true})).find(item => label(item.children) === name); + assert.ok(busy); assert.equal(busy.disabled, true); busy.onClick(); assert.deepEqual(sent, []); + button.onClick(); assert.deepEqual(sent, [expected]); return sent[0]; +} + +void test('independent Ecaz-card Ixian replacement exposes a neutral offer and privately claims its single card', () => { + const f = createSemutaIxAllyCostFixture({independent:true}); + assert.equal(f.game.advanced, false); assert.equal(f.game.ecazTreachery, true); + assert.equal(f.game.semutaRecruitsPreview, true); + let game = allowSemutaIxAllyCost(structuredClone(f.game)); + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + assert.equal(pending.continuation.kind, 'ixAllyCard'); assert.equal(pending.reaction.stage, 'offer'); + const event = pending.batch.event, discarded = f.deliveredCard; + assert.deepEqual(pending.batch.entries.map(row => ({id:row.card.id, owner:row.discardedBy, face:row.publicFace})), + [{id:discarded.id, owner:f.buyer, face:false}]); + const holder = viewGame(game, f.holder), observer = viewGame(game, f.owner); + assert.equal(holder.semutaReaction?.canCommit, true); + assert.equal(observer.semutaReaction?.canCommit, false); + assert.deepEqual(holder.semutaReaction?.candidates, []); + assert.deepEqual(observer.semutaReaction?.candidates, []); + const holderHtml = markup({game:holder, act() {}, busy:false}); + const observerHtml = markup({game:observer, act() {}, busy:false}); + for (const html of [holderHtml, observerHtml]) { + assert.match(html, /Fresh Treachery discard/); assert.match(html, />Continue<\/button>/); + assert.ok(!html.includes(discarded.id)); assert.ok(!html.includes(discarded.name)); + } + assert.match(holderHtml, />Commit Semuta Drug<\/button>/); + assert.doesNotMatch(observerHtml, /Commit Semuta Drug/); + const pass = buttonAction(game, f.owner, 'Continue', {type:'semutaPass', event}); + game = applyAction(game, f.owner, pass); + assert.match(markup({game:viewGame(game, f.owner), act() {}, busy:false}), /You continued/); + const commit = buttonAction(game, f.holder, 'Commit Semuta Drug', {type:'semutaCommit', event}); + game = applyAction(game, f.holder, commit); + assert.equal(game.pendingTreacheryDiscard, null, 'A sole physical candidate is claimed without an extra confirmation.'); + const own = viewGame(game, f.holder), rival = viewGame(game, f.owner); + assert.ok(own.players.find(player => player.id === f.holder)?.hand?.some(card => card.id === discarded.id)); + assert.equal(rival.players.find(player => player.id === f.holder)?.hand, undefined); + assert.equal(SemutaReaction({game:own, act() {}, busy:false}), null); + const saved = structuredClone(game); + assert.throws(() => applyAction(game, f.holder, commit)); + assert.deepEqual(game, saved); +}); diff --git a/tests/semuta-independent-ix-ally.test.ts b/tests/semuta-independent-ix-ally.test.ts new file mode 100644 index 00000000..96b4d71c --- /dev/null +++ b/tests/semuta-independent-ix-ally.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + createSemutaIxAllyCostFixture as fixture, actSemutaIxAllyCost as act, + passSemutaIxAllyCost as pass, claimSemutaIxAllyCost as claim, + allowSemutaIxAllyCost as allow, finishSemutaIxAllyCost as finish, + semutaIxAllyCostPlayer as player, semutaIxAllyCostInventory as inventory, + type SemutaIxAllyCostFixture, +} from './fixture-semuta-ix-ally-cost'; + +/** The producer is a real ordinary paid purchase and native alliance use. */ +function source(f: SemutaIxAllyCostFixture) { + assert.equal(f.independent,true); assert.equal(f.setup.semutaRecruitsPreview,true); + assert.equal(f.setup.semutaPreview,undefined); assert.equal(f.setup.ecazTreachery,true); + assert.equal(f.originalInventory.length,60); + assert.ok(f.lobby.players.every(seat => seat.ready)); + assert.equal(f.pact.before.nexus,true); + assert.deepEqual(f.pact.steps.map(step => step.action.type),['alliance','alliance']); + assert.equal(player(f.game,f.owner).ally,f.buyer); assert.equal(player(f.game,f.buyer).ally,f.owner); + assert.equal(f.originalSale.origin,'normal'); assert.equal(f.originalSale.amount,1); + assert.equal(player(f.payment.before,f.buyer).spice - player(f.payment.after,f.buyer).spice,1); + assert.ok(f.purchase.steps.some(step => step.actor === f.buyer && step.action.type === 'bid')); + assert.equal(f.game.response?.kind,'ixAllyCard'); + assert.ok(player(f.game,f.buyer).hand.some(card => card.id === f.deliveredCardId)); + assert.deepEqual(inventory(f.game),f.originalInventory); +} +function open(f: SemutaIxAllyCostFixture, game = structuredClone(f.game)): Game { + game = allow(game); + const pending = game.pendingTreacheryDiscard; assert.ok(pending); + assert.equal(pending.continuation.kind,'ixAllyCard'); + assert.equal(pending.reaction?.stage,'offer'); + assert.deepEqual(pending.batch.entries.map(entry => ({id:entry.card.id,by:entry.discardedBy,public:entry.publicFace})), + [{id:f.deliveredCardId,by:f.buyer,public:false}]); + assert.equal(game.discard.filter(card => card.id === f.deliveredCardId).length,1); + assert.equal(player(game,f.buyer).hand.some(card => card.id === f.deliveredCardId),false); + assert.deepEqual(game.currentAuctionSale,f.originalSale); + assert.equal(viewGame(game,f.holder).semutaReaction?.canCommit,true); + for (const seat of game.players) { + const reaction = viewGame(game,seat.id).semutaReaction; assert.ok(reaction); + assert.deepEqual(reaction.candidates,[]); + assert.equal(reaction.canCommit,seat.id === f.holder); + } + return game; +} + +for (const claiming of [false,true]) void test( + `independent-card native Ix ally discard offers Semuta ${claiming ? 'claim' : 'pass'} before the replacement draw`, () => { + const f = fixture({independent:true,independentLot:true}); source(f); + assert.equal(f.deliveredCardId,'ecaz-recruits'); assert.equal(f.selectedIndependentLot,f.deliveredCardId); + const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event; + assert.deepEqual(offered.deck,f.originalDeck); + const buyerSpice = player(offered,f.buyer).spice, buyerHand = player(offered,f.buyer).hand.length; + const after = claiming ? claim(offered,f.holder) : pass(offered); + const done = finish(after); + assert.equal(done.pendingTreacheryDiscard ?? null,null); + assert.equal(done.currentAuctionSale ?? null,null); assert.equal(done.pendingIxAlly ?? null,null); + assert.equal(done.auction?.index,f.originalAuction.index + 1); + assert.equal(player(done,f.buyer).spice,buyerSpice); + assert.equal(player(done,f.buyer).hand.length,buyerHand + 1); + assert.equal(player(done,f.buyer).hand.filter(card => card.id === f.replacementTop.id).length,1); + assert.deepEqual(done.deck,f.originalDeck.slice(1)); + assert.equal(player(done,f.holder).hand.some(card => card.id === f.deliveredCardId),claiming); + assert.equal(done.discard.some(card => card.id === f.deliveredCardId),!claiming); + assert.equal(done.discard.some(card => card.id === f.semuta),claiming); + assert.deepEqual(inventory(done),f.originalInventory); + const before = structuredClone(done); + assert.throws(() => act(done,f.holder,{type:'semutaCommit',event})); + assert.deepEqual(done,before); + }, +); + +for (const claiming of [false,true]) void test( + `independent-card Ix ally ${claiming ? 'claim' : 'pass'} precedes a controlled empty-deck reshuffle`, () => { + const f = fixture({independent:true,independentLot:true}); source(f); + const arranged = structuredClone(f.game); + // Controlled conserved shortage after the real paid purchase: original + // undealt deck moves to discard, without touching the delivered lot. + arranged.discard.push(...arranged.deck); arranged.deck = []; + assert.deepEqual(inventory(arranged),f.originalInventory); + const offered = open(f,arranged), originalDiscard = offered.discard.map(card => card.id); + assert.equal(offered.deck.length,0); assert.ok(originalDiscard.includes(f.deliveredCardId)); + const buyerHand = player(offered,f.buyer).hand.map(card => card.id); + const buyerSpice = player(offered,f.buyer).spice; + const after = claiming ? claim(offered,f.holder) : pass(offered); + const done = finish(after); + const replacement = player(done,f.buyer).hand.filter(card => !buyerHand.includes(card.id)); + assert.equal(replacement.length,1); + const eligible = new Set(originalDiscard.filter(id => !claiming || id !== f.deliveredCardId)); + if (claiming) eligible.add(f.semuta); + assert.ok(eligible.has(replacement[0].id)); + assert.equal(player(done,f.buyer).spice,buyerSpice); + assert.equal(done.currentAuctionSale ?? null,null); assert.equal(done.pendingIxAlly ?? null,null); + assert.equal(done.auction?.index,f.originalAuction.index + 1); + assert.equal(player(done,f.holder).hand.some(card => card.id === f.deliveredCardId),claiming); + assert.deepEqual(inventory(done),f.originalInventory); + }, +); + +void test('all minimal bot profiles can legally answer the original independent-card Ix discard offer', () => { + const f = fixture({independent:true}); source(f); + const offered = open(f); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(offered,f.holder); + view.players.find(seat => seat.id === f.holder)!.bot = difficulty; + const action = botActions(view).find(item => item.type === 'semutaPass' || item.type === 'semutaCommit'); + assert.ok(action); + const next = act(structuredClone(offered),f.holder,action); + assert.deepEqual(inventory(next),f.originalInventory); + } +}); + +void test('independent-card native Ix replacement cancellation spends its real Karama without a Semuta cost offer', () => { + const f = fixture({independent:true}); source(f); + let game = act(structuredClone(f.game),f.spender,f.action); + if (game.pendingKull) { + const reaction = viewGame(game,'choam').kullReaction; assert.ok(reaction); + game = act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true}); + } + assert.equal(game.pendingTreacheryDiscard ?? null,null); + assert.equal(game.response?.kind === 'ixAllyCard',false); + assert.equal(player(game,f.spender).hand.some(card => card.id === f.cost),false); + assert.equal(game.discard.filter(card => card.id === f.cost).length,1); + const done = finish(game); + assert.equal(done.currentAuctionSale ?? null,null); assert.equal(done.pendingIxAlly ?? null,null); + assert.equal(done.auction?.index,f.originalAuction.index + 1); + assert.deepEqual(done.deck,f.originalDeck); + assert.ok(player(done,f.buyer).hand.some(card => card.id === f.deliveredCardId)); + assert.ok(player(done,f.holder).hand.some(card => card.id === f.semuta)); + assert.deepEqual(inventory(done),f.originalInventory); +});