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 { TERRITORIES } from '../game/board'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; import { basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as seat } from './fixture-basic-semuta-ambassador'; type Effect = 'hajr' | 'ghola' | 'weather' | 'harvester' | 'atomics'; type Native = 'tleilaxu' | 'ecaz'; const act = (game: Game, player: string, action: Action) => applyAction(game, player, action); /** Real public selected-family setup, cache purchase, ordinary auction and * shipment. Unopened main/Spice order is selected to shorten the route. When * the native faction is the actor, the bought physical card moves between * hands after the auction to exercise that faction's own control. */ function nativeFixture(effect: Effect, native: Native, actor: 'atreides' | Native = 'atreides') { let game = createGame('SEMUTANATIVEORDINARY', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam', 'ix', 'ecaz']); for (const faction of ['richese', 'guild', native] as const) joinGame(game, newPlayer(faction, faction, faction)); const owner = actor, buyer = 'atreides', holder = 'guild'; const send = (actor: string, action: Action) => { game = act(game, actor, action); }; const reach = (until: (state: Game) => boolean) => { for (let count = 0; count < 1600; count++) { if (until(game)) return; assert.notEqual(game.status, 'finished'); const reaction = game.pendingTreacheryDiscard?.reaction; if (reaction?.stage === 'offer') { const responder = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(responder); send(responder.id, {type:'semutaPass', event:game.pendingTreacheryDiscard!.batch.event}); continue; } const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`); send(step.actor, step.action); } throw Error('The selected native Semuta fixture did not reach its boundary.'); }; send(buyer, {type:'moduleOption', module:'ecazTreachery', enabled:true}); for (const player of game.players) send(player.id, {type:'ready'}); send(buyer, {type:'start'}); assert.equal(game.status, 'setup'); assert.equal(game.semutaRecruitsPreview, true); const original = inventory(game); const starting = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)) .slice(0, game.players.length).map(card => card.id); assert.equal(starting.length, game.players.length); const card = game.deck.find(item => item.effect === effect); assert.ok(card); for (const [position, id] of [...starting, card.id].entries()) { const from = game.deck.findIndex(candidate => candidate.id === id); assert.ok(from >= position); [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; } for (let count = 0; game.status === 'setup' && count < 100; count++) { const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step); send(step.actor, step.action); } assert.equal(game.status, 'playing'); for (let position = 0; position < 4; position++) { const from = game.spiceDeck.findIndex((item, index) => index >= position && 'territory' in item && !item.discovery); assert.ok(from >= position); const item = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, item); } reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache'); send('richese', {type:'decision', event:game.richeseBidding!.event, card:SEMUTA_DRUG_ID, method:'onceAround', direction:'counterclockwise'}); reach(state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome); for (let count = 0; !seat(game, holder).hand.some(item => item.id === SEMUTA_DRUG_ID) && count < 8; count++) { const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome); send(lot.active, {type:'richeseBid', event:lot.event, amount:lot.active === holder ? 1 : null, allyPayment:0}); reach(clean); } assert.ok(seat(game, holder).hand.some(item => item.id === SEMUTA_DRUG_ID)); reach(state => state.phase === 3 && clean(state) && state.auction?.active === buyer); assert.equal(game.auction!.cards[game.auction!.index].id, card.id); send(buyer, {type:'bid', amount:1, allyPayment:0}); reach(state => clean(state) && seat(state, buyer).hand.some(item => item.id === card.id)); if (owner !== buyer) { const source = seat(game, buyer), recipient = seat(game, owner); const index = source.hand.findIndex(item => item.id === card.id); assert.ok(index >= 0); recipient.hand.push(...source.hand.splice(index, 1)); assert.deepEqual(inventory(game), original); } reach(state => state.phase === 5 && state.active === owner && clean(state)); if (effect === 'hajr') return {game, owner, holder, card:card.id, original, play:{type:'card',card:card.id} as Action}; const sector = effect === 'atomics' ? [8,9].find(value => value !== game.storm)! : game.storm % 18 + 1; const target = TERRITORIES.find(place => (effect === 'atomics' ? place.id === 'shield_wall' : place.type === 'sand') && place.sectors.includes(sector) && game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`)))); assert.ok(target); const casualty = `${target.id}:${sector}`; send(owner, {type:'ship', territory:target.id, sector, amount:3, elite:0, allyPayment:0}); if (effect === 'weather' || effect === 'atomics') reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && clean(state)); else if (effect === 'harvester') reach(state => state.turn === 2 && state.phase === 1 && !!state.spiceWindow && !state.spiceWindow.harvested && clean(state)); else { reach(state => state.turn === 2 && state.phase === 4 && clean(state)); assert.equal(seat(game, owner).forces[casualty], undefined); assert.equal(seat(game, owner).tanks, 3); } assert.deepEqual(inventory(game), original); const play = (effect === 'weather' ? {type:'card',card:card.id,amount:1} : effect === 'ghola' ? {type:'card',card:card.id,amount:3,elite:0} : {type:'card',card:card.id}) as Action; return {game, owner, holder, card:card.id, original, play, casualty}; } for (const native of ['tleilaxu','ecaz'] as const) { void test(`Basic native ${native} plays Hajr and Semuta resumes its actual movement`, () => { const f = nativeFixture('hajr', native, native); const pending = act(f.game, native, f.play); const frame = pending.pendingTreacheryDiscard; assert.ok(frame); assert.equal(frame.reaction?.stage, 'offer'); assert.equal(frame.continuation.kind, 'ordinaryCardDiscard'); assert.equal(frame.continuation.player, native); assert.ok(pending.hajr.includes(native)); assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true); let done = act(pending, f.holder, {type:'semutaCommit',event:frame.batch.event}); if (done.pendingTreacheryDiscard?.reaction?.stage === 'select') done = act(done, f.holder, {type:'semutaSelect',event:frame.batch.event,card:f.card}); assert.equal(done.pendingTreacheryDiscard, null); assert.ok(done.hajr.includes(native)); assert.equal(seat(done, f.holder).hand.some(item => item.id === f.card), true); assert.deepEqual(inventory(done), f.original); done = act(done, native, {type:'endMovement'}); assert.ok(!done.movementRemaining?.includes(native)); }); } for (const effect of ['hajr','ghola','weather','harvester','atomics'] as const) { for (const native of ['tleilaxu','ecaz'] as const) for (const claim of [false,true]) { void test(`Basic ${native} ${effect} discard offers Semuta ${claim ? 'claim' : 'pass'} without replay`, () => { const f = nativeFixture(effect, native), original = structuredClone(f.game); const prior = seat(f.game, f.owner); let game = act(f.game, f.owner, f.play); assert.deepEqual(f.game, original); const frame = game.pendingTreacheryDiscard; assert.ok(frame); assert.equal(frame.reaction?.stage, 'offer'); assert.equal(frame.continuation.kind, 'ordinaryCardDiscard'); assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [f.card]); if (effect === 'hajr') assert.ok(game.hajr.includes(f.owner)); if (effect === 'ghola') { assert.equal(seat(game, f.owner).reserves, prior.reserves + 3); assert.equal(seat(game, f.owner).tanks, prior.tanks - 3); } if (effect === 'weather') assert.equal(game.stormPending, 1); if (effect === 'harvester') { assert.equal(game.spiceWindow!.amount, f.game.spiceWindow!.amount * 2); assert.equal(game.spiceWindow!.harvested, true); } if (effect === 'atomics') { assert.equal(game.shieldWallDestroyed, true); assert.equal(seat(game, f.owner).forces[f.casualty!], undefined); assert.equal(seat(game, f.owner).tanks, prior.tanks + 3); } const committed = {phase:game.phase, turn:game.turn, storm:game.storm, stormPending:game.stormPending, hajr:game.hajr, spice:game.spice, spiceWindow:game.spiceWindow, shieldWallDestroyed:game.shieldWallDestroyed, forces:seat(game, f.owner).forces, reserves:seat(game, f.owner).reserves, tanks:seat(game, f.owner).tanks}; if (effect === 'hajr' && native === 'tleilaxu') for (const difficulty of DIFFICULTIES) { const view = viewGame(game, f.holder); view.players.find(player => player.id === f.holder)!.bot = difficulty; assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass')); } const event = frame.batch.event; if (claim) { game = act(game, f.holder, {type:'semutaCommit',event}); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = act(game, f.holder, {type:'semutaSelect',event,card:f.card}); } else for (const player of game.players) game = act(game, player.id, {type:'semutaPass',event}); assert.equal(game.pendingTreacheryDiscard, null); assert.deepEqual({phase:game.phase, turn:game.turn, storm:game.storm, stormPending:game.stormPending, hajr:game.hajr, spice:game.spice, spiceWindow:game.spiceWindow, shieldWallDestroyed:game.shieldWallDestroyed, forces:seat(game, f.owner).forces, reserves:seat(game, f.owner).reserves, tanks:seat(game, f.owner).tanks}, committed); assert.equal(seat(game, f.holder).hand.some(item => item.id === f.card), claim); assert.equal(game.discard.some(item => item.id === f.card), !claim); assert.equal(game.discard.some(item => item.id === SEMUTA_DRUG_ID), claim); assert.deepEqual(inventory(game), f.original); assert.throws(() => act(game, f.holder, {type:'semutaCommit',event})); }); } }