From 87d1887139ca97c3c29747b56a98477a457f5eeb Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:21:32 +1100 Subject: [PATCH] Connect Semuta to native faction discards --- docs/CURRENT_STATUS.md | 10 ++ docs/SEMUTA_DISCARD_CONTINUATIONS.md | 22 ++- game/engine.ts | 25 ++- game/reference.ts | 2 +- ...muta-independent-native-ambassador.test.ts | 118 +++++++++++++ tests/semuta-independent-native-choam.test.ts | 116 +++++++++++++ .../semuta-independent-native-terror.test.ts | 161 ++++++++++++++++++ 7 files changed, 441 insertions(+), 13 deletions(-) create mode 100644 tests/semuta-independent-native-ambassador.test.ts create mode 100644 tests/semuta-independent-native-choam.test.ts create mode 100644 tests/semuta-independent-native-terror.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 95c27c16..49047733 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Semuta with native CHOAM, Ambassadors and Terror — 29 October. Prototyped / +Partial.** Supported fresh Basic independent-card games offer Semuta after +CHOAM sales and five printed Worthless costs, clean CHOAM/Ixian Ambassador +discards, Sabotage and selected overflow discards. Private cards and original +income, draws and effects remain bound. Nine related files pass 119 cases; +new journeys use native setup and explicitly staged physical cards, forces and +token placements. Typecheck, lint and the production build pass. Overlapping arrivals, Kull +and other optional modules remain separate. +[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#native-choam-ambassador-and-terror-discards--29-october-2026). + **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 diff --git a/docs/SEMUTA_DISCARD_CONTINUATIONS.md b/docs/SEMUTA_DISCARD_CONTINUATIONS.md index 543de191..0fd3a281 100644 --- a/docs/SEMUTA_DISCARD_CONTINUATIONS.md +++ b/docs/SEMUTA_DISCARD_CONTINUATIONS.md @@ -1,5 +1,21 @@ # Semuta discard producers and continuation inventory +## Native CHOAM, Ambassador and Terror discards — 29 October 2026 + +**Prototyped / Partial.** In supported fresh Basic Richese games with the +independent Ecaz cards and selected native faction decks, Semuta may now react +to a completed CHOAM card sale or the supported Kulon, La La La, Baliset, Trip +to Gamont and Jubba Cloak costs. Clean native CHOAM/Ixian Ambassador discards, +Sabotage, Robbery overflow and Sabotage-gift overflow also offer their original +private discarded cards. A claim or pass resumes the original income, draw or +Terror effect once. Private discard faces remain hidden until the claimant is +entitled to choose. Fixtures use native setup and actions with explicitly staged +physical cards, forces and token placements. Overlapping arrivals, Kull and +other optional-module combinations remain outside this extension; its digital +ordering is provisional. [CHOAM cases](../tests/semuta-independent-native-choam.test.ts), +[Ambassador cases](../tests/semuta-independent-native-ambassador.test.ts), +[Terror cases](../tests/semuta-independent-native-terror.test.ts). + ## Native Moritani retention and Ixian substitution — 29 October 2026 **Prototyped / Partial.** Fresh Basic Richese games with independent Ecaz @@ -24,9 +40,9 @@ Distrans, definite fact/freeform Truthtrance and ordinary Ornithopter discards. Claim or pass preserves the original effect and continuation. Genuine Ecaz and Tleilaxu battles exercise the three batches; Tleilaxu's pending Face Dance remains available. The special-card journeys stage conserved transfers of the -tested cards. Both fixtures select unopened card and Spice order. Moritani -retention, Ixian substitution, other pending parents and optional modules -retain their separate guards and remain unfinished. [Battle cases](../tests/semuta-independent-native-battle.test.ts) +tested cards. Both fixtures select unopened card and Spice order. The later +native Moritani retention and Ixian substitution extension is described above; +other pending parents and optional modules remain unfinished. [Battle cases](../tests/semuta-independent-native-battle.test.ts) and [special-card cases](../tests/semuta-independent-native-specials.test.ts) cover the connected paths. diff --git a/game/engine.ts b/game/engine.ts index a0ef48b7..08f080e2 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9533,7 +9533,7 @@ function semutaOfferSupported( g.pendingArrivalOverlap.ambassadorEvent === continuation.entry.ambassadorEvent && ambassadorTerrorEntryMatches(g, continuation.entry); if (ambassadorDiscard && - (!bareBasicSemutaProfile(g) || g.phase !== 5 || + (!(bareBasicSemutaProfile(g) || basicIndependentRecruitsSemutaProfile(g) && !g.pendingArrivalOverlap) || g.phase !== 5 || continuation.entry.stage !== 'cards' || continuation.entry.resume !== 'none' || !['shipment', 'movement'].includes(continuation.entry.arrivalCause ?? '') || !['choam', 'ixians'].includes(continuation.entry.effect ?? '') || @@ -9706,9 +9706,13 @@ function semutaOfferSupported( // A discard saved before this preview expansion may already be pending // 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 === 'choamSaleDiscard' || continuation.kind === 'choamKulonDiscard' || + continuation.kind === 'choamLaLaLaDiscard' || continuation.kind === 'choamBalisetDiscard' || + continuation.kind === 'choamGamontDiscard' || continuation.kind === 'choamJubbaDiscard'; return (g.semutaPreview === true || g.semutaRecruitsPreview === true && (!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) && - (continuation.kind === 'ordinaryCardDiscard' && + (independentNativeDiscard || continuation.kind === 'ordinaryCardDiscard' && ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) || continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleCleanup' && (continuation.source === 'winner' || @@ -9719,7 +9723,7 @@ function semutaOfferSupported( continuation.kind === 'ornithopterDiscard' && (continuation.source === 'end' || continuation.source === 'move') ? basicIndependentRecruitsSemutaProfile(g) : basicIndependentSemutaProfile(g)) && - (continuation.kind === 'ordinaryCardDiscard' && + (independentNativeDiscard || continuation.kind === 'ordinaryCardDiscard' && ['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) || continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' || continuation.kind === 'battleCleanup' && (continuation.source === 'winner' || @@ -13209,6 +13213,9 @@ function basicIndependentRecruitsSemutaProfile(g: Game): boolean { !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.kullPreview && !g.nexusKullPreview && !g.moritaniAssassinatePreview; } +function semutaCardProducerEnabled(g: Game): boolean { + return g.semutaPreview === true || g.semutaRecruitsPreview === true && basicIndependentRecruitsSemutaProfile(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 { @@ -37130,7 +37137,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 (g.semutaPreview && (used.name === 'Kulon' || pending.nexusEvent) && + if (semutaCardProducerEnabled(g) && (used.name === 'Kulon' || pending.nexusEvent) && !pending.storm && !pending.revival && !pending.movement && !pending.mentat) { const continuation: ChoamKulonDiscardContinuation = { @@ -37148,7 +37155,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl } completeChoamKulon(g, choam); } else if (pending.effect === 'jubba') { - if (g.semutaPreview && (used.name === 'Jubba Cloak' || pending.nexusEvent) && + if (semutaCardProducerEnabled(g) && (used.name === 'Jubba Cloak' || pending.nexusEvent) && pending.storm && !pending.revival && !pending.movement && !pending.mentat) { const continuation: ChoamJubbaDiscardContinuation = { @@ -37165,7 +37172,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl } completeChoamJubba(g, choam, pending.location!); } else if (pending.effect === 'baliset') { - if (g.semutaPreview && (used.name === 'Baliset' || pending.nexusEvent) && + if (semutaCardProducerEnabled(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 && @@ -37200,7 +37207,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl if (ambassador) offerAmbassadorRelocation(g); } } else if (pending.effect === 'gamont') { - if (g.semutaPreview && (used.name === 'Trip to Gamont' || pending.nexusEvent) && + if (semutaCardProducerEnabled(g) && (used.name === 'Trip to Gamont' || pending.nexusEvent) && !pending.storm && !pending.revival && !pending.movement) { const continuation: ChoamGamontDiscardContinuation = { kind: 'choamGamontDiscard', player: choam.id, @@ -37222,7 +37229,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl pending.elite!, pending.noFieldEvent, { card: used.id, mentat: pending.mentat, nexusEvent: pending.nexusEvent }); } else { - if (g.semutaPreview && (used.name === 'La La La' || pending.nexusEvent) && + if (semutaCardProducerEnabled(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))) { @@ -37504,7 +37511,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 (g.semutaPreview) { + if (semutaCardProducerEnabled(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 138919c7..0d0433d8 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, 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.', + '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, 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/semuta-independent-native-ambassador.test.ts b/tests/semuta-independent-native-ambassador.test.ts new file mode 100644 index 00000000..988db7ac --- /dev/null +++ b/tests/semuta-independent-native-ambassador.test.ts @@ -0,0 +1,118 @@ +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 { placeAmbassador } from '../game/ecaz-ambassadors'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import { basicSemutaAmbassadorInventory as inventory, nextBasicSemutaAmbassadorStep } from './fixture-basic-semuta-ambassador'; + +const seat = (game: Game, id: string) => { + const found = game.players.find(player => player.id === id); assert.ok(found); return found; +}; +const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); + +/** Original public selected-family setup and physical component inventory. + * The later board, wallets, selected Ambassador cohort/token and physical hand + * transfers are staged; shipment, trigger, discard and reactions are actions. */ +function fixture(effect: 'choam' | 'ixians') { + let game = createGame('SEMUTAINDAMBASSADOR', newPlayer('ecaz','Ecaz','ecaz'), false, + ['choam','ix','ecaz']); + for (const faction of ['richese','guild','emperor'] as const) + joinGame(game,newPlayer(faction,faction,faction)); + game = act(game,'ecaz',{type:'moduleOption',module:'ecazTreachery',enabled:true}); + for (const player of game.players) game = act(game,player.id,{type:'ready'}); + game = act(game,'ecaz',{type:'start'}); + assert.equal(game.status,'setup'); assert.equal(game.semutaRecruitsPreview,true); + for (let count = 0; game.status === 'setup' && count < 150; count++) { + const step = nextBasicSemutaAmbassadorStep(game); assert.ok(step); + game = act(game,step.actor,step.action); + } + assert.equal(game.status,'playing'); + const original = inventory(game); + 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; + 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; + } + Object.assign(player,{spice:20,shipped:false,moved: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('The original physical card is unavailable.'); + }; + seat(game,'guild').hand.push(take(card => card.id === SEMUTA_DRUG_ID)); + seat(game,'ecaz').hand.push(take(card => card.kind === 'projectile'),take(card => card.kind === 'shield')); + const state = structuredClone(game.ecazAmbassadors!); + const token = state.tokens.find(item => item.effect === effect); assert.ok(token); + if (!state.cohort.includes(token.id)) { + const replaced = state.tokens.find(item => item.id === state.cohort[0]); assert.ok(replaced); + replaced.zone = 'pool'; token.zone = 'supply'; state.cohort[0] = token.id; + } + game.ecazAmbassadors = placeAmbassador(state,token.id,{turn:1,availableSpice:20, + destination:{id:'arrakeen',stronghold:true,inStorm:false,allowed:true}}).state; + Object.assign(game,{turn:2,phase:5,storm:18,active:'emperor',order:['emperor','ecaz','richese','guild'], + movementRemaining:['emperor','ecaz','richese','guild'],ready:[],response:null,decision:null, + phaseOpening:null,stormPending:null,nexus:false}); + assert.deepEqual(inventory(game),original); + game = act(game,'emperor',{type:'ship',territory:'arrakeen',sector:10,amount:1,elite:0,allyPayment:0}); + for (let count=0;game.response && count<20;count++) { + const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); + game = act(game,actor.id,{type:'passResponse'}); + } + assert.equal(game.pendingAmbassador?.stage,'offer'); + const event = game.pendingAmbassador!.event; + game = act(game,'ecaz',{type:'decision',event,trigger:true,beneficiary:'ecaz',choice:'effect'}); + assert.equal(game.pendingAmbassador?.stage,'cards'); + const chosen = seat(game,'ecaz').hand.slice(0,effect === 'choam' ? 2 : 1).map(card => card.id); + assert.equal(chosen.length,effect === 'choam' ? 2 : 1); + const deckTop = game.deck[0]?.id; assert.ok(deckTop); + return {game,event,chosen,deckTop,original}; +} + +for (const effect of ['choam','ixians'] as const) for (const claim of [false,true]) { + void test(`Basic independent cards: ${effect} Ambassador discard offers Semuta ${claim ? 'claim' : 'pass'}`, () => { + const f = fixture(effect), before = structuredClone(f.game), spice = seat(f.game,'ecaz').spice; + let game = act(f.game,'ecaz',{type:'decision',event:f.event,cards:f.chosen}); + assert.deepEqual(f.game,before); + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + assert.equal(pending.continuation.kind,'ambassador'); + assert.deepEqual(pending.batch.entries.map(entry => entry.card.id),f.chosen); + assert.ok(pending.batch.entries.every(entry => !entry.publicFace && entry.discardedBy === 'ecaz')); + assert.equal(seat(game,'ecaz').spice,spice + (effect === 'choam' ? 3*f.chosen.length : 0)); + assert.deepEqual(game.deck,before.deck); + for (const player of game.players) assert.deepEqual(viewGame(game,player.id).semutaReaction?.candidates,[]); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game,'guild'); + const owner = view.players.find(player => player.id === 'guild'); assert.ok(owner); owner.bot = difficulty; + assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass')); + } + const event = pending.batch.event; + if (claim) { + game = act(game,'guild',{type:'semutaCommit',event}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') { + assert.deepEqual(viewGame(game,'guild').semutaReaction!.candidates.map(card => card.id),f.chosen); + assert.deepEqual(viewGame(game,'ecaz').semutaReaction!.candidates,[]); + game = act(game,'guild',{type:'semutaSelect',event,card:f.chosen[0]}); + } + } else for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event}); + assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.pendingAmbassador,null); + assert.equal(seat(game,'ecaz').spice,spice + (effect === 'choam' ? 3*f.chosen.length : 0)); + assert.equal(seat(game,'guild').hand.some(card => card.id === f.chosen[0]),claim); + if (effect === 'ixians') { + assert.ok(seat(game,'ecaz').hand.some(card => card.id === f.deckTop)); + assert.deepEqual(game.deck.map(card => card.id),before.deck.slice(1).map(card => card.id)); + } else assert.deepEqual(game.deck,before.deck); + assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID),claim); + assert.deepEqual(inventory(game),f.original); + assert.throws(() => act(game,'guild',{type:'semutaCommit',event})); + game = act(game,'emperor',{type:'endMovement'}); + assert.ok(!game.movementRemaining?.includes('emperor')); + }); +} diff --git a/tests/semuta-independent-native-choam.test.ts b/tests/semuta-independent-native-choam.test.ts new file mode 100644 index 00000000..6aa5bdfe --- /dev/null +++ b/tests/semuta-independent-native-choam.test.ts @@ -0,0 +1,116 @@ +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 selected-family public 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('SEMUTAINDEPENDENTCHOAM', newPlayer('c','CHOAM','choam'), false, + ['choam','ix','ecaz']); + for (const faction of ['richese','atreides','ecaz'] as const) + joinGame(game,newPlayer(faction,faction,faction)); + game = act(game,'c',{type:'moduleOption',module:'ecazTreachery',enabled:true}); + for (const p of game.players) game = act(game,p.id,{type:'ready'}); + game = act(game,'c',{type:'start'}); + assert.equal(game.status,'setup'); assert.equal(game.semutaRecruitsPreview,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','ecaz']}); + 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(`Basic selected native 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); + 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-independent-native-terror.test.ts b/tests/semuta-independent-native-terror.test.ts new file mode 100644 index 00000000..cd7aa8fe --- /dev/null +++ b/tests/semuta-independent-native-terror.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { placeTerror } from '../game/moritani-terror'; +import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; +import type { Card } from '../game/cards'; + +const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); +const seat = (game: Game, id: string) => { + const player = game.players.find(row => row.id === id); + assert.ok(player); return player; +}; +const inventory = (game: Game) => [...game.deck, ...game.discard, ...(game.richeseCache ?? []), + ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); +const take = (game: Game, predicate: (card: Card) => boolean): Card => { + 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('An original physical card is unavailable.'); +}; +const card = (game: Game) => take(game, item => ['projectile','poison','shield','snooper','worthless'].includes(item.kind)); + +/** Original public Basic setup with independent Ecaz Treachery Cards. The + * later phase-five position, wallets, physical card hands and Terror placement + * are staged with conserved pieces; shipment and Terror choices are actions. */ +function fixture(kind: 'sabotage' | 'robbery') { + let game = createGame('SEMUTANATIVETERROR', newPlayer('m','Moritani','moritani'), false, ['choam','ecaz']); + joinGame(game, newPlayer('r','Richese','richese')); + joinGame(game, newPlayer('a','Atreides','atreides')); + game = act(game,'m',{type:'moduleOption',module:'ecazTreachery',enabled:true}); + for (const player of game.players) game = act(game,player.id,{type:'ready'}); + game = act(game,'m',{type:'start'}); + assert.equal(game.status,'setup'); + assert.equal(game.semutaRecruitsPreview,true); + for (let count = 0; game.status === 'setup' && count < 160; count++) { + let next: {id:string; action:Action} | undefined; + for (const player of game.players) { + const view = viewGame(game,player.id); + view.players.find(row => row.id === player.id)!.bot = 'Medium'; + const action = botActions(view)[0]; + if (action) {next = {id:player.id,action}; break;} + } + assert.ok(next, 'The original setup must have a legal action.'); + game = act(game,next.id,next.action); + } + assert.equal(game.status,'playing'); + assert.equal(game.ecazTreachery,true); + assert.equal(game.semutaPreview,undefined); + const original = inventory(game); + game.deck.push(...game.players.flatMap(player => player.hand)); + for (const player of game.players) { + player.hand = []; + player.reserves += player.tanks + Object.values(player.forces).reduce((sum,n) => sum+n,0); + player.forces = {}; player.tanks = 0; player.spice = 20; + player.shipped = false; player.moved = 0; + assert.equal(player.reserves,20); + } + const semuta = take(game,item => item.id === SEMUTA_DRUG_ID); + seat(game,'r').hand.push(semuta); + const gift = card(game); + if (kind === 'sabotage') { + seat(game,'m').hand.push(gift); + seat(game,'a').hand.push(card(game),card(game)); + } else { + game.deck.push(gift); + for (let count = 0; count < 4; count++) seat(game,'m').hand.push(card(game)); + } + const token = game.moritaniTerror!.tokens.find(row => row.kind === kind); assert.ok(token); + game.moritaniTerror = placeTerror(game.moritaniTerror!,token.id,'carthag',1); + Object.assign(game,{turn:2,phase:5,storm:18,active:'a',order:['a','m','r'], + movementRemaining:['a','m','r'],ready:[],response:null,decision:null,phaseOpening:null}); + assert.deepEqual(inventory(game),original); + game = act(game,'a',{type:'ship',territory:'carthag',sector:11,amount:2,elite:0,allyPayment:0}); + assert.equal(game.pendingTerrorEntry?.stage,'offer'); + game = act(game,'m',{type:'decision',reveal:true}); + if (kind === 'robbery') { + assert.equal(game.pendingTerrorEntry?.stage,'robbery'); + game = act(game,'m',{type:'decision',choice:'card'}); + assert.equal(game.pendingTerrorEntry?.stage,'discard'); + game = act(game,'m',{type:'decision',card:seat(game,'m').hand[0].id}); + } + return {game, original, token:token.id, gift:gift.id, semuta:semuta.id}; +} + +function pass(game: Game): Game { + const event = game.pendingTreacheryDiscard!.batch.event; + for (const player of game.players) game = act(game,player.id,{type:'semutaPass',event}); + return game; +} +function claim(game: Game, owner: string, selected: string): Game { + const event = game.pendingTreacheryDiscard!.batch.event; + game = act(game,owner,{type:'semutaCommit',event}); + if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') + game = act(game,owner,{type:'semutaSelect',event,card:selected}); + return game; +} + +for (const kind of ['sabotage','robbery'] as const) for (const claimed of [false,true]) { + void test(`Basic independent cards: ${kind} private discard offers Semuta ${claimed ? 'claim' : 'pass'}`, () => { + const f = fixture(kind), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + assert.equal(pending.continuation.kind,'terrorDiscard'); + assert.equal(pending.batch.cause,kind === 'sabotage' ? 'terror:sabotage' : 'terror:robberyOverflow'); + assert.equal(pending.batch.entries.length,1); + assert.equal(pending.batch.entries[0].publicFace,false); + assert.equal(pending.batch.entries[0].discardedBy,kind === 'sabotage' ? 'a' : 'm'); + const target = pending.batch.entries[0].card.id, event = pending.batch.event; + assert.deepEqual(viewGame(f.game,'r').semutaReaction?.candidates,[]); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(f.game,'r'); view.players.find(player => player.id === 'r')!.bot = difficulty; + assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass')); + } + let game = claimed ? claim(f.game,'r',target) : pass(f.game); + assert.equal(seat(game,'r').hand.some(item => item.id === target),claimed); + assert.equal(game.discard.some(item => item.id === f.semuta),claimed); + assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)!.status,'removed'); + assert.throws(() => act(game,'r',{type:'semutaCommit',event})); + if (kind === 'sabotage') { + assert.equal(game.pendingTerrorEntry?.stage,'gift'); + game = act(game,'m',{type:'decision',card:f.gift}); + assert.ok(seat(game,'a').hand.some(item => item.id === f.gift)); + } + assert.equal(game.pendingTerrorEntry ?? null,null); + assert.equal(seat(game,'a').forces['carthag:11'],2); + assert.equal(seat(game,'a').reserves,18); + assert.equal(seat(game,'a').shipped,true); + assert.deepEqual(inventory(game),f.original); + }); +} + +for (const keepGift of [false,true]) for (const claimed of [false,true]) { + void test(`Basic independent cards: Sabotage gift overflow discards ${keepGift ? 'an existing card' : 'the gift'} and Semuta ${claimed ? 'claims' : 'passes'}`, () => { + const f = fixture('sabotage'); + let game = pass(f.game); + assert.equal(game.pendingTerrorEntry?.stage,'gift'); + // Stage the exceptional intervening full hand with original physical cards. + while (seat(game,'a').hand.length < handLimit(seat(game,'a'))) + seat(game,'a').hand.push(card(game)); + assert.deepEqual(inventory(game),f.original); + const existing = seat(game,'a').hand[0].id; + game = act(game,'m',{type:'decision',card:f.gift}); + assert.equal(game.pendingTerrorEntry?.stage,'giftDiscard'); + assert.equal(game.decision?.player,'a'); + const selected = keepGift ? existing : f.gift; + game = act(game,'a',{type:'decision',card:selected}); + const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); + assert.equal(pending.batch.cause,'terror:sabotageGiftOverflow'); + assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id,entry.discardedBy,entry.publicFace]), + [[selected,'a',false]]); + assert.deepEqual(viewGame(game,'r').semutaReaction?.candidates,[]); + game = claimed ? claim(game,'r',selected) : pass(game); + assert.equal(seat(game,'r').hand.some(item => item.id === selected),claimed); + assert.equal(seat(game,'a').hand.some(item => item.id === f.gift),keepGift); + assert.equal(game.pendingTerrorEntry ?? null,null); + assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)!.status,'removed'); + assert.equal(seat(game,'a').forces['carthag:11'],2); + assert.deepEqual(inventory(game),f.original); + }); +}