Files
Dune/tests/richese-homeworld-marker-runtime.test.ts
T

175 lines
12 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 { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { nextIndependentCardModulesStep } from './fixture-independent-card-modules';
const richese = 'richese', opponent = 'atreides', world = 'homeworld:atreides';
const player = (game: Game, id: string) => {
const seat = game.players.find(candidate => candidate.id === id); assert.ok(seat); return seat;
};
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingTreacheryDiscard;
/** Follow only the real native decisions and response queues. The Guild's
* actual Advanced stop opportunity is explicitly allowed and recorded. */
function advance(game: Game, until: (state: Game) => boolean, trace: string[] = []): Game {
let state = game;
for (let count = 0; count < 2000; count++) {
if (until(state)) return state;
assert.notEqual(state.status, 'finished');
if (state.response) trace.push(`response:${state.response.kind}`);
if (state.decision?.kind === 'homeworldShipmentGuild') {
const d = state.decision; trace.push(`decision:${d.kind}`);
state = applyAction(state, d.player, { type: 'decision', event: d.event, allow: true });
continue;
}
const step = nextIndependentCardModulesStep(state);
assert.ok(step, `Native continuation stopped at ${state.phase}/${state.decision?.kind ?? 'no decision'}`);
state = applyAction(state, step.actor, step.action);
}
throw Error('The native Homeworld marker programme did not reach its boundary.');
}
function setup(advanced = true) {
let game = createGame('RICHESEHOMEWORLDMARKER', newPlayer(richese, richese, 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(opponent, opponent, 'atreides'));
joinGame(game, newPlayer('guild', 'guild', 'guild'));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
for (const seat of game.players) game = applyAction(game, seat.id, { type: 'ready' });
// Use the original public start; Advanced explicitly acknowledges preview.
// No phase, resource, army or marker is assigned by the fixture.
game = applyAction(game, game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
// Select one position in the original still-undealt deck, so Guild receives
// a real Karama at its starting-card deal. This makes both the cancel window
// and the Guild stop observable rather than automatically passed.
const karama = game.deck.findIndex(card => card.effect === 'karama');
assert.ok(karama >= 0 && game.deck.length >= 3);
[game.deck[2], game.deck[karama]] = [game.deck[karama], game.deck[2]];
game = advance(game, state => state.status === 'playing');
assert.ok(player(game, 'guild').hand.some(card => card.effect === 'karama'));
return advance(game, state => state.phase === 5 && state.active === richese && clean(state) &&
!!viewGame(state, richese).homeworldShipment && !!viewGame(state, richese).richeseNoField?.canShip);
}
function inventory(game: Game) {
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
return Object.fromEntries(game.players.map(seat => [seat.id, seat.reserves + seat.tanks +
Object.values(seat.forces).reduce((sum, value) => sum + value, 0) + homes
.filter(home => home.native !== seat.id)
.reduce((sum, home) => sum + (home.forces[seat.id]?.normal ?? 0) + (home.forces[seat.id]?.elite ?? 0), 0)]));
}
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} Richese ships its ${value} marker to an actual Homeworld battle, reveals and finishes`, () => {
let game = setup(advanced);
const initial = structuredClone(game), originalInventory = inventory(game);
const own = viewGame(game, richese), marker = own.richeseNoField!.private!.tokens.find(token => token.value === value);
assert.ok(marker); assert.equal(own.homeworldShipment!.blocked, null);
const declaration: Action = { type: 'homeworldShip', event: own.homeworldShipment!.event,
destination: world, sources: {}, noField: marker.id, noFieldEvent: own.richeseNoField!.event, allyPayment: 0 };
game = applyAction(game, richese, declaration);
assert.equal(game.response?.kind, 'richeseNoField');
assert.equal(player(game, richese).spice, player(initial, richese).spice,
'The pending Karama response cannot charge the shipper.');
assert.deepEqual(player(game, richese).noField, player(initial, richese).noField);
const trace: string[] = [];
game = advance(game, state => state.active === richese && state.phase === 5 && clean(state) &&
player(state, richese).shipped, trace);
assert.ok(trace.includes('response:richeseNoField'), 'The original Karama opportunity completed.');
assert.equal(trace.includes('decision:homeworldShipmentGuild'), advanced,
advanced ? 'The original Advanced Guild stop was allowed.' : 'Basic does not grant the Advanced Guild stop.');
assert.equal(player(game, richese).spice, player(initial, richese).spice - 1);
assert.equal(player(game, richese).reserves, player(initial, richese).reserves);
assert.deepEqual(inventory(game), originalInventory);
assert.deepEqual(player(game, richese).noField!.deployed!.location, { homeworld: world });
assert.equal(player(game, richese).noField!.lastShipped, marker.id);
const observer = viewGame(game, opponent).richeseNoField!;
assert.equal(observer.private, null);
assert.deepEqual(observer.public.deployed!.location, { homeworld: world });
assert.equal(JSON.stringify(observer.public).includes(marker.id), false, 'A face-down marker does not publish its identity.');
game = applyAction(game, richese, { type: 'endMovement' });
game = advance(game, state => state.phase === 6 && clean(state) && !state.battle && !!state.active &&
viewGame(state, state.active).battleChoices.some(choice => choice.territory === world));
const chooser = game.active!;
const choice = viewGame(game, chooser).battleChoices.find(candidate => candidate.territory === world)!;
const target = choice.attacker === chooser ? choice.defender : choice.attacker;
game = applyAction(game, chooser, { type: 'chooseBattle', territory: world, target });
game = advance(game, state => !!state.battle && !state.battle.revealed && clean(state) &&
(!state.battle.preLeader || state.battle.preLeader.closed) && !state.battle.preparation);
const event = game.battle!.event;
assert.ok(game.battle!.noFieldPlayers?.includes(richese));
for (const actor of [richese, opponent]) {
game = advance(game, state => !!state.battle && !state.battle.revealed && clean(state) &&
(!state.battle.preLeader || state.battle.preLeader.closed) && !state.battle.preparation);
const leader = player(game, actor).leaders.find(candidate => !candidate.dead &&
(!candidate.usedAt || candidate.usedAt === world));
assert.ok(leader);
game = applyAction(game, actor, { type: 'battlePlan', dial: 0, support: 0, allyPayment: 0,
leader: leader.id, weapon: null, defense: null });
if (actor === richese) assert.ok(player(game, richese).noField!.deployed, 'One seal does not reveal the marker.');
}
assert.equal(game.battle!.revealed, true);
assert.equal(player(game, richese).noField!.deployed, null);
assert.equal(player(game, richese).reserves, player(initial, richese).reserves - value);
assert.equal(game.homeworlds!.custody!.visitors[world]?.[richese]?.normal ?? 0, value);
assert.deepEqual(inventory(game), originalInventory);
game = advance(game, state => !state.battle && state.lastBattleContext?.event === event && clean(state));
assert.deepEqual(inventory(game), originalInventory);
});
}
void test('Karama cancels the proposed Homeworld marker without charging or spending the shipment', () => {
let game = setup();
const before = structuredClone(game), own = viewGame(game, richese);
const token = own.richeseNoField!.private!.tokens.find(candidate => candidate.value === 3)!;
const karama = player(game, 'guild').hand.find(card => card.effect === 'karama')!;
game = applyAction(game, richese, { type: 'homeworldShip', event: own.homeworldShipment!.event,
destination: world, sources: {}, noField: token.id, noFieldEvent: own.richeseNoField!.event, allyPayment: 0 });
assert.equal(game.response?.kind, 'richeseNoField');
game = applyAction(game, 'guild', { type: 'card', mode: 'cancel', card: karama.id });
game = advance(game, state => state.phase === 5 && state.active === richese && clean(state) && !state.pendingHomeworldShipment);
assert.equal(player(game, richese).spice, player(before, richese).spice);
assert.equal(player(game, richese).reserves, player(before, richese).reserves);
assert.equal(player(game, richese).shipped, false);
assert.deepEqual(player(game, richese).noField, player(before, richese).noField);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
assert.deepEqual(inventory(game), inventory(before));
assert.equal(player(game, richese).noFieldBlockedTurn, game.turn);
assert.equal(viewGame(game, richese).richeseNoField!.canShip, false);
const option = viewGame(game, richese).homeworldShipment!;
game = applyAction(game, richese, { type: 'homeworldShip', event: option.event,
destination: world, sources: { 'homeworld:richese': { normal: 1, elite: 0 } }, allyPayment: 0 });
game = advance(game, state => state.phase === 5 && state.active === richese && clean(state) &&
player(state, richese).shipped && !state.pendingHomeworldShipment);
assert.equal(game.homeworlds!.custody!.visitors[world][richese].normal, 1,
'The original physical shipment is still available after preventing the marker.');
assert.equal(player(game, richese).spice, player(before, richese).spice - 1);
assert.deepEqual(inventory(game), inventory(before));
});
void test('Advanced Guild stops the Homeworld marker before payment or arrival while using the shipment', () => {
let game = setup();
const before = structuredClone(game), own = viewGame(game, richese);
const token = own.richeseNoField!.private!.tokens.find(candidate => candidate.value === 5)!;
const karama = player(game, 'guild').hand.find(card => card.effect === 'karama')!;
game = applyAction(game, richese, { type: 'homeworldShip', event: own.homeworldShipment!.event,
destination: world, sources: {}, noField: token.id, noFieldEvent: own.richeseNoField!.event, allyPayment: 0 });
game = advance(game, state => state.decision?.kind === 'homeworldShipmentGuild');
assert.equal(player(game, richese).spice, player(before, richese).spice);
assert.deepEqual(player(game, richese).noField, player(before, richese).noField);
game = applyAction(game, 'guild', { type: 'card', mode: 'special', card: karama.id });
game = advance(game, state => state.phase === 5 && state.active === richese && clean(state) && !state.pendingHomeworldShipment);
assert.equal(player(game, richese).spice, player(before, richese).spice);
assert.equal(player(game, richese).reserves, player(before, richese).reserves);
assert.equal(player(game, richese).shipped, true);
assert.deepEqual(player(game, richese).noField, player(before, richese).noField);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
assert.deepEqual(inventory(game), inventory(before));
});