From 59579486a44d88e1099834dd849492ace29d7b11 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:17:07 +1100 Subject: [PATCH] Connect Semuta with native allied retention --- docs/CURRENT_STATUS.md | 14 +- docs/SEMUTA_DISCARD_CONTINUATIONS.md | 15 ++ game/engine.ts | 12 +- game/reference.ts | 2 +- tests/fixture-semuta-ix-substitution.ts | 2 + ...emuta-independent-native-retention.test.ts | 106 ++++++++++++ ...ta-independent-native-substitution.test.ts | 152 ++++++++++++++++++ 7 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 tests/semuta-independent-native-retention.test.ts create mode 100644 tests/semuta-independent-native-substitution.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 743c95c4..95c27c16 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Semuta with Moritani retention and Ixian substitution — 29 October. +Prototyped / Partial.** Supported Basic independent-card battles now retain +Moritani's allied card choice through the initial loser discard and separately +offer unkept cards afterward. The existing Ixian path offers the loser discard +before substitution and the winner's mandatory discard afterward. Six related +files pass 56 cases; their native setup fixtures stage conserved battle forces +and cards. An older Ixian fixture was repaired to complete the real battle +outcome window; the same failures reproduced at the previous commit. Typecheck, +lint and the production build pass. Advanced and other optional modules remain separate. +[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#native-moritani-retention-and-ixian-substitution--29-october-2026). + **Semuta battle and special cards with native factions — 29 October. Prototyped / Partial.** Fresh Basic Richese games with independent cards now offer supported battle-discard batches and completed Box, Distrans, simple @@ -684,7 +695,8 @@ isolated HTTP verification, registry login and image publication. Run 52 was canceled before starting; run 53 for `3ba9087` succeeded, including HTTP verification and image publication. Run 54 was canceled before starting and run 55 for `4feb418` succeeded and published its verified image. Run 56 for -`0f8e0b4` was running at the latest check. The already-approved runner network adjustment and registry secret +`0f8e0b4` succeeded and published its verified image. Run 57 was canceled +before starting; run 58 for `4ee4d9f` was running at the latest check. The already-approved runner network adjustment and registry secret remain in use; neither needed to be recreated. Deployment remains unverified. Existing NAS deployment and its persistent storage remain untouched. diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 2b285c67..543de191 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -1,5 +1,20 @@ # Semuta discard producers and continuation inventory +## Native Moritani retention and Ixian substitution — 29 October 2026 + +**Prototyped / Partial.** Fresh Basic Richese games with independent Ecaz +Treachery Cards can offer an initial mandatory loser discard while a native +Moritani allied-retention choice is pending. A later discard of the unkept +played card has its own offer; neither offer includes the retained card or +replays the battle. The existing Ixian continuation also offers the loser's +discard before native Suboid/Cyborg substitution and the winner's mandatory +discard after substitution. The fixtures start with native public setup and +stage conserved battle forces and card custody, then use the actual decisions. +Advanced, other optional modules and further reaction combinations remain +open. The ordering is an online composition, not a publisher priority ruling. +[Moritani cases](../tests/semuta-independent-native-retention.test.ts); +[Ixian cases](../tests/semuta-independent-native-substitution.test.ts). + ## Battle and special-card discards with native factions — 29 October 2026 **Prototyped / Partial.** Fresh Basic Richese games with selected native diff --git a/game/engine.ts b/game/engine.ts index 164c806e..a0ef48b7 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9668,7 +9668,8 @@ function semutaOfferSupported( ((g.pendingWinnerDiscards && continuation.kind !== 'battleResolved') || (g.moritaniRetention && (g.advanced || g.moritaniRetention.source !== undefined || g.moritaniRetention.stage !== 'choose' || - (continuation.kind === 'battleResolved' && !bareBasicSemutaProfile(g)))) || + (continuation.kind === 'battleResolved' && !bareBasicSemutaProfile(g) && + !basicIndependentRecruitsSemutaProfile(g)))) || g.pendingIxSubstitution)) return false; if (continuation.kind === 'terrorDiscard' && (!childTerrorDiscard && (continuation.entry.resume !== 'none' || @@ -9710,7 +9711,8 @@ function semutaOfferSupported( (continuation.kind === 'ordinaryCardDiscard' && ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) || continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' || - continuation.kind === 'battleCleanup' && continuation.source === 'winner' || + continuation.kind === 'battleCleanup' && (continuation.source === 'winner' || + continuation.source === 'moritani' && !g.advanced && continuation.retention?.source === undefined) || continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' || continuation.kind === 'truthtranceDiscard' && continuation.promise === null && (continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform') || @@ -9720,7 +9722,8 @@ function semutaOfferSupported( (continuation.kind === 'ordinaryCardDiscard' && ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) || continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' || - continuation.kind === 'battleCleanup' && continuation.source === 'winner' || + continuation.kind === 'battleCleanup' && (continuation.source === 'winner' || + continuation.source === 'moritani' && !g.advanced && continuation.retention?.source === undefined) || continuation.kind === 'residualPoisonDiscard' || continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' || continuation.kind === 'truthtranceDiscard' && continuation.promise === null && @@ -33507,7 +33510,8 @@ function currentBattleResolutionInput(g: Game, canceledVoter?: string): BattleRe physicalCards: physicalTreacheryCards(g), pendingAuditorPresent: !!g.pendingAuditor, pendingRetentionPresent: !!g.moritaniRetention, - nativeMoritaniEarlyMandatoryDiscard: !!g.semutaPreview && bareBasicSemutaProfile(g), + nativeMoritaniEarlyMandatoryDiscard: !!g.semutaPreview && bareBasicSemutaProfile(g) || + !!g.semutaRecruitsPreview && basicIndependentRecruitsSemutaProfile(g), nexusMoritani: !!g.nexusCards?.cards && !byFaction(g, 'moritani'), ...(b.stoneAllocation ? { stoneAllocations: b.stoneAllocation.selections } : {}), }; diff --git a/game/reference.ts b/game/reference.ts index 904bfb2f..138919c7 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -6620,7 +6620,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. Supported battle cleanup, 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. Supported battle cleanup, native Moritani allied retention, Ixian substitution, 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-substitution.ts b/tests/fixture-semuta-ix-substitution.ts index cb20731d..b1134de3 100644 --- a/tests/fixture-semuta-ix-substitution.ts +++ b/tests/fixture-semuta-ix-substitution.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam'; export type SemutaIxStep = { actor: string; action: Action }; export interface SemutaIxFixture { @@ -122,6 +123,7 @@ export function semutaIxLoserDiscardFixture(advanced = false): SemutaIxFixture { assert.equal(game.decision?.kind, 'poisonTooth'); game = applyAction(game, f.winner, { type: 'decision', activate: true }); for (const actor of [f.winner, f.loser]) game = applyAction(game, actor, { type: 'traitorCall', call: false }); + game = finishSemutaBattleOutcome(game); return { ...f, game }; } diff --git a/tests/semuta-independent-native-retention.test.ts b/tests/semuta-independent-native-retention.test.ts new file mode 100644 index 00000000..4623df8a --- /dev/null +++ b/tests/semuta-independent-native-retention.test.ts @@ -0,0 +1,106 @@ +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 type { Card } from '../game/cards'; +import { resolveSemutaMoritaniBattle, finishSemutaMoritaniWinner, + chooseSemutaMoritaniRetention, allowSemutaMoritaniResponse, + passSemutaMoritaniDiscard, semutaMoritaniInventory as inventory, + semutaMoritaniResources as resources, semutaMoritaniPlayer as seat, + type SemutaMoritaniFixture } from './fixture-semuta-moritani-retention'; + +const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); + +/** Actual public selected-deck setup with independent Ecaz cards. The later + * reciprocal alliance, held physical cards and conserved battle position are + * deliberately staged; battle plans, retention and Semuta use real actions. */ +function fixture(): SemutaMoritaniFixture & { hero: string } { + let game = createGame('SEMUTANATIVERETENTION', newPlayer('r','Richese','richese'), false, ['choam','ecaz']); + joinGame(game,newPlayer('m','Moritani','moritani')); + joinGame(game,newPlayer('w','Emperor','emperor')); + joinGame(game,newPlayer('l','Guild','guild')); + game = act(game,'r',{type:'moduleOption',module:'ecazTreachery',enabled:true}); + for (const player of game.players) game = act(game,player.id,{type:'ready'}); + game = act(game,'r',{type:'start'}); + assert.equal(game.status,'setup'); + assert.equal(game.semutaRecruitsPreview,true); + for (let count=0;game.status === 'setup' && count<160;count++) { + let next: {actor:string;action:Action}|undefined; + for (const player of game.players) { + const view = viewGame(game,player.id); + view.players.find(candidate => candidate.id === player.id)!.bot = 'Medium'; + const action = botActions(view)[0]; + if (action) {next = {actor:player.id,action};break;} + } + assert.ok(next, 'Original setup must offer a legal decision.'); + game = act(game,next.actor,next.action); + } + assert.equal(game.status,'playing'); + assert.equal(game.ecazTreachery,true); + assert.equal(game.semutaPreview,undefined); + game.deck.push(...game.players.flatMap(player => player.hand)); + for (const player of game.players) player.hand = []; + const hold = (owner:string,predicate:(card:Card)=>boolean) => { + for (const zone of [game.deck,game.discard,game.richeseCache!]) { + const index = zone.findIndex(predicate); + if (index >= 0) {const [card] = zone.splice(index,1);seat(game,owner).hand.push(card);return card.id;} + } + throw Error('The original physical card is unavailable.'); + }; + const semuta = hold('r',card=>card.id === SEMUTA_DRUG_ID); + const weapon = hold('l',card=>card.kind === 'projectile'); + const defense = hold('l',card=>card.kind === 'snooper'); + const unrelated = hold('l',card=>card.kind === 'worthless'); + const hero = hold('l',card=>card.kind === 'hero'); + const winnerWeapon = hold('w',card=>card.kind === 'projectile'); + const winnerDefense = hold('w',card=>card.kind === 'shield'); + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((sum,count)=>sum+count,0); + player.forces = {}; + assert.equal(player.tanks,0);assert.equal(player.reserves,20); + } + for (const id of ['w','l']) {seat(game,id).reserves-=5;seat(game,id).forces['arrakeen:10']=5;} + seat(game,'m').ally='l';seat(game,'l').ally='m'; + Object.assign(game,{turn:2,phase:6,storm:18,active:'w',order:['w','l','m','r'], + ready:[],response:null,decision:null,phaseOpening:null,stormPending:null,movementRemaining:[]}); + const winnerLeader = seat(game,'w').leaders.reduce((best,leader)=>leader.strength > best.strength ? leader : best).id; + return {game,semuta,weapon,defense,unrelated,hero,winnerWeapon,winnerDefense,winnerHero:null,karama:null, + cards:inventory(game),winnerLeader,loserLeader:hero,loserStrength:0}; +} + +for (const initialClaim of [false,true]) for (const finalClaim of [false,true]) { + if (initialClaim && finalClaim) continue; // Claiming spends the only Semuta. + void test(`Basic independent cards: Moritani retention ${initialClaim ? 'claims' : 'passes'} initial Hero and ${finalClaim ? 'claims' : 'passes'} later discard`,()=>{ + const f = fixture(); + let game = resolveSemutaMoritaniBattle(f); + const initial = game.pendingTreacheryDiscard;assert.ok(initial?.reaction); + assert.equal(initial.batch.cause,'battle:mandatory'); + assert.deepEqual(initial.batch.entries.map(entry=>entry.card.id),[f.hero]); + assert.deepEqual(game.moritaniRetention?.eligible,[f.weapon,f.defense]); + const committed = structuredClone(resources(game)); + if (!initialClaim) for (const difficulty of DIFFICULTIES) { + const view = viewGame(game,'r');view.players.find(player=>player.id === 'r')!.bot=difficulty; + assert.ok(botActions(view).some(action=>action.type === 'semutaCommit' || action.type === 'semutaPass')); + } + game = initialClaim ? act(game,'r',{type:'semutaCommit',event:initial.batch.event}) : passSemutaMoritaniDiscard(game); + assert.deepEqual(resources(game),committed); + assert.equal(seat(game,'r').hand.some(card=>card.id === f.hero),initialClaim); + game = finishSemutaMoritaniWinner(game); + assert.equal(game.decision?.kind,'moritaniRetention'); + game = chooseSemutaMoritaniRetention(game,f.weapon); + game = allowSemutaMoritaniResponse(game); + const later = game.pendingTreacheryDiscard;assert.ok(later?.reaction); + assert.equal(later.batch.cause,'battle:moritani'); + assert.deepEqual(later.batch.entries.map(entry=>entry.card.id),[f.defense]); + game = finalClaim ? act(game,'r',{type:'semutaCommit',event:later.batch.event}) : passSemutaMoritaniDiscard(game); + assert.equal(game.pendingTreacheryDiscard,null); + assert.equal(game.moritaniRetention,null); + assert.equal(seat(game,'l').hand.some(card=>card.id === f.weapon),true); + assert.equal(seat(game,'r').hand.some(card=>card.id === f.defense),finalClaim); + assert.equal(game.discard.some(card=>card.id === f.defense),!finalClaim); + assert.deepEqual(resources(game),committed); + assert.deepEqual(inventory(game),f.cards); + }); +} diff --git a/tests/semuta-independent-native-substitution.test.ts b/tests/semuta-independent-native-substitution.test.ts new file mode 100644 index 00000000..b2291348 --- /dev/null +++ b/tests/semuta-independent-native-substitution.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, 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 { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam'; +import { + semutaIxAllowResponses, semutaIxInventory, semutaIxPassDiscard, semutaIxPlanSteps, + semutaIxPlayer, semutaIxSubstitutionStep, type SemutaIxFixture, +} from './fixture-semuta-ix-substitution'; + +/** A fresh public Basic setup with the three selected families and the + * independent Ecaz cards. A later battle position is explicitly staged by + * moving the original held cards and all original forces between legal pools; + * battle choice, plans, traitor calls and subsequent effects are actual actions. */ +function independentIxBattle(): SemutaIxFixture { + let game = createGame('SEMUTAINDEPENDENTIX', newPlayer('r', 'Richese', 'richese'), false, + ['choam', 'ix', 'ecaz']); + joinGame(game, newPlayer('i', 'Ixians', 'ixians')); + joinGame(game, newPlayer('e', 'Emperor', 'emperor')); + game = applyAction(game, 'r', {type:'moduleOption', module:'ecazTreachery', enabled:true}); + for (const player of game.players) player.bot = 'Medium'; + for (const id of game.players.map(player => player.id)) + game = applyAction(game, id, {type:'ready'}); + game = applyAction(game, 'r', {type:'start'}); + assert.equal(game.status, 'setup'); + assert.equal(game.semutaRecruitsPreview, true); + for (let step = 0; (game.status === 'setup' || game.phase === 1 || !game.mobileStronghold?.location) && step < 160; step++) { + let next: Game | undefined; + for (const player of game.players) { + const action = botActions(viewGame(game, player.id))[0]; + if (action) { next = applyAction(game, player.id, action); break; } + } + assert.ok(next, 'Native setup and first Storm need a legal action.'); + game = next; + } + assert.equal(game.status, 'playing'); + assert.ok(game.mobileStronghold?.location); + const cards = semutaIxInventory(game); + assert.equal(new Set(cards).size, cards.length); + for (const player of game.players) { + game.deck.push(...player.hand); player.hand = []; + player.reserves += player.tanks + Object.values(player.forces).reduce((sum,n) => sum + n,0); + player.forces = {}; player.tanks = 0; player.battleLosses = 0; + if (player.elites) { + player.elites.reserves += player.elites.tanks + Object.values(player.elites.forces).reduce((sum,n) => sum + n,0); + player.elites.forces = {}; player.elites.tanks = 0; + } + } + const take = (id: string) => { + for (const zone of [game.deck, game.discard, game.richeseCache!]) { + const index = zone.findIndex(card => card.id === id); + if (index >= 0) return zone.splice(index,1)[0]; + } + throw new Error(`Missing original physical card ${id}.`); + }; + const semuta = take(SEMUTA_DRUG_ID); + const tooth = take(game.deck.find(card => card.kind === 'poisonTooth')!.id); + const shield = take(game.deck.find(card => card.kind === 'shield')!.id); + const snooper = take(game.deck.find(card => card.kind === 'snooper')!.id); + semutaIxPlayer(game,'r').hand.push(semuta, take(game.deck.find(card => card.effect === 'karama')!.id)); + semutaIxPlayer(game,'i').hand.push(tooth,shield); + semutaIxPlayer(game,'e').hand.push(snooper); + const location = 'arrakeen:10', ix = semutaIxPlayer(game,'i'), emperor = semutaIxPlayer(game,'e'); + assert.equal(ix.reserves,20); assert.equal(ix.elites!.reserves,7); + ix.reserves -= 3; ix.forces[location] = 3; ix.elites!.reserves--; ix.elites!.forces[location] = 1; + emperor.reserves -= 5; emperor.forces[location] = 5; + Object.assign(game,{turn:2,phase:6,active:'i',order:['i','e','r'],storm:18, + ready:[],response:null,decision:null,phaseOpening:null,movementRemaining:[]}); + assert.deepEqual(semutaIxInventory(game),cards); + return {game, holder:'r', winner:'i', loser:'e', territory:'arrakeen', location, + semuta:semuta.id, tooth:tooth.id, shield:shield.id, snooper:snooper.id, cards}; +} + +function loserDiscard(): SemutaIxFixture { + const fixture = independentIxBattle(); + let game = applyAction(fixture.game,fixture.winner,{type:'chooseBattle',territory:fixture.territory,target:fixture.loser}); + for (let step = 0; step < 40; step++) { + if (game.response) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = applyAction(game,actor.id,{type:'passResponse'}); + } else if (game.battle?.preparation) + game = applyAction(game,game.battle.preparation.owner,{type:'declineBattlePower'}); + else if (game.decision?.kind === 'fullPlanOffer') + game = applyAction(game,game.decision.player,{type:'decision',decline:true}); + else break; + } + for (const actor of [fixture.winner,fixture.loser]) + game = applyAction(game,actor,{type:'battlePreparationReady',event:game.battle!.preLeader!.event}); + for (const step of semutaIxPlanSteps(game,fixture)) game = applyAction(game,step.actor,step.action); + assert.equal(game.decision?.kind,'poisonTooth'); + game = applyAction(game,fixture.winner,{type:'decision',activate:true}); + for (const actor of [fixture.winner,fixture.loser]) + game = applyAction(game,actor,{type:'traitorCall',call:false}); + game = finishSemutaBattleOutcome(game); + return {...fixture,game}; +} + +for (const claim of [false,true]) for (const substitute of [false,true]) { + void test(`Basic independent cards: Ixian loser discard ${claim ? 'claim' : 'pass'}, then ${substitute ? 'substitute' : 'decline'}`, () => { + const fixture = loserDiscard(), pending = fixture.game; + const batch = pending.pendingTreacheryDiscard; assert.ok(batch?.reaction); + assert.equal(batch.reaction.stage,'offer'); + assert.equal(batch.continuation.kind,'battleResolved'); + assert.deepEqual(batch.batch.entries.map(entry => entry.card.id),[fixture.snooper]); + assert.deepEqual(pending.pendingWinnerDiscards?.cards,[fixture.tooth]); + assert.equal(pending.pendingIxSubstitution ?? null,null); + const event = batch.batch.event; + let game = claim ? applyAction(pending,fixture.holder,{type:'semutaCommit',event}) : semutaIxPassDiscard(pending); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = applyAction(game,fixture.holder,{type:'semutaSelect',event,card:fixture.snooper}); + assert.equal(game.decision?.kind,'ixSubstitution'); + assert.equal(game.decision?.player,fixture.winner); + assert.deepEqual(game.pendingIxSubstitution!.losses,{[fixture.location]:1}); + assert.equal(semutaIxPlayer(game,fixture.winner).tanks,1); + assert.equal(semutaIxPlayer(game,fixture.winner).elites!.tanks,1); + assert.equal(semutaIxPlayer(game,fixture.holder).hand.some(card => card.id === fixture.snooper),claim); + assert.equal(game.discard.some(card => card.id === fixture.tooth),false); + const step = semutaIxSubstitutionStep(fixture,substitute); + game = applyAction(game,step.actor,step.action); + if (substitute) { + assert.equal(game.response?.kind,'ixSubstitution'); + game = semutaIxAllowResponses(game); + } + assert.equal(game.pendingIxSubstitution,null); + assert.equal(semutaIxPlayer(game,fixture.winner).elites!.tanks,substitute ? 0 : 1); + assert.equal(semutaIxPlayer(game,fixture.winner).battleLosses,1); + assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'winnerMandatoryDiscard'); + assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id),[fixture.tooth]); + game = semutaIxPassDiscard(game); + assert.equal(game.decision?.kind,'battleCards'); + game = applyAction(game,fixture.winner,{type:'decision',discard:[fixture.shield]}); + if (game.pendingTreacheryDiscard) game = semutaIxPassDiscard(game); + assert.equal(game.phase,7); + assert.deepEqual(semutaIxInventory(game),fixture.cards); + assert.equal(game.discard.filter(card => card.id === fixture.semuta).length,Number(claim)); + for (const player of game.players) + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum,n) => sum + n,0),20); + }); +} + +void test('all four AI profiles can answer the fresh discard before Ixian substitution', () => { + const fixture = loserDiscard(); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(fixture.game,fixture.holder); + view.players.find(player => player.id === fixture.holder)!.bot = difficulty; + const action = botActions(view).find(item => item.type === 'semutaCommit' || item.type === 'semutaPass'); + assert.ok(action); + assert.doesNotThrow(() => applyAction(fixture.game,fixture.holder,action)); + } +});