import assert from 'node:assert/strict'; import test from 'node:test'; import { viewGame, type Game } from '../game/engine'; import { territory } from '../game/board'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { selectedDiscoverySkillsStart } from './selected-discovery-skills-fixture'; import { actBasicSemutaAmbassador as act, nextBasicSemutaAmbassadorStep, basicSemutaAmbassadorPlayer as player, basicSemutaAmbassadorInventory as inventory } from './fixture-basic-semuta-ambassador'; import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; const owner = 'ecaz', holder = 'guild', rider = 'fremen'; type Effect = 'choam' | 'ixians'; /** The original lobby and Skills setup, real paid Ambassador placement, Spice * Blow, Nexus and Fremen ride. Conserved staging chooses the supplied token, * transfers Semuta and one Fremen force, and orders the unopened Spice deck. */ function source(effect: Effect) { let g = selectedDiscoverySkillsStart({advanced:false, factions:[owner,'richese',rider,holder], expansions:['ecaz','choam']}).game; assert.equal(g.semutaPreview,true); assert.ok(g.discoveries && g.leaderSkills); const packet = g.ecazAmbassadors!; const selected = packet.tokens.find(token => token.effect === effect)!; if (!packet.cohort.includes(selected.id)) { const displaced = packet.tokens.find(token => token.id === packet.cohort[0])!; assert.equal(displaced.zone,'supply'); packet.cohort[0] = selected.id; displaced.zone = 'pool'; selected.zone = 'supply'; } const guild = player(g,holder), semutaIndex = g.richeseCache!.findIndex(card => card.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 fremen = player(g,rider), sourceForce = Object.keys(fremen.forces).find(key => fremen.forces[key] > 0)!; assert.ok(sourceForce); fremen.forces[sourceForce]--; if (!fremen.forces[sourceForce]) delete fremen.forces[sourceForce]; fremen.forces['the_great_flat:15'] = (fremen.forces['the_great_flat:15'] ?? 0) + 1; for (const [position, predicate] of [ [0, (card: Game['spiceDeck'][number]) => 'territory' in card && card.territory === 'the_great_flat'], [1, (card: Game['spiceDeck'][number]) => 'worm' in card && !card.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]]; } const reach = (condition: (game: Game) => boolean) => { for (let n = 0; n < 2000; n++) { if (condition(g)) return; assert.notEqual(g.status,'finished'); const next = g.decision?.kind === 'richeseCache' ? nextRicheseStrongholdsNativeStep(g) : nextBasicSemutaAmbassadorStep(g); assert.ok(next, `Missing action at ${g.status}/${g.turn}/${g.phase}/${g.decision?.kind}`); g = act(g,next.actor,next.action); } throw Error('The mixed game did not reach the worm ride'); }; 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))!; 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'}); assert.equal(g.pendingAmbassador?.stage,'cards'); const cards = player(g,owner).hand.slice(0,effect === 'choam' ? 2 : 1).map(card => card.id); assert.ok(cards.length >= 1); return {g,event,cards,destination,sector,original:inventory(g)}; } for (const effect of ['choam','ixians'] as const) for (const claim of [false,true]) void test(`Basic Discovery and Skills: worm ride ${effect} Semuta ${claim ? 'claim' : 'pass'}`, () => { const f = source(effect), 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(entry => entry.card.id),f.cards); assert.ok(pending.batch.entries.every(entry => entry.discardedBy === owner && !entry.publicFace)); assert.deepEqual(g.deck,before.deck); assert.equal(player(g,owner).spice,spice + (effect === 'choam' ? 3*f.cards.length : 0)); assert.equal(player(g,rider).forces[`${f.destination}:${f.sector}`],1); for (const p of g.players) assert.deepEqual(viewGame(g,p.id).semutaReaction!.candidates,[]); 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' ? 3*f.cards.length : 0)); assert.equal(player(g,holder).hand.some(card => card.id === f.cards[0]),claim); if (effect === 'ixians') assert.ok(player(g,owner).hand.some(card => card.id === top)); assert.deepEqual(inventory(g),f.original); assert.throws(() => act(g,holder,{type:'semutaCommit',event})); assert.notEqual(g.decision?.kind,'ecazAmbassador'); });