143 lines
8.1 KiB
TypeScript
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);
|
|
});
|