import assert from 'node:assert/strict'; import test from 'node:test'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { distance, location, TERRITORIES } from '../game/board'; 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'; import { basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador'; import { moveSemutaEmperorCard, semutaEmperorCardId } from './fixture-semuta-emperor-share'; type Native = 'tleilaxu' | 'ecaz'; type Kind = 'box' | 'distrans' | 'truth' | 'ornithopter'; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); function advance(game: Game, until: (game: Game) => boolean): Game { for (let count = 0; count < 1600; count++) { if (until(game)) return game; const pending = game.pendingTreacheryDiscard; if (pending?.reaction?.stage === 'offer') { const reaction = pending.reaction; const next = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(next); game = act(game, next.id, {type:'semutaPass', event:pending.batch.event}); } else { const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, `The native game needs a decision at ${game.turn}/${game.phase}/${game.decision?.kind}`); game = act(game, next.actor, next.action); } } throw Error('The original native game did not reach its requested boundary.'); } /** Public Basic setup with selected native faction decks; Guild buys the real * Semuta and Atreides buys Hajr. Unopened main/Spice ordering is selected. * A single additional physical card is moved to Atreides after its purchase. */ function fixture(kind: Kind, native: Native) { let game = createGame('SEMUTANATIVESPECIALS', newPlayer('atreides','Atreides','atreides'), false, ['choam','ix','ecaz']); for (const faction of ['richese','guild',native] as const) joinGame(game,newPlayer(faction,faction,faction)); game = act(game,'atreides',{type:'moduleOption',module:'ecazTreachery',enabled:true}); for (const seat of game.players) game = act(game,seat.id,{type:'ready'}); game = act(game,'atreides',{type:'start'}); assert.equal(game.status,'setup'); assert.equal(game.semutaRecruitsPreview,true); const originalInventory = inventory(game); const starting = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)) .slice(0,game.players.length).map(card => card.id); const hajr = game.deck.find(card => card.effect === 'hajr'); assert.ok(hajr); for (const [position,id] of [...starting,hajr.id].entries()) { const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); [game.deck[position],game.deck[from]] = [game.deck[from],game.deck[position]]; } game = advance(game,state => state.status === 'playing'); for (let position = 0; position < 4; position++) { const from = game.spiceDeck.findIndex((card,index) => index >= position && 'territory' in card && !card.discovery); assert.ok(from >= position); game.spiceDeck.splice(position,0,game.spiceDeck.splice(from,1)[0]); } game = advance(game,state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache'); game = act(game,'richese',{type:'decision',event:game.richeseBidding!.event,card:SEMUTA_DRUG_ID, method:'onceAround',direction:'counterclockwise'}); game = advance(game,state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome); for (let count = 0; !player(game,'guild').hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) { const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome); game = act(game,lot.active,{type:'richeseBid',event:lot.event,amount:lot.active === 'guild' ? 1 : null,allyPayment:0}); game = advance(game,clean); } assert.ok(player(game,'guild').hand.some(card => card.id === SEMUTA_DRUG_ID)); game = advance(game,state => state.phase === 3 && clean(state) && state.auction?.active === 'atreides'); assert.equal(game.auction!.cards[game.auction!.index].id,hajr.id); game = act(game,'atreides',{type:'bid',amount:1,allyPayment:0}); game = advance(game,state => clean(state) && player(state,'atreides').hand.some(card => card.id === hajr.id)); game = advance(game,state => state.phase === 5 && state.active === 'atreides' && clean(state)); const effect = kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : kind; const card = semutaEmperorCardId(game,item => item.effect === effect); moveSemutaEmperorCard(game,card,'atreides'); assert.deepEqual(inventory(game),originalInventory); let given: string | undefined, selected: string | undefined; if (kind === 'box') { game = act(game,'atreides',{type:'card',card:hajr.id}); const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); for (const seat of game.players) game = act(game,seat.id,{type:'semutaPass',event:pending.batch.event}); selected = hajr.id; game = act(game,'atreides',{type:'card',card}); if (game.pendingNullentropy) game = act(game,'atreides',{type:'decision',event:game.pendingNullentropy.event,card:selected}); } else if (kind === 'distrans') { given = hajr.id; game = act(game,'atreides',{type:'card',card,target:native,give:given}); } else if (kind === 'truth') { game = act(game,'atreides',{type:'card',card}); while (game.truthtrance?.stage === 'priority') { const next = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(next); game = act(game,next.id,{type:'truthPass'}); } game = act(game,'atreides',{type:'truthAsk',question:{kind:'freeform',target:native, scope:'fact',text:'Do you intend to protect Arrakeen?'}}); game = act(game,native,{type:'truthAnswer',answer:'yes'}); } else { const open = TERRITORIES.flatMap(place => place.type === 'sand' ? place.sectors.map(sector => ({place,sector})) : []) .find(({place,sector}) => sector !== game.storm && game.players.every(seat => !Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`)))); assert.ok(open); game = act(game,'atreides',{type:'ship',territory:open.place.id,sector:open.sector,amount:1,elite:0,allyPayment:0}); game = advance(game,clean); const from = location(open.place.id,open.sector); const target = TERRITORIES.flatMap(place => place.sectors.map(sector => ({place,sector}))) .find(({place,sector}) => place.id !== open.place.id && sector !== game.storm && distance(from,location(place.id,sector)) <= 1 && game.players.every(seat => !Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`)))); assert.ok(target); game = act(game,'atreides',{type:'move',from,amount:1,elite:0,territory:target.place.id, sector:target.sector,movementCard:card,ornithopter:'range3'}); } return {game,card,given,selected,originalInventory}; } for (const native of ['tleilaxu','ecaz'] as const) for (const kind of ['box','distrans','truth','ornithopter'] as const) for (const claim of [false,true]) { void test(`Basic ${native} ${kind} discard offers Semuta ${claim ? 'claim' : 'pass'} without replay`, () => { const f = fixture(kind,native), frame = f.game.pendingTreacheryDiscard; assert.ok(frame?.reaction); assert.deepEqual(frame.batch.entries.map(entry => entry.card.id),[f.card]); assert.equal(viewGame(f.game,'guild').semutaReaction?.canCommit,true); if (kind === 'distrans') { assert.ok(player(f.game,native).hand.some(card => card.id === f.given)); assert.ok(!frame.batch.entries.some(entry => entry.card.id === f.given)); } if (kind === 'box') assert.ok(player(f.game,'atreides').hand.some(card => card.id === f.selected)); if (kind === 'truth') assert.equal(f.game.truthHistory?.at(-1)?.answer,'yes'); const before = structuredClone({players:f.game.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})), truthHistory:f.game.truthHistory,moved:player(f.game,'atreides').moved}); if (kind === 'distrans' && native === 'tleilaxu') for (const difficulty of DIFFICULTIES) { const view = viewGame(f.game,'guild'); view.players.find(row => row.id === 'guild')!.bot = difficulty; const actions = botActions(view); assert.ok(actions.some(action => action.type === 'semutaCommit' || action.type === 'semutaPass')); } let done = f.game; if (claim) { done = act(done,'guild',{type:'semutaCommit',event:frame.batch.event}); if (done.pendingTreacheryDiscard?.reaction?.stage === 'select') done = act(done,'guild',{type:'semutaSelect',event:frame.batch.event,card:f.card}); } else for (const seat of done.players) done = act(done,seat.id,{type:'semutaPass',event:frame.batch.event}); assert.equal(done.pendingTreacheryDiscard,null); assert.deepEqual({players:done.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})), truthHistory:done.truthHistory,moved:player(done,'atreides').moved},before); assert.equal(player(done,'guild').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.originalInventory); }); }