import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; import { truthMovementInventory as inventory } from './fixture-truth-movement'; const seat = (game: Game, id: string) => { const player = game.players.find(candidate => candidate.id === id); assert.ok(player, `Missing ${id}`); return player; }; const quiet = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.truthtrance && !game.pendingRevival && !game.pendingTreacheryDiscard; function next(game: Game) { if (game.phase === 5 && game.active === 'emperor' && quiet(game)) return {actor:'emperor', action:{type:'endMovement'} satisfies Action}; return nextTruthMovementNativeEarlyStep(game, {position:'first',method:'onceAround',direction:'clockwise'}); } function reach(game: Game, predicate: (state: Game) => boolean): Game { for (let i = 0; i < 2000; i++) { if (predicate(game)) return game; assert.notEqual(game.status, 'finished'); const step = next(game); assert.ok(step, `No native step at ${game.turn}/${game.phase}/${game.decision?.kind}`); game = act(game, step.actor, step.action); } throw Error('The native game did not reach Revival'); } /** Native Basic setup and first-turn shipment; stage reciprocal alliance and * conserved casualties at the following Revival boundary. A card swap supplies * a real Karama response without creating or destroying a physical card. */ function source(value: 0 | 3, recipient: 'atreides' | 'richese') { let game = createGame(`REVEALREVIVAL${value}${recipient}`, newPlayer('richese','Richese','richese'), false, ['choam']); joinGame(game, newPlayer('atreides','Atreides','atreides')); 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'}); game = reach(game, state => state.status === 'playing'); 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 === 1 && state.phase === 5 && state.active === 'richese' && quiet(state)); const owner = seat(game,'richese'), token = owner.noField!.tokens.find(item => item.value === value); assert.ok(token); assert.ok(owner.noFieldEvent); game = act(game,'richese',{type:'ship',noField:token.id,event:owner.noFieldEvent,territory:'polar_sink',sector:0,allyPayment:0}); game = reach(game, quiet); game = act(game,'richese',{type:'endMovement'}); game = reach(game, state => state.turn === 2 && state.phase === 4 && quiet(state)); const allied = seat(game,recipient), emperor = seat(game,'emperor'); allied.ally = 'emperor'; emperor.ally = recipient; assert.ok(allied.reserves >= 2); allied.reserves -= 2; allied.tanks += 2; if (recipient === 'richese') { // The future paid return must not increase the earlier reveal's pool. allied.tanks += allied.reserves - 1; allied.reserves = 1; } const responder = seat(game,recipient === 'richese' ? 'atreides' : 'richese'); if (!responder.hand.some(card => card.effect === 'karama')) { const zones = [game.deck, game.discard, ...game.players.filter(player => player.id !== responder.id).map(player => player.hand)]; const zone = zones.find(cards => cards.some(card => card.effect === 'karama')); assert.ok(zone); assert.ok(responder.hand.length); const index = zone.findIndex(card => card.effect === 'karama'); [responder.hand[0],zone[index]] = [zone[index],responder.hand[0]]; } const event = seat(game,'richese').noFieldEvent; assert.ok(event); game = act(game,'emperor',{type:'emperorRevival',amount:2}); assert.equal(game.response?.kind,'emperorRevival'); return {game, token:token.id, event, recipient}; } function passResponse(game: Game): Game { for (let i = 0; game.response?.kind === 'emperorRevival' && i < 8; i++) { const player = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); assert.ok(player); game = act(game,player.id,{type:'passResponse'}); } assert.notEqual(game.response?.kind,'emperorRevival'); return game; } for (const value of [0,3] as const) { void test(`Richese ${value} reveal preserves an Emperor extra-revival response and its one-time payment`, () => { const {game,token,event,recipient} = source(value,'atreides'); const response = structuredClone(game.response), cards = inventory(game); const reserves = seat(game,'richese').reserves, forces = seat(game,'richese').forces['polar_sink:0'] ?? 0; const allyReserves = seat(game,recipient).reserves, allyTanks = seat(game,recipient).tanks; const emperorSpice = seat(game,'emperor').spice; assert.equal(viewGame(game,'richese').richeseNoField?.canReveal,true); let after = act(game,'richese',{type:'revealNoField',token,event}); assert.deepEqual(after.response,response); assert.equal(seat(after,'richese').forces['polar_sink:0'] ?? 0,forces + value); assert.equal(seat(after,'richese').reserves,reserves - value); assert.deepEqual(inventory(after),cards); after = passResponse(after); assert.equal(seat(after,'emperor').spice,emperorSpice - 4); assert.equal(seat(after,recipient).reserves,allyReserves + 2); assert.equal(seat(after,recipient).tanks,allyTanks - 2); assert.equal(after.emperorExtra[recipient],2); assert.equal(seat(after,'richese').forces['polar_sink:0'] ?? 0,forces + value); }); } void test('Emperor extra revival to Richese does not replenish the reserves used by the earlier reveal', () => { const {game,token,event} = source(3,'richese'); const response = structuredClone(game.response), reserves = seat(game,'richese').reserves; const tanks = seat(game,'richese').tanks, emperorSpice = seat(game,'emperor').spice; let after = act(game,'richese',{type:'revealNoField',token,event}); assert.deepEqual(after.response,response); assert.equal(reserves,1); assert.equal(seat(after,'richese').reserves,0); assert.equal(seat(after,'richese').tanks,tanks); after = passResponse(after); assert.equal(seat(after,'richese').forces['polar_sink:0'],1); assert.equal(seat(after,'richese').reserves,2); assert.equal(seat(after,'richese').tanks,tanks - 2); assert.equal(seat(after,'emperor').spice,emperorSpice - 4); }); void test('printed Karama cancels the Emperor revival after reveal without undoing it', () => { const {game,token,event,recipient} = source(3,'atreides'); const karama = seat(game,'richese').hand.find(card => card.effect === 'karama'); assert.ok(karama); const emperorSpice = seat(game,'emperor').spice, reserves = seat(game,recipient).reserves, tanks = seat(game,recipient).tanks; let after = act(game,'richese',{type:'revealNoField',token,event}); after = act(after,'richese',{type:'card',mode:'cancel',card:karama.id}); if (after.pendingKull?.stage === 'offer') after = act(after,after.pendingKull.player,{type:'kullDecision',event:after.pendingKull.event,decline:true}); while (after.pendingTreacheryDiscard?.reaction?.stage === 'offer') { const reaction = after.pendingTreacheryDiscard.reaction, batch = after.pendingTreacheryDiscard.batch.event; const responder = after.players.find(player => !reaction.passed.includes(player.id)); assert.ok(responder); after = act(after,responder.id,{type:'semutaPass',event:batch}); } assert.notEqual(after.response?.kind,'emperorRevival'); assert.equal(seat(after,'emperor').spice,emperorSpice); assert.equal(seat(after,recipient).reserves,reserves); assert.equal(seat(after,recipient).tanks,tanks); assert.equal(after.emperorExtra[recipient] ?? 0,0); assert.equal(seat(after,'richese').noField?.deployed,null); assert.equal(seat(after,'richese').forces['polar_sink:0'],3); assert.equal(after.discard.filter(card => card.id === karama.id).length,1); });