import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, joinGame, newPlayer, viewGame, handLimit, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { territory } from '../game/board'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorPlayer as player, basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; const moritani = 'moritani', richese = 'richese', rider = 'fremen', holder = 'guild'; /** Real public setup, native Terror placement, Spice Blow, Nexus and worm ride. * Physical cards and two Fremen forces are moved between their original zones * to give the chosen branch a deterministic victim, holder and second ride; * only unopened Spice positions are selected. No pending event is staged. */ function source(kind: 'sabotage' | 'robbery') { let g = createGame('SEMUTATERRORWORM', newPlayer(moritani, 'Moritani', 'moritani'), false, ['ecaz', 'choam']); for (const faction of [richese, rider, holder] as const) joinGame(g, newPlayer(faction, faction, faction)); for (const p of g.players) g = act(g, p.id, {type:'ready'}); g = act(g, moritani, {type:'start'}); assert.equal(g.status, 'setup'); assert.equal(g.semutaPreview, true); const step = () => { if (g.status === 'setup' && g.setupStage === 'forces' && player(g, rider).reserves === 20) { g = act(g, rider, {type:'fremenSetup', placements:{'sietch_tabr:14':10}}); return; } if (g.status === 'setup' && !g.setupStage && g.decision?.kind === 'moritaniSetup') { g = act(g, moritani, {type:'decision', territory:'oh_gap', sector:9}); return; } const next = nextRicheseStrongholdsNativeStep(g); assert.ok(next, `Missing native action at ${g.status}/${g.turn}/${g.phase}/${g.decision?.kind}`); g = act(g, next.actor, next.action); }; const reach = (condition: (state: Game) => boolean) => { for (let n = 0; n < 2000; n++) { if (condition(g)) return; assert.notEqual(g.status, 'finished'); step(); } throw Error('Native Terror worm source did not reach the requested phase'); }; reach(state => state.status === 'playing'); const f = player(g, rider); const originalForce = Object.keys(f.forces).find(key => f.forces[key] >= 2)!; assert.ok(originalForce); f.forces[originalForce] -= 2; if (!f.forces[originalForce]) delete f.forces[originalForce]; f.forces['the_great_flat:15'] = 1; f.forces['hagga_basin:12'] = 1; for (const [position, predicate] of [ [0, (c: Game['spiceDeck'][number]) => 'territory' in c && c.territory === 'the_great_flat'], [1, (c: Game['spiceDeck'][number]) => 'worm' in c && !c.greatMaker], ] as const) { const index = g.spiceDeck.findIndex((card, i) => i >= position && predicate(card)); assert.ok(index >= position); [g.spiceDeck[position], g.spiceDeck[index]] = [g.spiceDeck[index], g.spiceDeck[position]]; } reach(state => state.phase === 8 && !state.response && state.decision?.kind === 'moritaniPlacement'); const token = g.moritaniTerror!.tokens.find(t => t.kind === kind)!; assert.ok(token); const destination = ['carthag', 'arrakeen', 'habbanya_ridge_sietch', 'tueks_sietch'] .find(id => territory(id).sectors.some(sector => sector !== g.storm))!; g = act(g, moritani, {type:'decision', token:token.id, territory:destination}); reach(state => state.turn === 2 && state.decision?.kind === 'wormRide' && state.decision.player === rider); assert.equal(g.decision?.kind === 'wormRide' && g.decision.territory, 'the_great_flat'); const sector = territory(destination).sectors.find(value => value !== g.storm)!; assert.ok(sector !== undefined); assert.notEqual(g.storm, 15); assert.notEqual(g.storm, 12); g = act(g, rider, {type:'decision', accept:true, territory:destination, sector, forces:{'the_great_flat:15':1}}); assert.equal(g.pendingTerrorEntry?.cause, 'wormRide'); assert.equal(g.pendingTerrorEntry?.resume, 'wormRide'); // Supplement the real first ride with one conserved, staged later ride. g.wormRides.push('hagga_basin'); const semutaAt = g.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID); assert.ok(semutaAt >= 0 && player(g, holder).hand.length > 0); const semuta = g.richeseCache!.splice(semutaAt, 1)[0]; g.deck.push(player(g, holder).hand[0]); player(g, holder).hand[0] = semuta; for (const id of [moritani, rider]) { const seat = player(g, id); g.deck.push(...seat.hand); seat.hand = []; } const card = () => { const index = g.deck.findIndex(c => ['projectile','poison','shield','snooper','worthless'].includes(c.kind)); assert.ok(index >= 0); return g.deck.splice(index, 1)[0]; }; const gift = card(); if (kind === 'sabotage') { player(g, moritani).hand.push(gift); player(g, rider).hand.push(card()); } else { g.deck.push(gift); for (let n = 0; n < handLimit(player(g, moritani)); n++) player(g, moritani).hand.push(card()); } const original = inventory(g); return {g, token:token.id, destination, sector, gift:gift.id, original}; } function pass(g: Game) { const event = g.pendingTreacheryDiscard!.batch.event; for (const seat of g.players) g = act(g, seat.id, {type:'semutaPass', event}); return g; } function finishSemuta(g: Game, claim: boolean) { const batch = g.pendingTreacheryDiscard!.batch; const selected = batch.entries[0].card.id; if (claim) { g = act(g, holder, {type:'semutaCommit', event:batch.event}); if (g.pendingTreacheryDiscard?.reaction?.stage === 'select') g = act(g, holder, {type:'semutaSelect', event:batch.event, card:selected}); } else g = pass(g); return {g, selected, event:batch.event}; } for (const cause of ['sabotage', 'sabotageGiftOverflow', 'robberyOverflow'] as const) for (const claim of [false, true]) void test( `Basic ${cause} during worm ride preserves Semuta ${claim ? 'claim' : 'pass'} and original continuation`, () => { const f = source(cause === 'robberyOverflow' ? 'robbery' : 'sabotage'); let g = act(f.g, moritani, {type:'decision', reveal:true}); if (cause === 'robberyOverflow') { assert.equal(g.pendingTerrorEntry?.stage, 'robbery'); const deck = g.deck.map(card => card.id); g = act(g, moritani, {type:'decision', choice:'card'}); assert.equal(g.pendingTerrorEntry?.stage, 'discard'); assert.ok(player(g, moritani).hand.some(card => card.id === deck[0])); g = act(g, moritani, {type:'decision', card:player(g, moritani).hand[0].id}); } else if (cause === 'sabotageGiftOverflow') { assert.equal(g.pendingTreacheryDiscard?.batch.cause, 'terror:sabotage'); g = pass(g); assert.equal(g.pendingTerrorEntry?.stage, 'gift'); // A conserved intervening hand refill tests the distinct second discard. while (player(g, rider).hand.length < handLimit(player(g, rider))) { const card = g.deck.pop(); assert.ok(card); player(g, rider).hand.push(card); } g = act(g, moritani, {type:'decision', card:f.gift}); assert.equal(g.pendingTerrorEntry?.stage, 'giftDiscard'); g = act(g, rider, {type:'decision', card:f.gift}); } const pending = g.pendingTreacheryDiscard; assert.equal(pending?.continuation.kind, 'terrorDiscard'); assert.equal(pending?.reaction?.stage, 'offer'); assert.equal(pending?.batch.cause, `terror:${cause}`); assert.equal(pending?.batch.entries.length, 1); assert.equal(pending?.batch.entries[0].discardedBy, cause === 'robberyOverflow' ? moritani : rider); assert.equal(pending?.batch.entries[0].publicFace, false); assert.equal(player(g, rider).forces[`${f.destination}:${f.sector}`], 1); assert.deepEqual(g.wormRides, ['hagga_basin']); for (const p of g.players) { const view = viewGame(g, p.id); assert.deepEqual(view.semutaReaction?.candidates, []); assert.equal(view.semutaReaction?.canCommit, p.id === holder); } for (const difficulty of DIFFICULTIES) { const view = viewGame(g, holder); view.players.find(p => p.id === holder)!.bot = difficulty; assert.ok(botActions(view).some(action => action.type === 'semutaCommit' || action.type === 'semutaPass')); } const result = finishSemuta(g, claim); g = result.g; assert.equal(player(g, holder).hand.some(card => card.id === result.selected), claim); assert.equal(g.discard.some(card => card.id === SEMUTA_DRUG_ID), claim); if (cause === 'sabotage') { assert.equal(g.pendingTerrorEntry?.stage, 'gift'); g = act(g, moritani, {type:'decision', card:f.gift}); assert.ok(player(g, rider).hand.some(card => card.id === f.gift)); } assert.equal(g.pendingTerrorEntry ?? null, null); assert.equal(g.moritaniTerror!.tokens.find(token => token.id === f.token)!.status, 'removed'); assert.deepEqual(g.decision, {kind:'wormRide', player:rider, territory:'hagga_basin'}); assert.deepEqual(g.wormRides, []); assert.equal(player(g, rider).forces[`${f.destination}:${f.sector}`], 1); assert.deepEqual(inventory(g), f.original); assert.throws(() => act(g, holder, {type:'semutaCommit', event:result.event})); g = act(g, rider, {type:'decision', accept:false}); assert.notEqual(g.decision?.kind, 'wormRide'); assert.deepEqual(g.wormRides, []); assert.deepEqual(inventory(g), f.original); }, );