import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board'; import { bribeAction } from '../game/bribe-options'; import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; import { truthMovementInventory } from './fixture-truth-movement'; type Kind = 'fremenMovement' | 'ixMovement' | 'choamMovement'; const seat = (g: Game, id: string) => { const p = g.players.find(p => p.id === id); assert.ok(p); return p; }; const clean = (g: Game) => !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingTreacheryDiscard; const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); const cards = (g: Game) => truthMovementInventory(g); /** Select an existing physical card by a custody-preserving swap after the * actual starting deal. This controls only who can answer the test window. */ function swapHeld(g: Game, id: string, predicate: (c: Game['deck'][number]) => boolean): string { const target = seat(g, id); const held = target.hand.find(predicate); if (held) return held.id; assert.ok(target.hand.length); const index = g.deck.findIndex(predicate); if (index >= 0) { [target.hand[0], g.deck[index]] = [g.deck[index], target.hand[0]]; return target.hand[0].id; } for (const other of g.players) { if (other.id === id) continue; const at = other.hand.findIndex(predicate); if (at >= 0) { [target.hand[0], other.hand[at]] = [other.hand[at], target.hand[0]]; return target.hand[0].id; } } throw Error('The original card must exist in the native deal or deck'); } /** All pending movement/decisions come from actual player actions. The only * controlled inputs are unopened Spice cards, first Storm dials and conserved * card swaps. Richese's marker is actually shipped on its own turn. */ function fixture(kind: Kind, keepMarker: boolean) { const value = 3, markerAtDestination = false; assert.ok(!markerAtDestination || kind === 'fremenMovement'); const mover = kind === 'fremenMovement' ? 'fremen' : kind === 'ixMovement' ? 'ixians' : 'emperor'; const expansions = kind === 'ixMovement' ? ['choam', 'ix'] : ['choam']; let g = createGame(`REVEAL-${kind}-${value}`, newPlayer('richese', 'Richese', 'richese'), false, expansions); if (kind === 'choamMovement') joinGame(g, newPlayer('choam', 'CHOAM', 'choam')); else joinGame(g, newPlayer(mover, mover, mover)); joinGame(g, newPlayer('emperor', 'Emperor', 'emperor')); for (const p of g.players) g = act(g, p.id, {type:'ready'}); g = act(g, g.host, {type:'start'}); assert.equal(g.status, 'setup'); if (kind === 'ixMovement') { // The native Ix setup driver chooses a Shield from the inspected packet. // Move the original unopened card to its first draw position. const shield = g.deck.findIndex(card => card.kind === 'shield'); assert.ok(shield >= 0); [g.deck[0], g.deck[shield]] = [g.deck[shield], g.deck[0]]; } const next = (state: Game) => { const fremen = state.players.find(p => p.faction === 'fremen'); if (state.status === 'setup' && state.setupStage === 'forces' && fremen?.reserves === 20 && viewGame(state, fremen.id).setupPending.includes(fremen.id)) return {actor:fremen.id, action:{type:'fremenSetup', placements:{'sietch_tabr:14':10}} satisfies Action}; if (state.phase === 5 && state.active === 'richese' && clean(state) && !seat(state,'richese').shipped && !seat(state,'richese').noField?.deployed) { const owner = seat(state,'richese'), marker = owner.noField!.tokens.find(t => t.value === value); assert.ok(marker && owner.noFieldEvent); const target = markerAtDestination ? TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s))) .find(key => splitLocation(key).sector > 7 && state.players.every(p => !(p.forces[key] > 0)) && gameDistance(state,'sietch_tabr:14',key,k => splitLocation(k).sector === state.storm) === 2 && gameDistance(state,'sietch_tabr:14',key,k => splitLocation(k).sector === (state.storm % 18) + 1) === 2) : 'polar_sink:0'; assert.ok(target, 'The original Fremen position has a safe distance-two marker destination'); return {actor:'richese', action:{type:'ship', noField:marker.id, event:owner.noFieldEvent, ...splitLocation(target), allyPayment:0} satisfies Action}; } if (kind === 'choamMovement' && state.turn === 1 && state.phase === 5 && state.active === 'choam' && clean(state) && !seat(state,'choam').shipped) return {actor:'choam', action:{type:'ship', territory:'arrakeen', sector:10, amount:1, elite:0, allyPayment:0} satisfies Action}; if (state.phase === 5 && state.active === 'emperor' && clean(state)) return {actor:'emperor', action:{type:'endMovement'} satisfies Action}; const step = nextTruthMovementNativeEarlyStep(state); assert.ok(step, `Missing native continuation ${state.status}/${state.turn}/${state.phase}/${state.decision?.kind}`); if (step.action.type === 'stormDial' && state.turn === 1) return {...step, action:{...step.action, amount:0}}; return step; }; const reach = (until: (s: Game) => boolean) => { for (let n = 0; n < 2400 && !until(g); n++) { assert.notEqual(g.status, 'finished'); const step = next(g); g = act(g, step.actor, step.action); } assert.ok(until(g), 'The real game reached the requested native boundary'); }; reach(state => state.status === 'playing'); const karama = swapHeld(g, 'emperor', c => c.effect === 'karama'); const baliset = kind === 'choamMovement' ? swapHeld(g, 'choam', c => c.name === 'Baliset') : undefined; for (let pos = 0; pos < 2; pos++) { const at = g.spiceDeck.findIndex((c, index) => index >= pos && 'territory' in c && !c.discovery); assert.ok(at >= pos); [g.spiceDeck[pos], g.spiceDeck[at]] = [g.spiceDeck[at], g.spiceDeck[pos]]; } reach(state => state.turn === 2 && state.phase === 5 && state.active === mover && clean(state) && !!seat(state,'richese').noField?.deployed); const marker = seat(g,'richese').noField!.deployed!.tokenId; const event = seat(g,'richese').noFieldEvent; assert.ok(event); let origin: string; if (kind === 'fremenMovement') { origin = 'sietch_tabr:14'; assert.ok((seat(g,mover).forces[origin] ?? 0) > 0); } else if (kind === 'choamMovement') { origin = 'carthag:11'; g = act(g,mover,{type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0}); reach(clean); assert.ok((seat(g,'choam').forces['arrakeen:10'] ?? 0) > 0); } else { origin = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s))) .find(key => splitLocation(key).sector !== g.storm && g.players.every(p => !(p.forces[key] > 0)) && TERRITORIES.some(t => t.type === 'sand' && t.sectors.some(s => s !== g.storm && gameDistance(g,key,location(t.id,s),k => splitLocation(k).sector === g.storm) === 2)))!; assert.ok(origin); g = act(g,mover,{type:'ship', ...splitLocation(origin), amount:1, elite:1, allyPayment:0}); reach(clean); } const markerLocation = seat(g,'richese').noField!.deployed!.location; const destination = markerAtDestination && 'territory' in markerLocation ? location(markerLocation.territory,markerLocation.sector) : kind === 'choamMovement' ? 'arrakeen:10' : TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => location(t.id,s))) .find(key => splitLocation(key).sector !== g.storm && g.players.every(p => !(p.forces[key] > 0)) && gameDistance(g,origin,key,k => splitLocation(k).sector === g.storm) === 2)!; assert.ok(destination); // Also exercise movement without a deployed No-Field, using its real reveal. if (!keepMarker) g = act(g,'richese',{type:'revealNoField', token:marker, event}); const beforeMove = structuredClone(g); const move: Action = {type:'move', forces:{[origin]:1}, ...(kind === 'ixMovement' ? {eliteForces:{[origin]:1}} : {}), ...splitLocation(destination)}; g = act(g,mover,move); assert.equal(kind === 'choamMovement' ? g.decision?.kind : g.response?.kind, kind); assert.deepEqual(seat(g,mover).forces, seat(beforeMove,mover).forces); return {game:g, mover, marker, event, karama, baliset, value, origin, destination, beforeMove}; } function finishPending(g: Game) { for (let n = 0; n < 40; n++) { if (g.pendingKull?.stage === 'offer') { g = act(g,g.pendingKull.player,{type:'kullDecision',event:g.pendingKull.event,decline:true}); continue; } if (g.pendingTreacheryDiscard) { const reaction = g.pendingTreacheryDiscard.reaction; assert.ok(reaction?.stage === 'offer'); const actor = g.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor); g = act(g,actor.id,{type:'semutaPass',event:g.pendingTreacheryDiscard.batch.event}); continue; } if (g.response) { const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor); g = act(g,actor.id,{type:'passResponse'}); continue; } if (g.decision?.kind === 'choamMovement') { g = act(g,g.decision.player,{type:'decision',decline:true}); continue; } return g; } throw Error('The original movement response did not finish'); } const cached = new Map>(); function source(kind: Kind, keepMarker: boolean) { const key = `${kind}:${keepMarker}`; if (!cached.has(key)) cached.set(key,fixture(kind,keepMarker)); return cached.get(key)!; } function rejectUnchanged(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before); } function pending(game: Game, kind: Kind) { return kind === 'fremenMovement' ? game.pendingFremenMove : kind === 'ixMovement' ? game.pendingIxMove : game.pendingChoamMove; } function pay(g: Game, kind: Kind) { const payer = 'emperor', recipient = 'richese', before = structuredClone(g); const options = viewGame(g,payer).bribeOptions; assert.equal(options.blocked,null); const candidate = options.targets.find(target => target.id === recipient); assert.ok(candidate && candidate.maximum >= 1); const action = bribeAction(options,recipient,1); assert.deepEqual(action,{type:'bribe',target:recipient,amount:1}); rejectUnchanged(g,payer,{type:'bribe',target:payer,amount:1}); rejectUnchanged(g,payer,{type:'bribe',target:recipient,amount:options.available+1}); const after = act(g,payer,action!); assert.deepEqual(pending(after,kind),pending(before,kind)); assert.deepEqual(after.response,before.response); assert.deepEqual(after.decision,before.decision); assert.equal(seat(after,payer).spice,seat(before,payer).spice-1); assert.equal(seat(after,recipient).spice,seat(before,recipient).spice); assert.equal(seat(after,recipient).bribes,seat(before,recipient).bribes+1); assert.deepEqual(cards(after),cards(before)); if (candidate.maximum === options.available) { const exhausted = act(before,payer,{type:'bribe',target:recipient,amount:candidate.maximum}); assert.equal(viewGame(exhausted,payer).bribeOptions.available,0); rejectUnchanged(exhausted,payer,{type:'bribe',target:recipient,amount:1}); } return after; } for (const kind of ['fremenMovement','ixMovement','choamMovement'] as const) { for (const keepMarker of [false,true]) void test(`Basic ${kind} keeps the actual move through a bribe with ${keepMarker ? 'a deployed' : 'no deployed'} marker`, () => { const f = source(kind,keepMarker), before = structuredClone(f.game); assert.equal(!!seat(before,'richese').noField?.deployed,keepMarker); const after = pay(before,kind); assert.deepEqual(seat(after,f.mover).forces,seat(f.beforeMove,f.mover).forces); const control = finishPending(reload(before)), done = finishPending(reload(after)); assert.deepEqual(seat(done,f.mover).forces,seat(control,f.mover).forces); assert.equal(seat(done,f.mover).moved,seat(control,f.mover).moved); assert.equal(seat(done,f.mover).forces[f.destination],(seat(f.beforeMove,f.mover).forces[f.destination] ?? 0)+1); assert.equal(seat(done,f.mover).forces[f.origin] ?? 0,(seat(f.beforeMove,f.mover).forces[f.origin] ?? 0)-1); assert.equal(seat(done,'richese').bribes,seat(before,'richese').bribes+1); }); void test(`Basic ${kind} still allows the original cancellation and bot responses after a bribe`, () => { const f = source(kind,true), before = structuredClone(f.game), after = pay(before,kind); const actor = kind === 'choamMovement' ? 'choam' : 'emperor'; for (const difficulty of DIFFICULTIES) { const view = viewGame(after,actor); view.players.find(p => p.id === actor)!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action,difficulty); assert.doesNotThrow(() => act(reload(after),actor,action)); } let cancelled = kind === 'choamMovement' ? act(after,'choam',{type:'card',card:f.baliset,mode:'choam',target:f.mover,territory:splitLocation(f.destination).territory}) : act(after,'emperor',{type:'card',card:f.karama,mode:'cancel'}); cancelled = finishPending(cancelled); assert.deepEqual(seat(cancelled,f.mover).forces,seat(f.beforeMove,f.mover).forces); assert.equal(seat(cancelled,f.mover).moved,seat(f.beforeMove,f.mover).moved); assert.equal(seat(cancelled,'richese').bribes,seat(before,'richese').bribes+1); }); }