diff --git a/components/game-table.tsx b/components/game-table.tsx index 6ec756d7..a5c1c4f0 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1312,7 +1312,7 @@ export function GameTable({ )} {g.semutaAdvancedOrdinaryPreview && (

- Advanced Semuta preview: supported Hajr, Ghola, Harvester, Weather Control, completed Box, Distrans and simple Truthtrance discards can be claimed, along with supported battle cleanup. Other discard opportunities remain unfinished. + Advanced Semuta preview: supported Hajr, Ghola, Harvester, Weather Control, completed Box, Distrans, simple Truthtrance, Residual Poison and CHOAM sale or Worthless discards can be claimed, along with supported battle cleanup. Other discard opportunities remain unfinished.

)} {g.semutaPreview && ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e29ea56b..5ac375b7 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,14 @@ open mode gates. ## Current checkpoint and work +**Advanced CHOAM and Residual Poison Semuta — 29 October. Prototyped / Partial.** +The public Advanced Richese preview now offers Semuta after supported CHOAM +sales, printed Worthless costs and clean pre-leader Residual Poison. Claim or +pass resumes the original effect; the public-start journeys stage conserved +cards and phase or battle state. The 105 related cases, quick type/lint checks and production build +pass. Other continuations and optional modules remain open. +[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#advanced-choam-and-residual-poison-discards--29-october-2026). + **Advanced Richese battle and special Semuta — 29 October. Prototyped / Partial.** The public Advanced preview now supports Semuta after clean paid Box searches, Distrans, simple definite Truthtrance answers and supported loser, mandatory diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index a0bd237c..545450d1 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -1,5 +1,19 @@ # Semuta discard producers and continuation inventory +## Advanced CHOAM and Residual Poison discards — 29 October 2026 + +**Prototyped / Partial.** The public Advanced Richese and classic/CHOAM preview +also offers Semuta after a completed CHOAM sale, supported printed Kulon, +La La La, Baliset, Trip to Gamont or Jubba Cloak costs, and a clean pre-leader +Residual Poison. Claim or pass retains the original payment, restriction, +movement, force return, storm protection or single leader death and continuation. +Private Nexus substitutions and other parents remain outside this slice. + +The [CHOAM journeys](../tests/semuta-advanced-public-choam.test.ts) and +[Residual Poison journeys](../tests/semuta-advanced-public-residual.test.ts) +start public Advanced games and stage conserved cards and relevant phase or +battle state, then exercise native actions; they do not certify a full game. + ## Advanced classic battle and special discards — 29 October 2026 **Prototyped / Partial.** The public Advanced classic/Richese preview extends diff --git a/game/engine.ts b/game/engine.ts index 5d9d26b8..eef1134f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9732,6 +9732,10 @@ function semutaOfferSupported( continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleCleanup' && continuation.source === 'winner' || continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' || + continuation.kind === 'residualPoisonDiscard' || continuation.kind === 'choamSaleDiscard' || + ((continuation.kind === 'choamKulonDiscard' || continuation.kind === 'choamLaLaLaDiscard' || + continuation.kind === 'choamBalisetDiscard' || continuation.kind === 'choamGamontDiscard' || + continuation.kind === 'choamJubbaDiscard') && continuation.nexusEvent === undefined) || continuation.kind === 'truthtranceDiscard' && continuation.promise === null && (continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform')) || g.semutaRecruitsPreview === true && @@ -13259,6 +13263,11 @@ function basicIndependentRecruitsSemutaProfile(g: Game): boolean { function semutaCardProducerEnabled(g: Game): boolean { return g.semutaPreview === true || g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(g); } +/** The public Advanced profile supports only the explicitly admitted native costs. */ +function semutaAdvancedNativeProducerEnabled(g: Game): boolean { + return semutaCardProducerEnabled(g) || + g.semutaAdvancedOrdinaryPreview === true && bareAdvancedClassicSemutaProfile(g); +} /** Bare native Basic starts share the original deck/roster validation. Optional * modules retain their own explicit Semuta envelopes; saved games are not upgraded. */ function bareBasicSemutaProfile(g: Game): boolean { @@ -30873,7 +30882,7 @@ function playResidualPoison( `${p.name} played Residual Poison against ${target.name}. ${victim.name} was randomly selected from the available leaders and sent to the Tanks. No spice is awarded, no forces are lost, and battle preparation continues.`, { faction: p.faction, name: 'Residual Poison' }, ); - if (g.semutaPreview || g.semutaRecruitsPreview && basicIndependentRecruitsSemutaProfile(g)) { + if (semutaAdvancedNativeProducerEnabled(g)) { const continuation: ResidualPoisonDiscardContinuation = { kind: 'residualPoisonDiscard', player: p.id, target: target.id, victim: victim.id, battleEvent: b.event!, card: used.id, @@ -37189,7 +37198,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl } if (pending.effect === 'kulon') { rememberMovementDiscards(g, [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]); - if (semutaCardProducerEnabled(g) && (used.name === 'Kulon' || pending.nexusEvent) && + if (semutaAdvancedNativeProducerEnabled(g) && (used.name === 'Kulon' || pending.nexusEvent) && !pending.storm && !pending.revival && !pending.movement && !pending.mentat) { const continuation: ChoamKulonDiscardContinuation = { @@ -37207,7 +37216,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl } completeChoamKulon(g, choam); } else if (pending.effect === 'jubba') { - if (semutaCardProducerEnabled(g) && (used.name === 'Jubba Cloak' || pending.nexusEvent) && + if (semutaAdvancedNativeProducerEnabled(g) && (used.name === 'Jubba Cloak' || pending.nexusEvent) && pending.storm && !pending.revival && !pending.movement && !pending.mentat) { const continuation: ChoamJubbaDiscardContinuation = { @@ -37224,7 +37233,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl } completeChoamJubba(g, choam, pending.location!); } else if (pending.effect === 'baliset') { - if (semutaCardProducerEnabled(g) && (used.name === 'Baliset' || pending.nexusEvent) && + if (semutaAdvancedNativeProducerEnabled(g) && (used.name === 'Baliset' || pending.nexusEvent) && !pending.storm && !pending.revival && !pending.mentat && (!pending.movement || (g.pendingChoamMove && !g.pendingChoamMove.source && !g.pendingChoamMove.ornithopterEvent && !g.pendingChoamMove.ornithopterRange && @@ -37259,7 +37268,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl if (ambassador) offerAmbassadorRelocation(g); } } else if (pending.effect === 'gamont') { - if (semutaCardProducerEnabled(g) && (used.name === 'Trip to Gamont' || pending.nexusEvent) && + if (semutaAdvancedNativeProducerEnabled(g) && (used.name === 'Trip to Gamont' || pending.nexusEvent) && !pending.storm && !pending.revival && !pending.movement) { const continuation: ChoamGamontDiscardContinuation = { kind: 'choamGamontDiscard', player: choam.id, @@ -37281,7 +37290,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl pending.elite!, pending.noFieldEvent, { card: used.id, mentat: pending.mentat, nexusEvent: pending.nexusEvent }); } else { - if (semutaCardProducerEnabled(g) && (used.name === 'La La La' || pending.nexusEvent) && + if (semutaAdvancedNativeProducerEnabled(g) && (used.name === 'La La La' || pending.nexusEvent) && !pending.storm && !pending.movement && !pending.mentat && (!pending.revival || (g.pendingRevival?.kind === 'forces' && g.pendingRevival.player === pending.target))) { @@ -37563,7 +37572,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl owner.spice += sale.price; log(g, `${owner.name} received ${sale.price} spice from the card sale.`); delete market.sale; - if (semutaCardProducerEnabled(g)) { + if (semutaAdvancedNativeProducerEnabled(g)) { const continuation: ChoamSaleDiscardContinuation = { kind: 'choamSaleDiscard', owner: owner.id, card: used.id, witness: sale.witness ?? null, price: sale.price, diff --git a/game/reference.ts b/game/reference.ts index ea521f9a..78b9965b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1341,7 +1341,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ? '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, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, 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, proactive or reactive La La La disposal before blocking free revival, proactive or reactive Baliset before restricting movement, proactive Trip to Gamont before its ordinary/elite return or native No-Field reveal and capped recall, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, other reactive Worthless uses, inspected Residual Poison battles, nested responses, other pending rewards and wider interactions remain unfinished.' : 'Reference component only: this Richese card’s game actions are not enabled.', ...(definition.card.effect === 'semutaDrug' ? [ - 'Fresh Advanced Richese preview games with only the CHOAM expansion deck and classic or CHOAM opponents enable Semuta reactions for supported ordinary Hajr, Ghola, Harvester and Weather Control discards, completed Box and Distrans, simple definite Truthtrance answers and supported battle cleanup. Other discard parents and optional-module combinations remain unfinished. Existing games are not retrofitted.', + 'Fresh Advanced Richese preview games with only the CHOAM expansion deck and classic or CHOAM opponents enable Semuta reactions for supported ordinary Hajr, Ghola, Harvester and Weather Control discards, completed Box and Distrans, simple definite Truthtrance answers, clean Residual Poison, completed CHOAM sales, supported printed CHOAM Worthless costs and supported battle cleanup. Other discard parents and optional-module combinations remain unfinished. Existing games are not retrofitted.', 'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.', 'Mandatory and winner battle discards also pause before native Tleilaxu Face Dance. Claim or Continue preserves the original winner’s casualties and card cleanup, then restores the same private Face Dancer opportunity. Replacement, leader death and force sources remain native; the claimed Treachery card stays with its claimant, and no Face Dancer card becomes a Semuta target.', 'Mandatory and winner battle discards also pause before quoted Advanced CHOAM force-payment income. Claim or Continue leaves the original amount unpaid through casualties and card cleanup, then opens the same allow/Karama response. Allow pays once; cancellation sends that income to the bank without refunding the original force payments. The original post-battle market still completes afterward.', diff --git a/tests/semuta-advanced-public-choam.test.ts b/tests/semuta-advanced-public-choam.test.ts new file mode 100644 index 00000000..3fb92804 --- /dev/null +++ b/tests/semuta-advanced-public-choam.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as seat } from './fixture-basic-semuta-ambassador'; +import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; + +type Kind = 'sale' | 'kulon' | 'laLaLa' | 'baliset' | 'gamont' | 'jubba'; +const names: Record = {sale:'Baliset',kulon:'Kulon',laLaLa:'La La La', + baliset:'Baliset',gamont:'Trip to Gamont',jubba:'Jubba Cloak'}; +const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); + +/** Native public Advanced setup; the relevant physical cards and later + * phase/board position are explicitly staged. The power, cancellation queue, + * payment, Semuta and continuation are all original actions. */ +function fixture(kind: Kind) { + let game = createGame('ADVSEMUTACHOAM', newPlayer('c','CHOAM','choam'), true, ['choam']); + for (const faction of ['richese','atreides'] as const) + joinGame(game,newPlayer(faction,faction,faction)); + for (const p of game.players) game = act(game,p.id,{type:'ready'}); + game = act(game,'c',{type:'start',advancedPreview:true}); + assert.equal(game.status,'setup'); assert.equal(game.semutaAdvancedOrdinaryPreview,true); + for (let n=0; game.status==='setup' && n<160; n++) { + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + game = act(game,next.actor,next.action); + } + assert.equal(game.status,'playing'); + const cards = inventory(game); + const card = [game.deck,game.discard,game.richeseCache!,...game.players.map(p=>p.hand)] + .flat().find(item=>item.name===names[kind]); assert.ok(card); + moveSemutaEmperorCard(game,SEMUTA_DRUG_ID,'richese'); + moveSemutaEmperorCard(game,card.id,'c'); + assert.deepEqual(inventory(game),cards); + if (kind==='sale') { + for (let n=0;game.decision?.kind!=='choamMarket' && n<160;n++) { + const next=nextRicheseStrongholdsNativeStep(game); assert.ok(next); + game=act(game,next.actor,next.action); + } + assert.equal(game.decision?.kind,'choamMarket'); + return {game, card:card.id, cards, action:{type:'decision',mode:'sell',card:card.id} as Action}; + } + for (const p of game.players) { + p.reserves += Object.values(p.forces).reduce((sum,n)=>sum+n,0); + p.forces={}; + } + seat(game,'c').reserves--;seat(game,'c').forces['red_chasm:7']=1; + seat(game,'atreides').reserves--;seat(game,'atreides').forces['imperial_basin:10']=1; + Object.assign(game,{turn:2,phase:kind==='laLaLa'?4:kind==='gamont'?8:kind==='jubba'?0:5, + storm:6,active:'c',ready:[],response:null,decision:null,phaseOpening:null, + choamMarket:null,movementRemaining:['c','richese','atreides']}); + for (const p of game.players) {p.shipped=false;p.moved=0;} + if (kind==='jubba') { + game.stormPending=1; + game.stormResolution={from:6,distance:1,traversed:0,pending:[]}; + game.decision={kind:'choamStorm',player:'c', + territories:[{territory:'red_chasm',amount:1,sectors:[7]}],protected:[]}; + } + const selection: Partial> = + kind==='laLaLa' ? {target:'atreides'} : kind==='baliset' ? {target:'atreides',territory:'red_chasm'} : + kind==='gamont' ? {target:'atreides',from:'imperial_basin:10',elite:0} : + kind==='jubba' ? {territory:'red_chasm'} : {}; + return {game,card:card.id,cards,action:{type:'card',mode:'choam',card:card.id,...selection} as Action}; +} + +function offer(f: ReturnType) { + let game=act(f.game,'c',f.action); + for (let n=0;game.response && n<32;n++) { + const p=game.players.find(row=>!game.response!.passed.includes(row.id));assert.ok(p); + game=act(game,p.id,{type:'passResponse'}); + } + assert.equal(game.response??null,null); + return game; +} + +for (const kind of ['sale','kulon','laLaLa','baliset','gamont','jubba'] as const) + for (const claim of [false,true]) void test(`Advanced public CHOAM ${kind}: Semuta ${claim?'claim':'pass'} preserves the printed effect`,()=>{ + const f=fixture(kind), before=structuredClone(f.game), pending=offer(f); + assert.deepEqual(f.game,before); + const batch=pending.pendingTreacheryDiscard; assert.ok(batch?.reaction); + assert.equal(batch.reaction.stage,'offer'); + assert.deepEqual(batch.batch.entries.map(entry=>entry.card.id),[f.card]); + assert.equal(viewGame(pending,'richese').semutaReaction?.canCommit,true); + assert.deepEqual(viewGame(pending,'richese').semutaReaction?.candidates,[]); + assert.equal(viewGame(pending,'atreides').semutaReaction?.canCommit,false); + assert.deepEqual(viewGame(pending,'atreides').semutaReaction?.candidates,[]); + if (kind==='sale') assert.equal(seat(pending,'c').spice,seat(f.game,'c').spice+2); + if (kind==='kulon') for (const difficulty of DIFFICULTIES) { + const view=viewGame(pending,'richese');view.players.find(p=>p.id==='richese')!.bot=difficulty; + assert.ok(botActions(view).some(action=>action.type==='semutaCommit'||action.type==='semutaPass')); + } + const event=batch.batch.event; + let done=pending; + if (claim) { + done=act(done,'richese',{type:'semutaCommit',event}); + if(done.pendingTreacheryDiscard?.reaction?.stage==='select') + done=act(done,'richese',{type:'semutaSelect',event,card:f.card}); + } else for (const p of pending.players) done=act(done,p.id,{type:'semutaPass',event}); + assert.equal(done.pendingTreacheryDiscard,null); + assert.equal(seat(done,'richese').hand.some(card=>card.id===f.card),claim); + assert.equal(done.discard.some(card=>card.id===f.card),!claim); + assert.equal(done.discard.some(card=>card.id===SEMUTA_DRUG_ID),claim); + assert.deepEqual(inventory(done),f.cards); + if (kind==='sale') { + assert.equal(seat(done,'c').spice,seat(f.game,'c').spice+2); + assert.equal(done.decision?.kind,'choamMarket'); + } else if (kind==='kulon') assert.equal(viewGame(done,'c').choamMovementBonus,1); + else if (kind==='laLaLa') assert.equal(viewGame(done,'atreides').revival.freeBlocked,true); + else if (kind==='baliset') assert.ok(viewGame(done,'atreides').balisetRestrictions.some(row=>row.territory==='red_chasm')); + else if (kind==='gamont') { + assert.equal(seat(done,'atreides').reserves,seat(f.game,'atreides').reserves+1); + assert.equal(seat(done,'atreides').forces['imperial_basin:10']??0,0); + } else assert.equal(seat(done,'c').forces['red_chasm:7'],1); + assert.throws(()=>act(done,'richese',{type:'semutaCommit',event})); + }); diff --git a/tests/semuta-advanced-public-controls.test.tsx b/tests/semuta-advanced-public-controls.test.tsx index 6e9cf6e2..1b9fd37f 100644 --- a/tests/semuta-advanced-public-controls.test.tsx +++ b/tests/semuta-advanced-public-controls.test.tsx @@ -34,7 +34,7 @@ void test('public Advanced Richese setup displays the limited ordinary Semuta no const html = renderToStaticMarkup(createElement(GameTable, { game:view, busy:false, send:async () => {}, onExit() {}, })); - assert.match(html, /Advanced Semuta preview: supported Hajr, Ghola, Harvester, Weather Control, completed Box, Distrans and simple Truthtrance discards can be claimed/); + assert.match(html, /Advanced Semuta preview: supported Hajr, Ghola, Harvester, Weather Control, completed Box, Distrans, simple Truthtrance, Residual Poison and CHOAM sale or Worthless discards can be claimed/); assert.match(html, /supported battle cleanup/); assert.match(html, /Other discard opportunities remain unfinished/); assert.doesNotMatch(html, /Semuta Drug development preview/); diff --git a/tests/semuta-advanced-public-residual.test.ts b/tests/semuta-advanced-public-residual.test.ts new file mode 100644 index 00000000..ad572aab --- /dev/null +++ b/tests/semuta-advanced-public-residual.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; + +const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); +const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!; + +function inventory(game: Game): string[] { + const cards = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [], + ...game.players.map(seat => seat.hand)].flat().map(card => card.id).sort(); + assert.equal(new Set(cards).size, cards.length); + return cards; +} + +/** A genuine Advanced public start followed by native setup. Only the later + * card custody, board counters and battle phase are staged; Poison, readiness, + * Semuta and battle resolution use the actual actions. */ +function fixture() { + let game = createGame('ADVSEMUTARESIDUAL', newPlayer('e', 'Emperor', 'emperor'), true, ['choam']); + for (const [id, faction] of [['a', 'atreides'], ['r', 'richese'], ['g', 'guild']] as const) + joinGame(game, newPlayer(id, faction, faction)); + for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); + game = act(game, 'e', {type:'start', advancedPreview:true}); + assert.equal(game.semutaAdvancedOrdinaryPreview, true); + assert.equal(game.semutaPreview, undefined); + for (let count = 0; game.status === 'setup' && count < 150; count++) { + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + game = act(game, next.actor, next.action); + } + assert.equal(game.status, 'playing'); + const original = inventory(game); + for (const seat of game.players) game.deck.push(...seat.hand.splice(0)); + const take = (predicate: (card: Game['deck'][number]) => boolean) => { + for (const zone of [game.deck, game.discard, game.richeseCache!]) { + const index = zone.findIndex(predicate); + if (index >= 0) return zone.splice(index, 1)[0]; + } + throw Error('Missing an original physical card.'); + }; + const poison = take(card => card.effect === 'residualPoison'); + const semuta = take(card => card.id === SEMUTA_DRUG_ID); + player(game, 'e').hand.push(poison); + player(game, 'g').hand.push(semuta); + for (const id of ['e', 'a']) { + const seat = player(game, id); + seat.reserves += Object.values(seat.forces).reduce((total, amount) => total + amount, 0); + seat.forces = {}; + if (seat.elites) { + seat.elites.reserves += Object.values(seat.elites.forces).reduce((total, amount) => total + amount, 0); + seat.elites.forces = {}; + } + seat.reserves -= 3; + seat.forces['red_chasm:7'] = 3; + } + Object.assign(game, {turn:2, phase:6, storm:18, active:'e', order:['e','a','r','g'], + ready:[], response:null, decision:null, phaseOpening:null, battle:null, movementRemaining:[]}); + assert.deepEqual(inventory(game), original); + game = act(game, 'e', {type:'chooseBattle', territory:'red_chasm', target:'a'}); + for (let count = 0; count < 80; count++) { + if (game.battle?.preLeader && !game.battle.preLeader.closed && !game.response && + !game.decision && !game.phaseOpening && !game.battle.preparation) break; + if (game.response) { + const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(next); + game = act(game, next.id, {type:'passResponse'}); + } else if (game.decision?.kind === 'fullPlanOffer') { + game = act(game, game.decision.player, {type:'decision', decline:true}); + } else if (game.battle?.preparation) { + game = act(game, game.battle.preparation.owner, {type:'declineBattlePower'}); + } else { + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + game = act(game, next.actor, next.action); + } + } + assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed); + const quote = viewGame(game, 'e').residualPoison; + assert.ok(quote && !quote.blocked && quote.event && quote.target); + return {game, poison:poison.id, event:game.battle.event!, original, + action:{type:'card', card:poison.id, target:quote.target, event:quote.event} as Action}; +} + +function finishBattle(state: Game): Game { + let game = state; + const battleEvent = game.battle!.event!; + for (const id of ['e', 'a']) if (!game.battle!.preLeader!.ready.includes(id)) + game = act(game, id, {type:'battlePreparationReady', event:battleEvent}); + const emperor = player(game, 'e').leaders.filter(leader => !leader.dead && !leader.usedAt) + .sort((a, b) => b.strength - a.strength)[0]!; + const atreides = player(game, 'a').leaders.filter(leader => !leader.dead && !leader.usedAt) + .sort((a, b) => a.strength - b.strength)[0]!; + game = act(game, 'e', {type:'battlePlan', dial:3, support:3, leader:emperor.id, weapon:null, defense:null}); + game = act(game, 'a', {type:'battlePlan', dial:0, support:0, leader:atreides.id, weapon:null, defense:null}); + for (let count = 0; count < 400 && !(game.phase >= 7 && !game.response && !game.decision && !game.phaseOpening); count++) { + const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next); + game = act(game, next.actor, next.action); + } + assert.ok(game.phase >= 7 && !game.response && !game.decision && !game.phaseOpening); + return game; +} + +for (const ready of ['owner', 'opponent'] as const) for (const claim of [false, true]) + void test(`Advanced public Residual Poison ${ready}-ready Semuta ${claim ? 'claim' : 'pass'} resumes battle`, () => { + const f = fixture(), readyPlayer = ready === 'owner' ? 'e' : 'a'; + let game = act(f.game, readyPlayer, {type:'battlePreparationReady', event:f.event}); + const before = structuredClone(game); + const dead = new Set(player(game, 'a').leaders.filter(leader => leader.dead).map(leader => leader.id)); + game = act(game, 'e', f.action); + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + assert.equal(pending.continuation.kind, 'residualPoisonDiscard'); + assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id, entry.discardedBy, entry.publicFace]), + [[f.poison, 'e', true]]); + assert.deepEqual(game.battle!.preLeader!.ready, [readyPlayer]); + const victims = player(game, 'a').leaders.filter(leader => leader.dead && !dead.has(leader.id)); + assert.equal(victims.length, 1); + for (const difficulty of DIFFICULTIES) { + const botGame = structuredClone(game); + player(botGame, 'g').bot = difficulty; + const action = botActions(viewGame(botGame, 'g')).find(candidate => + candidate.type === 'semutaCommit' || candidate.type === 'semutaPass'); + assert.ok(action, difficulty); + assert.doesNotThrow(() => act(botGame, 'g', action), difficulty); + } + const event = pending.batch.event; + if (claim) { + game = act(game, 'g', {type:'semutaCommit', event}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = act(game, 'g', {type:'semutaSelect', event, card:f.poison}); + } else for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event}); + assert.equal(game.pendingTreacheryDiscard, null); + assert.deepEqual(game.battle!.preLeader!.ready, ready === 'owner' ? [] : ['a']); + assert.deepEqual(game.battle!.plans, before.battle!.plans); + assert.equal(player(game, 'a').leaders.find(leader => leader.id === victims[0].id)?.dead, true); + assert.equal(player(game, 'g').hand.some(card => card.id === f.poison), claim); + assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claim); + assert.equal(game.discard.some(card => card.id === f.poison), !claim); + assert.deepEqual(inventory(game), f.original); + assert.throws(() => act(game, 'g', {type:'semutaCommit', event})); + game = finishBattle(game); + assert.equal(player(game, 'a').leaders.find(leader => leader.id === victims[0].id)?.dead, true); + assert.deepEqual(inventory(game), f.original); + });