import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; import { territory } from '../game/board'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { actBasicSemutaAmbassador as act, nextBasicSemutaAmbassadorStep, basicSemutaAmbassadorPlayer as player, basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; type Effect = 'choam' | 'ixians'; const holder = 'guild', owner = 'ecaz', rider = 'fremen'; /** Public setup, paid Ambassador placement, real Spice Blow/Nexus and Fremen * ride. Conserved staging chooses the supplied Ambassador packet, exchanges Semuta * from the cache for Guild's dealt card in the deck, moves one starting Fremen counter to * the worm's desert, and selects the first two unopened Spice cards. */ function source(effect: Effect, copied: boolean) { let g = createGame('SEMUTAWORM', newPlayer(owner, 'Ecaz', 'ecaz'), 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, owner, {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; } const next = g.decision?.kind === 'richeseCache' ? nextRicheseStrongholdsNativeStep(g) : nextBasicSemutaAmbassadorStep(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: (g: Game) => boolean) => { for (let n = 0; n < 2000; n++) { if (condition(g)) return; assert.notEqual(g.status, 'finished'); step(); } throw Error('Native worm source did not reach the requested phase'); }; reach(state => state.status === 'playing'); const packet = g.ecazAmbassadors!, selected = packet.tokens.find(t => t.effect === (copied ? 'beneGesserit' : effect))!; const swap = (included: boolean, tokenId: string) => { if (packet.cohort.includes(tokenId) === included) return; const other = packet.cohort.find(id => id !== selected.id && id !== packet.tokens.find(t => t.effect === effect)!.id)!; const replacement = packet.tokens.find(t => t.id === tokenId)!; const displaced = packet.tokens.find(t => t.id === other)!; if (included) { packet.cohort[packet.cohort.indexOf(other)] = tokenId; displaced.zone = 'pool'; replacement.zone = 'supply'; } else { const candidate = packet.tokens.find(t => t.zone === 'pool' && t.id !== selected.id && t.id !== tokenId)!; packet.cohort[packet.cohort.indexOf(tokenId)] = candidate.id; replacement.zone = 'pool'; candidate.zone = 'supply'; } }; swap(true, selected.id); if (copied) swap(false, packet.tokens.find(t => t.effect === effect)!.id); const guild = player(g, holder), semutaIndex = g.richeseCache!.findIndex(c => c.id === SEMUTA_DRUG_ID); assert.ok(semutaIndex >= 0 && guild.hand.length); const semuta = g.richeseCache!.splice(semutaIndex,1)[0]; g.deck.push(guild.hand[0]); guild.hand[0] = semuta; const sourceForce = Object.keys(player(g, rider).forces).find(key => player(g, rider).forces[key] > 0)!; assert.ok(sourceForce); player(g, rider).forces[sourceForce]--; if (!player(g, rider).forces[sourceForce]) delete player(g, rider).forces[sourceForce]; player(g, rider).forces['the_great_flat:15'] = 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 === 4 && !state.response && state.decision?.kind === 'ecazPlacement'); const destination = ['carthag','arrakeen','habbanya_ridge_sietch','tueks_sietch'] .find(id => territory(id).sectors.some(sector => sector !== g.storm))!; assert.ok(destination); const sector = territory(destination).sectors.find(value => value !== g.storm)!; g = act(g, owner, {type:'decision',token:selected.id,territory:destination}); reach(state => !state.response && state.decision?.kind === 'ecazPlacement'); g = act(g, owner, {type:'decision',decline:true}); 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'); assert.notEqual(g.storm, 15); g = act(g, rider, {type:'decision',accept:true,territory:destination,sector, forces:{'the_great_flat:15':1}}); assert.equal(g.pendingAmbassador?.arrivalCause, 'wormRide'); assert.equal(g.pendingAmbassador?.resume, 'wormRide'); const event = g.pendingAmbassador!.event; g = act(g, owner, {type:'decision',event,trigger:true,beneficiary:owner,choice:'effect'}); if (copied) g = act(g, owner, {type:'decision',event,effect}); assert.equal(g.pendingAmbassador?.stage,'cards'); const cards = player(g, owner).hand.slice(0, effect === 'choam' ? 2 : 1).map(c => c.id); assert.ok(cards.length >= 1); if (effect === 'ixians') assert.equal(cards.length, 1); return {g,event,cards,destination,sector,original:inventory(g)}; } for (const effect of ['choam','ixians'] as const) for (const copied of [false,true]) for (const claim of [false,true]) void test( `Basic worm ride ${copied ? 'BG copy' : 'native'} ${effect} preserves entry after Semuta ${claim ? 'claim' : 'pass'}`, () => { const f = source(effect,copied), before = structuredClone(f.g); const spice = player(before,owner).spice, top = before.deck[0]?.id; let g = act(before,owner,{type:'decision',event:f.event,cards:f.cards}); const pending = g.pendingTreacheryDiscard!; assert.equal(pending?.continuation.kind,'ambassador'); assert.equal(pending?.reaction?.stage,'offer'); assert.equal(pending.batch.cause,`ambassador:${effect}`); assert.deepEqual(pending.batch.entries.map(e => e.card.id),f.cards); assert.ok(pending.batch.entries.every(e => e.discardedBy === owner && !e.publicFace)); assert.deepEqual(g.deck,before.deck); assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? f.cards.length * 3 : 0)); assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1); for (const p of g.players) { const projected = viewGame(g,p.id).semutaReaction!; assert.deepEqual(projected.candidates,[]); assert.equal(projected.canCommit,p.id === holder); } const event = pending.batch.event; if (claim) { g = act(g,holder,{type:'semutaCommit',event}); if (g.pendingTreacheryDiscard?.reaction?.stage === 'select') g = act(g,holder,{type:'semutaSelect',event,card:f.cards[0]}); } else for (const p of g.players) g = act(g,p.id,{type:'semutaPass',event}); assert.equal(g.pendingTreacheryDiscard,null); assert.equal(g.pendingAmbassador,null); assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1); assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? f.cards.length * 3 : 0)); assert.equal(player(g,holder).hand.some(c => c.id === f.cards[0]),claim); if (effect === 'ixians') assert.ok(player(g,owner).hand.some(c => c.id === top)); assert.deepEqual(inventory(g),f.original); assert.throws(() => act(g,holder,{type:'semutaCommit',event})); assert.notEqual(g.decision?.kind,'ecazAmbassador'); }); void test('a conserved staged remaining ride is bound to the discard and continues only once', () => { const f = source('ixians',false), before = structuredClone(f.g); // Supplement the real first ride with a conserved queued second ride. const fremen = player(before,rider); assert.ok(fremen.reserves > 0); fremen.reserves--; fremen.forces['hagga_basin:12'] = (fremen.forces['hagga_basin:12'] ?? 0) + 1; before.wormRides.push('hagga_basin'); let pending = act(before,owner,{type:'decision',event:f.event,cards:f.cards}); const event = pending.pendingTreacheryDiscard!.batch.event; assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer'); assert.deepEqual(pending.wormRides,['hagga_basin']); const tampered = structuredClone(pending); tampered.wormRides.push('hagga_basin'); const saved = structuredClone(tampered); assert.throws(() => act(tampered,holder,{type:'semutaPass',event})); assert.deepEqual(tampered,saved); for (const p of pending.players) pending = act(pending,p.id,{type:'semutaPass',event}); assert.equal(pending.pendingTreacheryDiscard,null); assert.deepEqual(pending.decision,{kind:'wormRide',player:rider,territory:'hagga_basin'}); assert.deepEqual(pending.wormRides,[]); pending = act(pending,rider,{type:'decision',accept:false}); assert.notEqual(pending.decision?.kind,'wormRide'); assert.deepEqual(pending.wormRides,[]); assert.equal(player(pending,rider).forces[`${f.destination}:${f.sector}`],1); });