Files
Dune/tests/richese-reveal-revival-income-runtime.test.ts
T
Quintine 3dbd326891
Verify and publish checkpoint container / container (push) Successful in 11m10s
Expand Basic No-Field reveal response timing
2026-10-29 09:47:39 +11:00

153 lines
8.4 KiB
TypeScript

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