153 lines
8.4 KiB
TypeScript
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);
|
|
}
|
|
});
|