import assert from 'node:assert/strict'; import test from 'node:test'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { splitLocation } from '../game/board'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador'; /** Original public selected-family setup, Richese cache sale, paid shipments * and real native-faction battle. Only unopened starting-deck and Spice-card * order are selected for this witness. */ function createNativeBattle(kind: 'loser-reinforcements' | 'loser-harass' | 'winner-mandatory' | 'winner-selected', native: 'tleilaxu' | 'ecaz') { let game = createGame('SEMUTANATIVEBATTLE', newPlayer(native, native, native), false, ['choam', 'ix', 'ecaz']); for (const faction of ['atreides', 'richese', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); const loser = native, winner = 'atreides', holder = 'guild', location = 'red_chasm:7'; const perform = (actor: string, action: Action) => { game = act(game, actor, action); }; const reach = (until: (state: Game) => boolean) => { for (let count = 0; count < 1800 && !until(game); count++) { assert.notEqual(game.status, 'finished'); if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { const reaction = game.pendingTreacheryDiscard.reaction; const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor); perform(actor.id, {type:'semutaPass', event:game.pendingTreacheryDiscard.batch.event}); } else { const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step, `Native step required at ${game.phase}`); perform(step.actor, step.action); } } assert.ok(until(game), 'The real game must reach the requested boundary.'); }; perform(loser, {type:'moduleOption', module:'ecazTreachery', enabled:true}); for (const seat of game.players) perform(seat.id, {type:'ready'}); perform(loser, {type:'start'}); assert.equal(game.semutaRecruitsPreview, true); const originalInventory = inventory(game); const cardId = kind === 'loser-harass' ? 'ecaz-harass-withdraw' : 'ecaz-reinforcements'; const shield = game.deck.find(card => card.kind === 'shield'); assert.ok(shield); const reserved = new Set([cardId, shield.id]); const filler = game.deck.filter(card => !reserved.has(card.id)).slice(0, 2); assert.equal(filler.length, 2); const starting = kind === 'winner-mandatory' ? [filler[0].id, cardId, filler[1].id, shield.id] : [cardId, shield.id, filler[0].id, filler[1].id]; for (let index = 0; index < starting.length; index++) { const from = game.deck.findIndex(card => card.id === starting[index]); assert.ok(from >= index); [game.deck[index], game.deck[from]] = [game.deck[from], game.deck[index]]; } reach(state => state.status === 'playing'); assert.ok(player(game, kind === 'winner-mandatory' ? winner : loser).hand.some(card => card.id === cardId)); if (kind !== 'winner-mandatory') assert.ok(player(game, winner).hand.some(card => card.id === shield.id)); for (let index = 0; index < 3; index++) { const from = game.spiceDeck.findIndex((card, pos) => pos >= index && 'territory' in card && !card.discovery); assert.ok(from >= index); game.spiceDeck.splice(index, 0, game.spiceDeck.splice(from, 1)[0]); } reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache'); perform('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; !player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) { const lot = game.richeseAuction; assert.ok(lot?.active && !lot.outcome); perform(lot.active, {type:'richeseBid', event:lot.event, amount:lot.active === holder ? 1 : null, allyPayment:0}); reach(clean); } assert.ok(player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID)); const shipped = new Set(); while (shipped.size < 2) { reach(state => state.phase === 5 && !!state.active && clean(state)); const actor = game.active!; if ([loser, winner].includes(actor) && !shipped.has(actor)) { perform(actor, {type:'ship', ...splitLocation(location), amount:actor === winner ? 4 : 1, elite:0, allyPayment:0}); reach(clean); shipped.add(actor); } perform(actor, {type:'endMovement'}); } reach(state => state.phase === 6 && !state.battle && clean(state)); const actor = game.active!; const choice = viewGame(game, actor).battleChoices.find(row => row.territory === 'red_chasm' && [row.attacker, row.defender].includes(loser) && [row.attacker, row.defender].includes(winner)); assert.ok(choice); perform(choice.chooser, {type:'chooseBattle', territory:choice.territory, target:choice.attacker === choice.chooser ? choice.defender : choice.attacker}); reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && clean(state)); const strongest = player(game, winner).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a,b) => b.strength-a.strength)[0]; const weakest = player(game, loser).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a,b) => a.strength-b.strength)[0]; assert.ok(strongest && weakest && strongest.strength + 1 > weakest.strength + 2); perform(winner, {type:'battlePlan', dial:1, support:0, leader:strongest.id, weapon:null, defense:kind === 'winner-mandatory' ? cardId : shield.id}); perform(loser, {type:'battlePlan', dial:0, support:0, leader:weakest.id, weapon:kind === 'winner-mandatory' ? null : cardId, defense:null}); let final: {actor:string;action:Action} | undefined; for (let count = 0; count < 100; count++) { const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step); if (step.action.type === 'traitorCall') { const voters = viewGame(game, game.host).battle!.traitorVoters; if (voters.every(id => id === step.actor || game.battle!.traitorCalls[id] !== undefined)) { final = step; break; } } perform(step.actor, step.action); } assert.ok(final); return {game, loser, winner, holder, location, card:cardId, shield:shield.id, originalInventory, finalActor:final.actor, finalAction:final.action, kind}; } function react(game: Game, holder: string, claim: boolean, selected: string) { const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); const event = pending.batch.event; if (!claim) { for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event}); return game; } game = act(game, holder, {type:'semutaCommit', event}); return game.pendingTreacheryDiscard?.reaction?.stage === 'select' ? act(game, holder, {type:'semutaSelect', event, card:selected}) : game; } function advance(game: Game, until: (state: Game) => boolean) { for (let count = 0; count < 600 && !until(game); count++) { if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = react(game, '', false, ''); else { const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, `Original battle needs a step at ${game.phase}/${game.decision?.kind}`); game = act(game, next.actor, next.action); } } assert.ok(until(game)); return game; } for (const native of ['tleilaxu','ecaz'] as const) for (const kind of ['loser-reinforcements','loser-harass','winner-mandatory','winner-selected'] as const) for (const claim of [false,true]) void test(`Basic ${native} ${kind} offers Semuta: ${claim ? 'claim' : 'pass'}`, () => { const f = createNativeBattle(kind, native); let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction)); if (kind === 'winner-selected') { assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory'); game = react(game, f.holder, false, f.card); game = advance(game, state => state.decision?.kind === 'battleCards' && state.decision.player === f.winner); game = act(game, f.winner, {type:'decision', discard:[f.shield]}); } const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction); assert.equal(pending.batch.cause, kind === 'winner-selected' ? 'battle:winner' : kind === 'winner-mandatory' ? 'battle:winnerMandatory' : 'battle:mandatory'); const card = kind === 'winner-selected' ? f.shield : f.card; assert.ok(pending.batch.entries.some(entry => entry.card.id === card)); const before = structuredClone(game); if (native === 'tleilaxu') { assert.equal(game.pendingFaceDance?.player, native); assert.equal(game.pendingFaceDance?.winner, f.winner); } if (native === 'tleilaxu' && kind === 'loser-reinforcements') for (const difficulty of DIFFICULTIES) { const view = viewGame(game, f.holder); view.players.find(seat => seat.id === f.holder)!.bot = difficulty; const action = botActions(view).find(option => option.type === 'semutaCommit' || option.type === 'semutaPass'); assert.ok(action); assert.doesNotThrow(() => act(game, f.holder, action)); } game = react(game, f.holder, claim, card); assert.equal(game.pendingTreacheryDiscard, null); assert.equal(player(game, f.loser).tanks, player(before, f.loser).tanks); assert.equal(game.lastBattleContext?.event, before.lastBattleContext?.event); assert.equal(player(game, f.holder).hand.some(held => held.id === card), claim); assert.equal(game.discard.some(discarded => discarded.id === card), !claim); assert.equal(game.discard.some(discarded => discarded.id === SEMUTA_DRUG_ID), claim); assert.deepEqual(inventory(game), f.originalInventory); assert.throws(() => act(game, f.holder, {type:'semutaCommit',event:pending.batch.event})); if (native === 'tleilaxu') assert.equal(game.pendingFaceDance?.player, native); if (native === 'tleilaxu' && kind === 'loser-reinforcements' && claim) { game = advance(game, state => state.decision?.kind === 'faceDance' && state.decision.player === native); game = act(game, native, {type:'decision', reveal:false}); assert.equal(game.pendingFaceDance ?? null, null); assert.ok(player(game, f.holder).hand.some(held => held.id === card)); } });