130 lines
8.0 KiB
TypeScript
130 lines
8.0 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
|
import { homeworldContext } from '../game/homeworld-game';
|
|
import { homeworldForceGroups } from '../game/homeworld-custody';
|
|
import { homeworldShipmentActions, richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
|
|
import { nextIndependentCardModulesStep } from './fixture-independent-card-modules';
|
|
|
|
const owner = 'richese', opponent = 'atreides', guild = 'guild';
|
|
const origin = 'homeworld:richese', destination = 'homeworld:atreides';
|
|
function seat(game: Game, id: string) {
|
|
const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player;
|
|
}
|
|
const quiet = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
|
|
!game.truthtrance && !game.pendingTreacheryDiscard;
|
|
|
|
/** Follow the actual native phase decisions and allow the real Advanced Guild
|
|
* interception. No saved phase, played card, reserve or marker is staged. */
|
|
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 decision = state.decision; trace.push(`decision:${decision.kind}`);
|
|
state = applyAction(state, decision.player, {type: 'decision', event: decision.event, allow: true});
|
|
continue;
|
|
}
|
|
const next = nextIndependentCardModulesStep(state);
|
|
assert.ok(next, `Native continuation stopped at ${state.phase}/${state.decision?.kind ?? 'no decision'}.`);
|
|
state = applyAction(state, next.actor, next.action);
|
|
}
|
|
throw Error('The real mixed Homeworld shipment did not reach its boundary.');
|
|
}
|
|
|
|
/** Original public Homeworld lobby, selecting just one undealt Karama position
|
|
* so Guild can actually exercise the marker response and Advanced stop. */
|
|
function setup(advanced: boolean): Game {
|
|
let game = createGame('RICHESENATIVEFRESHMIX', newPlayer(owner, owner, 'richese'), advanced, ['choam']);
|
|
joinGame(game, newPlayer(opponent, opponent, 'atreides'));
|
|
joinGame(game, newPlayer(guild, guild, 'guild'));
|
|
game = applyAction(game, owner, {type: 'homeworlds', enabled: true});
|
|
for (const player of game.players) game = applyAction(game, player.id, {type: 'ready'});
|
|
game = applyAction(game, owner, advanced ? {type: 'start', advancedPreview: true} : {type: 'start'});
|
|
const card = game.deck.findIndex(candidate => candidate.effect === 'karama');
|
|
assert.ok(card >= 0 && game.deck.length >= 3);
|
|
[game.deck[2], game.deck[card]] = [game.deck[card], game.deck[2]];
|
|
game = advance(game, state => state.status === 'playing');
|
|
assert.ok(seat(game, guild).hand.some(card => card.effect === 'karama'));
|
|
return advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
|
!!viewGame(state, owner).homeworldShipment && !!viewGame(state, owner).richeseNoField?.canShip);
|
|
}
|
|
|
|
function inventory(game: Game) {
|
|
const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
|
return Object.fromEntries(game.players.map(player => [player.id, player.reserves + player.tanks +
|
|
Object.values(player.forces).reduce((sum, value) => sum + value, 0) + homes
|
|
.filter(home => home.native !== player.id)
|
|
.reduce((sum, home) => sum + (home.forces[player.id]?.normal ?? 0) + (home.forces[player.id]?.elite ?? 0), 0)]));
|
|
}
|
|
function declaration(game: Game) {
|
|
const own = viewGame(game, owner), info = own.richeseNoField!;
|
|
const token = info.private!.tokens.find(candidate => candidate.value === 3); assert.ok(token);
|
|
const selected = richeseHomeworldMarkerChoice(own, destination, token.id, 0, undefined, {normal: 2, elite: 0});
|
|
assert.equal(selected.amount, 3); assert.equal(selected.cost, 3); assert.ok(selected.action);
|
|
return {action: selected.action, token: token.id};
|
|
}
|
|
|
|
for (const advanced of [false, true]) {
|
|
void test(`${advanced ? 'Advanced' : 'Basic'} fresh marker and reserve companions use one paid shipment`, () => {
|
|
let game = setup(advanced);
|
|
const before = structuredClone(game), originalInventory = inventory(game), {action, token} = declaration(game);
|
|
assert.ok(seat(before, owner).spice >= 3);
|
|
for (const stale of [{...action, noFieldEvent: 'stale-marker'}, {...action, event: 'stale-shipment'}]) {
|
|
assert.throws(() => applyAction(game, owner, stale)); assert.deepEqual(game, before);
|
|
}
|
|
game = applyAction(game, owner, action);
|
|
assert.equal(game.response?.kind, 'richeseNoField');
|
|
assert.equal(seat(game, owner).spice, seat(before, owner).spice);
|
|
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
|
|
const trace: string[] = [];
|
|
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) &&
|
|
seat(state, owner).shipped && !state.pendingHomeworldShipment, trace);
|
|
assert.ok(trace.includes('response:richeseNoField'));
|
|
assert.equal(trace.includes('decision:homeworldShipmentGuild'), advanced);
|
|
assert.equal(seat(game, owner).spice, seat(before, owner).spice - 3);
|
|
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves - 2);
|
|
assert.deepEqual(game.homeworlds!.custody!.visitors[destination]?.[owner], {normal: 2, elite: 0});
|
|
assert.deepEqual(seat(game, owner).noField!.deployed, {tokenId: token, controller: owner, location: {homeworld: destination}});
|
|
assert.equal(seat(game, owner).noField!.lastShipped, token);
|
|
assert.deepEqual(inventory(game), originalInventory);
|
|
const other = viewGame(game, opponent).richeseNoField!;
|
|
assert.equal(other.private, null); assert.equal(JSON.stringify(other.public).includes(token), false);
|
|
});
|
|
|
|
void test(`${advanced ? 'Advanced' : 'Basic'} Karama cancellation preserves the fresh mixed source and unused shipment`, () => {
|
|
let game = setup(advanced);
|
|
const before = structuredClone(game), {action} = declaration(game);
|
|
const karama = seat(game, guild).hand.find(card => card.effect === 'karama'); assert.ok(karama);
|
|
game = applyAction(game, owner, action);
|
|
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 === owner && quiet(state) && !state.pendingHomeworldShipment);
|
|
assert.equal(seat(game, owner).spice, seat(before, owner).spice);
|
|
assert.equal(seat(game, owner).reserves, seat(before, owner).reserves);
|
|
assert.equal(seat(game, owner).shipped, false);
|
|
assert.deepEqual(seat(game, owner).noField, seat(before, owner).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));
|
|
const option = viewGame(game, owner).homeworldShipment!;
|
|
game = applyAction(game, owner, {type: 'homeworldShip', event: option.event, destination,
|
|
sources: {[origin]: {normal: 1, elite: 0}}, allyPayment: 0});
|
|
game = advance(game, state => state.phase === 5 && state.active === owner && quiet(state) && seat(state, owner).shipped);
|
|
assert.equal(game.homeworlds!.custody!.visitors[destination][owner].normal, 1);
|
|
assert.equal(seat(game, owner).spice, seat(before, owner).spice - 1);
|
|
});
|
|
}
|
|
|
|
void test('all minimal bot profiles offer native legal marker candidates', () => {
|
|
const game = setup(false), view = viewGame(game, owner);
|
|
for (const difficulty of [0, 1, 2, 3]) {
|
|
const action = homeworldShipmentActions(view, difficulty).find(candidate => candidate.noField !== undefined &&
|
|
(candidate.sources as Record<string, {normal: number; elite: number}> | undefined)?.['homeworld:richese']?.normal === 1);
|
|
assert.ok(action, `Profile ${difficulty} must offer a marker with legal reserve companions when eligible.`);
|
|
assert.doesNotThrow(() => applyAction(game, owner, action));
|
|
}
|
|
});
|