import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; import { truthMovementInventory as inventory } from './fixture-truth-movement'; const richese = 'richese', tleilaxu = 'tleilaxu', emperor = 'emperor'; const seat = (game: Game, id: string) => { const player = game.players.find(candidate => candidate.id === id); assert.ok(player, `Missing ${id}`); return player; }; const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.truthtrance && !game.pendingRevival && !game.pendingTreacheryDiscard; function next(game: Game) { if (game.turn === 1 && game.phase === 5 && game.active === richese && clean(game)) { const owner = seat(game, richese); if (!owner.shipped) { const token = owner.noField!.tokens.find(item => item.value === 3); assert.ok(token); return { actor:richese, action:{ type:'ship', noField:token.id, event:owner.noFieldEvent, territory:'polar_sink', sector:0, allyPayment:0 } satisfies Action }; } return { actor:richese, action:{type:'endMovement'} satisfies Action }; } if (game.phase === 5 && game.active === emperor && clean(game)) return { actor:emperor, action:{type:'endMovement'} satisfies Action }; return nextTruthMovementNativeEarlyStep(game, {position:'first',method:'onceAround',direction:'clockwise'}); } function reach(game: Game, condition: (state: Game) => boolean): Game { for (let i = 0; i < 2000; i++) { if (condition(game)) return game; assert.notEqual(game.status, 'finished', 'The native game ended before Revival'); const step = next(game); assert.ok(step, `Supply the original action at ${game.turn}/${game.phase}/${game.decision?.kind}`); game = act(game, step.actor, step.action); } throw Error('The native game did not reach the requested Revival boundary'); } /** Genuine mixed-deck setup, real first-turn marker shipment, and real next-turn * revival. One physical Karama is swapped into Emperor's starting hand, and * three Richese counters are moved from reserves to Tanks at the Revival * boundary; both controlled preparations conserve their physical inventories. */ function source() { let game = createGame('RICHESEINCOMEREVEAL', newPlayer(richese, 'Richese', 'richese'), false, ['choam','ix']); joinGame(game, newPlayer(tleilaxu, 'Tleilaxu', 'tleilaxu')); joinGame(game, newPlayer(emperor, 'Emperor', 'emperor')); for (const player of game.players) game = act(game, player.id, {type:'ready'}); game = act(game, game.host, {type:'start'}); assert.equal(game.status, 'setup'); const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 2); const karama = game.deck.find(card => card.effect === 'karama'); assert.equal(ordinary.length, 2); assert.ok(karama); for (const [position, card] of [...ordinary,karama].entries()) { const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; } game = reach(game, state => state.status === 'playing'); assert.ok(seat(game, emperor).hand.some(card => card.id === karama.id)); for (const position of [0,1]) { const from = game.spiceDeck.findIndex((card,index) => index >= position && 'territory' in card && !card.discovery); assert.ok(from >= position); [game.spiceDeck[position],game.spiceDeck[from]] = [game.spiceDeck[from],game.spiceDeck[position]]; } game = reach(game, state => state.turn === 2 && state.phase === 4 && clean(state)); const owner = seat(game, richese), marker = owner.noField?.deployed, event = owner.noFieldEvent; assert.ok(marker); assert.ok(event); assert.ok(owner.reserves >= 3); owner.reserves -= 3; owner.tanks += 3; const before = structuredClone(game), beforeReserves = owner.reserves, beforeTanks = owner.tanks; game = act(game, richese, {type:'revive', amount:2, elite:0}); // The real native request has already returned counters and created the // separate Tleilaxu income response before the No-Field is revealed. assert.equal(seat(game, richese).reserves, beforeReserves + 2); assert.equal(seat(game, richese).tanks, beforeTanks - 2); assert.equal(game.pendingRevival, null); assert.equal(game.response?.kind, 'revivalIncome'); assert.equal(game.response.owner, tleilaxu); assert.equal(game.response.recipient, richese); assert.equal(game.response.amount, 1); assert.ok(seat(game, emperor).hand.some(card => card.id === karama.id)); return {game, before, token:marker.tokenId, event, karama:karama.id, cards:inventory(game)}; } let saved: ReturnType | undefined; const original = () => saved ??= source(); function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); } function passIncome(game: Game): Game { for (let i = 0; game.response?.kind === 'revivalIncome' && i < 8; i++) { const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); game = act(game, actor.id, {type:'passResponse'}); } assert.notEqual(game.response?.kind, 'revivalIncome'); return game; } void test('Richese reveals after its real returned forces while native Tleilaxu income remains pending', () => { const {game,token,event,cards} = original(), response = structuredClone(game.response), reserves = seat(game, richese).reserves, tanks = seat(game, richese).tanks, field = seat(game, richese).forces['polar_sink:0'] ?? 0, income = seat(game, tleilaxu).spice; assert.equal(viewGame(game, richese).richeseNoField?.canReveal, true); reject(game, emperor, {type:'revealNoField',token,event}); reject(game, richese, {type:'revealNoField',token,event:'stale'}); let after = act(game, richese, {type:'revealNoField',token,event}); assert.deepEqual(after.response, response); assert.equal(seat(after, richese).reserves, reserves - 3); assert.equal(seat(after, richese).forces['polar_sink:0'], field + 3); assert.equal(seat(after, richese).tanks, tanks); assert.equal(seat(after, richese).noField?.deployed, null); assert.deepEqual(inventory(after), cards); reject(after, richese, {type:'revealNoField',token,event}); after = passIncome(after); assert.equal(seat(after, tleilaxu).spice, income + 1); assert.equal(seat(after, richese).tanks, tanks); assert.equal(seat(after, richese).forces['polar_sink:0'], field + 3); assert.deepEqual(inventory(after), cards); }); void test('Karama cancels the original income after reveal without undoing revival or materializing twice', () => { const {game,token,event,karama} = original(), income = seat(game, tleilaxu).spice, tanks = seat(game, richese).tanks, reserves = seat(game, richese).reserves; let after = act(game, richese, {type:'revealNoField',token,event}); after = act(after, emperor, {type:'card',card:karama,mode:'cancel'}); after = passIncome(after); assert.equal(seat(after, tleilaxu).spice, income); assert.equal(seat(after, richese).tanks, tanks); assert.equal(seat(after, richese).reserves, reserves - 3); assert.equal(seat(after, richese).forces['polar_sink:0'], 3); assert.equal(after.discard.filter(card => card.id === karama).length, 1); }); void test('every minimal bot profile can continue the original income response after reveal', () => { const {game,token,event} = original(); const revealed = act(game, richese, {type:'revealNoField',token,event}); for (const difficulty of DIFFICULTIES) { const view = viewGame(revealed, emperor); view.players.find(player => player.id === emperor)!.bot = difficulty; const action = botActions(view).find(candidate => candidate.type === 'passResponse' || candidate.type === 'card' && candidate.mode === 'cancel'); assert.ok(action, difficulty); const continued = applyAction(revealed, emperor, action); assert.ok(continued.response?.kind !== 'revivalIncome' || continued.response.passed.includes(emperor) || continued.pendingKarama || continued.pendingTreacheryDiscard, difficulty); assert.equal(seat(continued, richese).forces['polar_sink:0'], 3); } });