import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { ownedTech, TECH_TOKENS } from '../game/tech-tokens'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { nextNativeTechFactionsStep } from './fixture-native-tech-factions'; import { basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador'; const seat = (game: Game, id: string) => { const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player; }; const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard; /** Run original setup and first Storm; select only safe unopened Spice draws. * Once the real movement opportunity is reached, relocate the physical Semuta * and Hajr to their users' hands and return those hands' other cards to the * deck. No Tech ownership, force, resource or turn state is staged. */ function fixture() { let game = createGame('SEMUTATECHPUBLIC', newPlayer('r', 'Richese', 'richese'), false, ['choam']); joinGame(game, newPlayer('a', 'Atreides', 'atreides')); joinGame(game, newPlayer('g', 'Guild', 'guild')); game = applyAction(game, 'r', { type: 'techTokens', enabled: true }); for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); game = applyAction(game, 'r', { type: 'start' }); assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true); const original = inventory(game); for (let count = 0; game.status === 'setup' && count < 250; count++) { const step = nextNativeTechFactionsStep(game); assert.ok(step); game = applyAction(game, step.actor, step.action); } assert.equal(game.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); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card); } for (let count = 0; !(game.phase === 5 && game.active === 'a' && clean(game)) && count < 1600; count++) { assert.notEqual(game.status, 'finished'); const step = nextNativeTechFactionsStep(game); assert.ok(step); game = applyAction(game, step.actor, step.action); } assert.equal(game.phase, 5); assert.equal(game.active, 'a'); assert.ok(clean(game)); const owners = TECH_TOKENS.map(token => game.techTokens![token.id].owner); assert.equal(new Set(owners).size, 3); assert.ok(owners.every(owner => game.players.some(player => player.id === owner))); for (const id of ['r', 'a']) game.deck.push(...seat(game, id).hand.splice(0)); const zones = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [], ...game.players.map(player => player.hand)]; const take = (match: (card: Game['deck'][number]) => boolean) => { for (const zone of zones) { const index = zone.findIndex(match); if (index >= 0) return zone.splice(index, 1)[0]; } throw Error('The original physical card was not found'); }; const semuta = take(card => card.id === SEMUTA_DRUG_ID); const hajr = take(card => card.effect === 'hajr'); seat(game, 'r').hand.push(semuta); seat(game, 'a').hand.push(hajr); assert.deepEqual(inventory(game), original); return { game, original, hajr: hajr.id, tech: structuredClone(game.techTokens!) }; } for (const claim of [false, true]) { void test(`Basic public Tech Semuta ${claim ? 'claims' : 'passes'} a real Hajr discard and continues the shipment`, () => { const f = fixture(); let game = applyAction(f.game, 'a', { type: 'card', card: f.hajr }); const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction?.stage === 'offer'); assert.equal(pending.continuation.kind, 'ordinaryCardDiscard'); assert.equal(viewGame(game, 'r').semutaReaction?.canCommit, true); assert.deepEqual(game.techTokens, f.tech); const event = pending.batch.event; if (claim) game = applyAction(game, 'r', { type: 'semutaCommit', event }); else for (const player of game.players) game = applyAction(game, player.id, { type: 'semutaPass', event }); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = applyAction(game, 'r', { type: 'semutaSelect', event, card: f.hajr }); assert.equal(game.pendingTreacheryDiscard, null); assert.ok(game.hajr.includes('a')); assert.equal(seat(game, 'r').hand.some(card => card.id === f.hajr), claim); assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claim); const owner = game.techTokens!.heighliners.owner!; const already = game.techTokens!.heighliners.triggeredTurn === game.turn; const spice = already ? game.techTokens!.heighliners.spice : ownedTech(game.techTokens, owner).length; const reserves = seat(game, 'a').reserves; game = applyAction(game, 'a', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1 }); for (let count = 0; !clean(game) && count < 30; count++) { const step = nextNativeTechFactionsStep(game); assert.ok(step); game = applyAction(game, step.actor, step.action); } assert.ok(clean(game)); assert.equal(seat(game, 'a').reserves, reserves - 1); assert.equal(game.techTokens!.heighliners.owner, owner); assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn); assert.equal(game.techTokens!.heighliners.spice, spice); assert.deepEqual(inventory(game), f.original); }); } void test('all four bot profiles can legally respond to the Basic Tech Semuta offer', () => { const f = fixture(); const pending = applyAction(f.game, 'a', { type: 'card', card: f.hajr }); for (const difficulty of DIFFICULTIES) { const view = viewGame(pending, 'r'); const player = view.players.find(candidate => candidate.id === 'r'); assert.ok(player); player.bot = difficulty; const action = botActions(view)[0]; assert.ok(action, difficulty); assert.doesNotThrow(() => applyAction(pending, 'r', action)); } });