Files
Dune/tests/richese-emperor-revival-interruption.test.ts
T
Quintine a10e019b01
Verify and publish checkpoint container / container (push) Canceled after 0s
Allow Richese reveal during Emperor revival
2026-10-29 18:41:15 +11:00

143 lines
8.1 KiB
TypeScript

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);
});