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); }); }