659 lines
24 KiB
TypeScript
659 lines
24 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeHomeworldGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { baseDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import {
|
|
homeworldContext,
|
|
homeworldGameIntegrity,
|
|
} from '../game/homeworld-game';
|
|
import {
|
|
homeworldForceGroups,
|
|
quoteHomeworldCustody,
|
|
type HomeworldCustodyChange,
|
|
type HomeworldForces,
|
|
} from '../game/homeworld-custody';
|
|
|
|
const seat = (g: Game, id: string) =>
|
|
g.players.find((player) => player.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
const sum = (counts: Record<string, number>) =>
|
|
Object.values(counts).reduce((a, b) => a + b, 0);
|
|
|
|
function inventory(g: Game, supplementalCards = 0) {
|
|
homeworldGameIntegrity(g);
|
|
const groups = homeworldForceGroups(
|
|
homeworldContext(g),
|
|
g.homeworlds!.custody!,
|
|
);
|
|
for (const player of g.players) {
|
|
const visitors = groups
|
|
.filter((home) => home.native !== player.id)
|
|
.map((home) => home.forces[player.id] ?? { normal: 0, elite: 0 });
|
|
assert.equal(
|
|
player.reserves +
|
|
player.tanks +
|
|
sum(player.forces) +
|
|
visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0),
|
|
20,
|
|
);
|
|
if (player.elites)
|
|
assert.equal(
|
|
player.elites.reserves +
|
|
player.elites.tanks +
|
|
sum(player.elites.forces) +
|
|
visitors.reduce((n, pool) => n + pool.elite, 0),
|
|
player.faction === 'emperor' ? 5 : player.faction === 'fremen' ? 3 : 7,
|
|
);
|
|
}
|
|
const cards = [
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((player) => player.hand),
|
|
];
|
|
assert.equal(cards.length, baseDeck().length + supplementalCards);
|
|
assert.equal(new Set(cards.map((card) => card.id)).size, cards.length);
|
|
}
|
|
function setup(roster: FactionId[], advanced = false) {
|
|
let g = createGame(
|
|
'HOMEWORLDBATTLEENGINE',
|
|
newPlayer(roster[0], roster[0], roster[0]),
|
|
advanced,
|
|
);
|
|
for (const faction of roster.slice(1))
|
|
joinGame(g, newPlayer(faction, faction, faction));
|
|
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
|
|
for (const player of g.players)
|
|
g = applyAction(g, player.id, { type: 'ready' });
|
|
g = initializeHomeworldGameForAudit(g);
|
|
for (let step = 0; g.status === 'setup' && step < 30; step++) {
|
|
let next: Game | undefined;
|
|
for (const player of g.players) {
|
|
const view = viewGame(g, player.id);
|
|
view.players.find((owner) => owner.id === player.id)!.bot = 'Easy';
|
|
const action = botActions(view)[0];
|
|
if (action) {
|
|
next = applyAction(g, player.id, action);
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(next, 'Genuine audit setup must have a legal decision.');
|
|
g = next;
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
for (const player of g.players) g.deck.push(...player.hand.splice(0));
|
|
Object.assign(g, {
|
|
phase: 6,
|
|
phaseOpening: null,
|
|
response: null,
|
|
decision: null,
|
|
active: roster[0],
|
|
order: roster,
|
|
ready: [],
|
|
storm: 18,
|
|
});
|
|
inventory(g);
|
|
return g;
|
|
}
|
|
function custody(g: Game, changes: HomeworldCustodyChange[]) {
|
|
const quote = quoteHomeworldCustody(
|
|
homeworldContext(g),
|
|
g.homeworlds!.custody!,
|
|
changes,
|
|
);
|
|
g.homeworlds!.custody = quote.state;
|
|
for (const update of quote.players) {
|
|
const player = seat(g, update.id);
|
|
player.reserves = update.reserves;
|
|
if (player.elites) player.elites.reserves = update.eliteReserves;
|
|
}
|
|
inventory(g);
|
|
}
|
|
function invade(
|
|
g: Game,
|
|
player: string,
|
|
destination: string,
|
|
normal: number,
|
|
elite = 0,
|
|
) {
|
|
// Explicit conserved invasion position, not a claim that invasion shipment
|
|
// actions are open: withdraw the exact native pieces before depositing them.
|
|
custody(g, [
|
|
{
|
|
homeworld: `homeworld:${seat(g, player).faction}`,
|
|
player,
|
|
withdraw: { normal, elite },
|
|
deposit: { normal: 0, elite: 0 },
|
|
},
|
|
{
|
|
homeworld: destination,
|
|
player,
|
|
withdraw: { normal: 0, elite: 0 },
|
|
deposit: { normal, elite },
|
|
},
|
|
]);
|
|
}
|
|
function hold(g: Game, player: string, kind: string) {
|
|
const index = g.deck.findIndex((card) => card.kind === kind);
|
|
assert.ok(index >= 0);
|
|
const [card] = g.deck.splice(index, 1);
|
|
seat(g, player).hand.push(card);
|
|
return card.id;
|
|
}
|
|
function prepared(state: Game, actor: string, target: string, home: string) {
|
|
let g = applyAction(state, actor, {
|
|
type: 'chooseBattle',
|
|
territory: home,
|
|
target,
|
|
});
|
|
for (
|
|
let step = 0;
|
|
(g.response || g.battle?.preparation || g.decision) && step < 30;
|
|
step++
|
|
) {
|
|
if (g.response) {
|
|
const owner = g.players.find(
|
|
(player) => !g.response!.passed.includes(player.id),
|
|
)!;
|
|
g = applyAction(g, owner.id, { type: 'passResponse' });
|
|
} else if (g.battle?.preparation)
|
|
g = applyAction(g, g.battle.preparation.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
else {
|
|
assert.equal(g.decision!.kind, 'fullPlanOffer');
|
|
g = applyAction(g, g.decision!.player, {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
}
|
|
}
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.decision, null);
|
|
assert.equal(g.battle!.territory, home);
|
|
assert.equal(g.battle!.preparation ?? null, null);
|
|
inventory(g);
|
|
return g;
|
|
}
|
|
function commit(
|
|
g: Game,
|
|
player: string,
|
|
dial = 0,
|
|
extras: Partial<Action> = {},
|
|
) {
|
|
const leader =
|
|
seat(g, player).leaders.find((leader) => leader.strength === 1) ??
|
|
seat(g, player).leaders[0];
|
|
return applyAction(g, player, {
|
|
type: 'battlePlan',
|
|
dial,
|
|
support: 0,
|
|
leader: leader.id,
|
|
...extras,
|
|
});
|
|
}
|
|
function reject(g: Game, player: string, action: Action) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, player, action));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
function explosionAction(g: Game, choice: number): Action {
|
|
return { type: 'decision', event: g.homeworldBattleLoss!.event, choice };
|
|
}
|
|
function legalBots(g: Game, owner: string, supplementalCards = 0) {
|
|
const candidates: Action[] = [];
|
|
for (const level of DIFFICULTIES) {
|
|
const view = viewGame(reload(g), owner);
|
|
view.players.find((player) => player.id === owner)!.bot = level;
|
|
const before = structuredClone(view);
|
|
const actions = botActions(view);
|
|
assert.ok(
|
|
actions.length,
|
|
`${level} must supply the pending Homeworld choice.`,
|
|
);
|
|
assert.deepEqual(view, before);
|
|
for (const action of actions) {
|
|
assert.equal(action.type, 'decision');
|
|
const snapshot = structuredClone(g);
|
|
const done = applyAction(g, owner, action);
|
|
assert.deepEqual(g, snapshot);
|
|
inventory(done, supplementalCards);
|
|
candidates.push(action);
|
|
}
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
void test('real Basic Homeworld battle awards native strength without treating the printed bonus as force casualties', () => {
|
|
let g = setup(['atreides', 'guild']);
|
|
invade(g, 'atreides', 'homeworld:guild', 3);
|
|
const before = structuredClone(g);
|
|
g = prepared(g, 'atreides', 'guild', 'homeworld:guild');
|
|
assert.deepEqual(viewGame(g, 'atreides').battle!.traitorVoters, ['guild']);
|
|
g = commit(g, 'atreides', 1);
|
|
assert.deepEqual(viewGame(reload(g), 'guild').battle!.plans, {});
|
|
g = commit(reload(g), 'guild');
|
|
reject(g, 'atreides', { type: 'traitorCall', call: false });
|
|
g = applyAction(reload(g), 'guild', { type: 'traitorCall', call: false });
|
|
assert.equal(g.lastBattleContext!.winner, 'guild');
|
|
assert.equal(g.lastBattleContext!.territory, 'homeworld:guild');
|
|
assert.equal(seat(g, 'guild').reserves, seat(before, 'guild').reserves);
|
|
assert.equal(seat(g, 'guild').tanks, 0);
|
|
assert.equal(seat(g, 'atreides').tanks, 3);
|
|
assert.equal(
|
|
g.homeworlds!.custody!.visitors['homeworld:guild']?.atreides,
|
|
undefined,
|
|
);
|
|
assert.deepEqual(
|
|
g.players.map((player) => player.forces),
|
|
before.players.map((player) => player.forces),
|
|
);
|
|
inventory(g);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
|
|
});
|
|
|
|
void test('two foreign armies at an empty native home resolve automatically after reveal with no fabricated traitor voter', () => {
|
|
let g = setup(['atreides', 'guild', 'harkonnen']);
|
|
invade(g, 'atreides', 'homeworld:harkonnen', 3);
|
|
invade(g, 'guild', 'homeworld:harkonnen', 3);
|
|
const native = seat(g, 'harkonnen');
|
|
native.tanks += native.reserves;
|
|
native.reserves = 0;
|
|
inventory(g);
|
|
g = prepared(g, 'atreides', 'guild', 'homeworld:harkonnen');
|
|
assert.deepEqual(viewGame(g, 'atreides').battle!.traitorVoters, []);
|
|
g = commit(g, 'atreides', 1);
|
|
const saved = reload(g);
|
|
assert.deepEqual(viewGame(saved, 'guild').battle!.plans, {});
|
|
assert.deepEqual(viewGame(saved, 'harkonnen').battle!.plans, {});
|
|
g = commit(saved, 'guild');
|
|
assert.equal(
|
|
g.battle,
|
|
null,
|
|
'No player needs to submit a nonexistent native traitor vote.',
|
|
);
|
|
assert.equal(g.lastBattleContext!.winner, 'atreides');
|
|
assert.equal(seat(g, 'atreides').tanks, 1);
|
|
assert.equal(seat(g, 'guild').tanks, 3);
|
|
assert.deepEqual(
|
|
g.homeworlds!.custody!.visitors['homeworld:harkonnen']!.atreides,
|
|
{ normal: 2, elite: 0 },
|
|
);
|
|
assert.equal(seat(g, 'harkonnen').reserves, 0);
|
|
inventory(g);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
|
|
});
|
|
|
|
void test('ordered loser elimination records a sole foreign qualifier before the Basic winner loses its entire dialed army', () => {
|
|
let g = setup(['atreides', 'guild', 'harkonnen']);
|
|
assert.equal(g.homeworlds!.historyVersion, 1);
|
|
assert.ok(g.homeworldOccupationHistory);
|
|
const world = 'homeworld:harkonnen';
|
|
invade(g, 'atreides', world, 3);
|
|
invade(g, 'guild', world, 3);
|
|
seat(g, 'harkonnen').tanks += seat(g, 'harkonnen').reserves;
|
|
seat(g, 'harkonnen').reserves = 0;
|
|
g = prepared(g, 'atreides', 'guild', world);
|
|
assert.equal(
|
|
g.homeworldOccupationHistory!.qualifications.filter(
|
|
(fact) => fact.world === world,
|
|
).length,
|
|
0,
|
|
);
|
|
g = commit(g, 'atreides', 3);
|
|
g = commit(reload(g), 'guild');
|
|
assert.equal(g.lastBattleContext!.winner, 'atreides');
|
|
assert.equal(seat(g, 'atreides').tanks, 3);
|
|
assert.equal(seat(g, 'guild').tanks, 3);
|
|
assert.equal(g.homeworlds!.custody!.visitors[world], undefined);
|
|
const history = g.homeworldOccupationHistory!;
|
|
const qualifications = history.qualifications.filter(
|
|
(fact) => fact.world === world,
|
|
);
|
|
assert.equal(qualifications.length, 1);
|
|
assert.equal(qualifications[0].player, 'atreides');
|
|
assert.equal(qualifications[0].cause, 'sole');
|
|
const source = history.sources.find(
|
|
(entry) => entry.event === qualifications[0].event,
|
|
)!;
|
|
const snapshot = JSON.parse(history.snapshots[source.snapshot]) as [
|
|
string,
|
|
[string, number, number][],
|
|
][];
|
|
const armies = snapshot.find(([location]) => location === world)![1];
|
|
assert.deepEqual(
|
|
armies.filter(([, normal, elite]) => normal + elite > 0),
|
|
[['atreides', 3, 0]],
|
|
'The original qualification observes the winner before its own dialed losses.',
|
|
);
|
|
inventory(g);
|
|
const saved = reload(g);
|
|
for (let attempt = 0; attempt < 3; attempt++)
|
|
g = normalizeAutomaticGame(reload(g));
|
|
assert.deepEqual(reload(g), saved);
|
|
assert.deepEqual(
|
|
g.homeworldOccupationHistory,
|
|
saved.homeworldOccupationHistory,
|
|
);
|
|
});
|
|
|
|
void test('simultaneous Lasgun Shield destruction of two invaders at an empty native Homeworld never invents a sole qualifier', () => {
|
|
let g = setup(['atreides', 'guild', 'harkonnen']);
|
|
const world = 'homeworld:harkonnen';
|
|
invade(g, 'atreides', world, 3);
|
|
invade(g, 'guild', world, 3);
|
|
seat(g, 'harkonnen').tanks += seat(g, 'harkonnen').reserves;
|
|
seat(g, 'harkonnen').reserves = 0;
|
|
const laser = hold(g, 'atreides', 'lasgun');
|
|
const shield = hold(g, 'guild', 'shield');
|
|
g = prepared(g, 'atreides', 'guild', world);
|
|
assert.deepEqual(viewGame(g, 'atreides').battle!.traitorVoters, []);
|
|
assert.equal(
|
|
g.homeworldOccupationHistory!.qualifications.filter(
|
|
(fact) => fact.world === world,
|
|
).length,
|
|
0,
|
|
);
|
|
g = commit(g, 'atreides', 0, { weapon: laser });
|
|
g = commit(reload(g), 'guild', 0, { defense: shield });
|
|
assert.equal(g.lastBattleContext!.winner, null);
|
|
assert.equal(g.battle, null);
|
|
assert.equal(seat(g, 'atreides').tanks, 3);
|
|
assert.equal(seat(g, 'guild').tanks, 3);
|
|
assert.equal(g.homeworlds!.custody!.visitors[world], undefined);
|
|
assert.equal(
|
|
g.homeworldOccupationHistory!.qualifications.filter(
|
|
(fact) => fact.world === world,
|
|
).length,
|
|
0,
|
|
);
|
|
for (const raw of g.homeworldOccupationHistory!.snapshots) {
|
|
const snapshot = JSON.parse(raw) as [string, [string, number, number][]][];
|
|
const present = snapshot
|
|
.find(([location]) => location === world)![1]
|
|
.filter(([, normal, elite]) => normal + elite > 0);
|
|
assert.ok(
|
|
present.length !== 1 || present[0][0] === 'harkonnen',
|
|
'No sequential half-explosion snapshot may show an invented sole invader.',
|
|
);
|
|
}
|
|
for (const id of [laser, shield])
|
|
assert.equal(g.discard.filter((card) => card.id === id).length, 1);
|
|
inventory(g);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
|
|
});
|
|
|
|
void test('real Lasgun Shield explosion preserves mixed native reserves and offers the printed-strength typed loss choice after mandatory discard', () => {
|
|
let g = setup(['atreides', 'emperor']);
|
|
invade(g, 'atreides', 'homeworld:emperor', 3);
|
|
const laser = hold(g, 'atreides', 'lasgun');
|
|
const shield = hold(g, 'emperor', 'shield');
|
|
const originalNative = seat(g, 'emperor').reserves;
|
|
g = prepared(g, 'atreides', 'emperor', 'homeworld:emperor');
|
|
g = commit(g, 'atreides', 0, { weapon: laser });
|
|
const attackerLeader = g.battle!.plans.atreides.leader!;
|
|
g = commit(reload(g), 'emperor', 0, { defense: shield });
|
|
const defenderLeader = g.battle!.plans.emperor.leader!;
|
|
g = applyAction(g, 'emperor', { type: 'traitorCall', call: false });
|
|
assert.equal(g.decision?.kind, 'homeworldExplosion');
|
|
if (g.decision?.kind !== 'homeworldExplosion')
|
|
throw new Error('Missing native Homeworld explosion choice.');
|
|
assert.equal(g.decision.player, 'emperor');
|
|
assert.deepEqual(
|
|
g.decision.options.map((option) => [option.normal, option.elite]),
|
|
[
|
|
[2, 0],
|
|
[1, 1],
|
|
[0, 2],
|
|
],
|
|
);
|
|
assert.equal(seat(g, 'atreides').tanks, 3);
|
|
assert.equal(seat(g, 'emperor').tanks, 0);
|
|
for (const card of [laser, shield])
|
|
assert.equal(g.discard.filter((held) => held.id === card).length, 1);
|
|
assert.equal(
|
|
seat(g, 'atreides').leaders.find((leader) => leader.id === attackerLeader)!
|
|
.dead,
|
|
true,
|
|
);
|
|
assert.equal(
|
|
seat(g, 'emperor').leaders.find((leader) => leader.id === defenderLeader)!
|
|
.dead,
|
|
true,
|
|
);
|
|
inventory(g);
|
|
const saved = reload(g);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(saved))), saved);
|
|
reject(saved, 'atreides', explosionAction(saved, 0));
|
|
if (saved.decision?.kind !== 'homeworldExplosion')
|
|
throw new Error('Missing restored native explosion choice.');
|
|
legalBots(saved, 'emperor');
|
|
for (let choice = 0; choice < saved.decision!.options.length; choice++) {
|
|
const losses: HomeworldForces = saved.decision!.options[choice];
|
|
const action = explosionAction(saved, choice);
|
|
const done = applyAction(reload(saved), 'emperor', action);
|
|
assert.equal(seat(done, 'emperor').reserves, originalNative - 2);
|
|
assert.equal(seat(done, 'emperor').tanks, 2);
|
|
assert.equal(seat(done, 'emperor').elites!.tanks, losses.elite);
|
|
assert.equal(done.battle, null);
|
|
assert.equal(done.lastBattleContext!.territory, 'homeworld:emperor');
|
|
assert.equal(done.lastBattleContext!.winner, null);
|
|
for (const card of [laser, shield])
|
|
assert.equal(done.discard.filter((held) => held.id === card).length, 1);
|
|
inventory(done);
|
|
const history = done.homeworldOccupationHistory!;
|
|
assert.ok(history);
|
|
assert.equal(
|
|
history.qualifications.filter(
|
|
(fact) => fact.world === 'homeworld:emperor',
|
|
).length,
|
|
0,
|
|
);
|
|
const source = history.sources.at(-1)!;
|
|
const snapshot = JSON.parse(history.snapshots[source.snapshot]) as [
|
|
string,
|
|
[string, number, number][],
|
|
][];
|
|
const native = snapshot
|
|
.find(([world]) => world === 'homeworld:emperor')![1]
|
|
.find(([owner]) => owner === 'emperor')!;
|
|
assert.equal(native[1] + native[2], originalNative - 2);
|
|
assert.equal(native[2], seat(done, 'emperor').elites!.reserves);
|
|
assert.deepEqual(
|
|
reload(normalizeAutomaticGame(reload(done))),
|
|
reload(done),
|
|
);
|
|
reject(done, 'emperor', action);
|
|
}
|
|
});
|
|
|
|
void test('a saved native explosion allocation rejects a same-total Emperor pool swap instead of applying losses to different typed counters', () => {
|
|
let g = setup(['atreides', 'emperor'], true);
|
|
custody(g, [
|
|
{
|
|
homeworld: 'homeworld:emperor',
|
|
player: 'emperor',
|
|
withdraw: { normal: 1, elite: 0 },
|
|
deposit: { normal: 0, elite: 1 },
|
|
},
|
|
{
|
|
homeworld: 'homeworld:emperor:salusa',
|
|
player: 'emperor',
|
|
withdraw: { normal: 0, elite: 1 },
|
|
deposit: { normal: 1, elite: 0 },
|
|
},
|
|
]);
|
|
invade(g, 'atreides', 'homeworld:emperor', 3);
|
|
const laser = hold(g, 'atreides', 'lasgun');
|
|
const shield = hold(g, 'emperor', 'shield');
|
|
g = prepared(g, 'atreides', 'emperor', 'homeworld:emperor');
|
|
g = commit(g, 'atreides', 0, { weapon: laser });
|
|
g = commit(g, 'emperor', 0, { defense: shield });
|
|
g = applyAction(g, 'emperor', { type: 'traitorCall', call: false });
|
|
assert.equal(g.decision?.kind, 'homeworldExplosion');
|
|
const saved = reload(g);
|
|
const corrupt = reload(saved);
|
|
// Kaitain remains fifteen counters, but its one star moves to Salusa in
|
|
// exchange for one normal. Aggregate reserve/star totals remain unchanged.
|
|
corrupt.homeworlds!.custody!.salusa = { normal: 0, elite: 5 };
|
|
const groups = homeworldForceGroups(
|
|
homeworldContext(corrupt),
|
|
corrupt.homeworlds!.custody!,
|
|
);
|
|
assert.deepEqual(
|
|
groups.find((home) => home.id === 'homeworld:emperor')!.forces.emperor,
|
|
{ normal: 15, elite: 0 },
|
|
);
|
|
assert.equal(
|
|
seat(corrupt, 'emperor').reserves,
|
|
seat(saved, 'emperor').reserves,
|
|
);
|
|
assert.equal(
|
|
seat(corrupt, 'emperor').elites!.reserves,
|
|
seat(saved, 'emperor').elites!.reserves,
|
|
);
|
|
reject(corrupt, 'emperor', explosionAction(corrupt, 0));
|
|
const done = applyAction(reload(saved), 'emperor', explosionAction(saved, 0));
|
|
assert.equal(seat(done, 'emperor').tanks, 2);
|
|
assert.deepEqual(done.homeworlds!.custody!.salusa, { normal: 1, elite: 4 });
|
|
inventory(done);
|
|
});
|
|
|
|
void test('an explosion between two invaders automatically reveals and gives only the uninvolved native owner its typed loss choice', () => {
|
|
let g = setup(['atreides', 'guild', 'emperor']);
|
|
invade(g, 'atreides', 'homeworld:emperor', 3);
|
|
invade(g, 'guild', 'homeworld:emperor', 3);
|
|
const laser = hold(g, 'atreides', 'lasgun');
|
|
const shield = hold(g, 'guild', 'shield');
|
|
const nativeLeaders = structuredClone(seat(g, 'emperor').leaders);
|
|
g = prepared(g, 'atreides', 'guild', 'homeworld:emperor');
|
|
assert.deepEqual(viewGame(g, 'atreides').battle!.traitorVoters, []);
|
|
g = commit(g, 'atreides', 0, { weapon: laser });
|
|
g = commit(reload(g), 'guild', 0, { defense: shield });
|
|
assert.equal(g.decision?.kind, 'homeworldExplosion');
|
|
assert.equal(g.decision!.player, 'emperor');
|
|
assert.equal(seat(g, 'atreides').tanks, 3);
|
|
assert.equal(seat(g, 'guild').tanks, 3);
|
|
assert.deepEqual(seat(g, 'emperor').leaders, nativeLeaders);
|
|
reject(reload(g), 'atreides', explosionAction(g, 0));
|
|
const done = applyAction(reload(g), 'emperor', explosionAction(g, 0));
|
|
assert.equal(seat(done, 'emperor').tanks, 2);
|
|
assert.deepEqual(seat(done, 'emperor').leaders, nativeLeaders);
|
|
assert.equal(done.battle, null);
|
|
assert.equal(
|
|
done.homeworlds!.custody!.visitors['homeworld:emperor'],
|
|
undefined,
|
|
);
|
|
inventory(done);
|
|
});
|
|
|
|
void test('normalization restores a previously saved revealed Homeworld battle with no native traitor voters exactly once', () => {
|
|
let g = setup(['atreides', 'guild', 'harkonnen']);
|
|
invade(g, 'atreides', 'homeworld:harkonnen', 3);
|
|
invade(g, 'guild', 'homeworld:harkonnen', 3);
|
|
seat(g, 'harkonnen').tanks += seat(g, 'harkonnen').reserves;
|
|
seat(g, 'harkonnen').reserves = 0;
|
|
g = prepared(g, 'atreides', 'guild', 'homeworld:harkonnen');
|
|
g = commit(g, 'atreides', 1);
|
|
// Reproduce the observed prior persisted frame: both original plans are
|
|
// revealed, but the old engine waited forever for an empty voter set.
|
|
g.battle!.plans.guild = {
|
|
dial: 0,
|
|
support: 0,
|
|
leader: seat(g, 'guild').leaders.find((leader) => leader.strength === 1)!
|
|
.id,
|
|
weapon: null,
|
|
defense: null,
|
|
};
|
|
g.battle!.revealed = true;
|
|
const before = reload(g);
|
|
const done = normalizeAutomaticGame(reload(g));
|
|
assert.deepEqual(g, before);
|
|
assert.equal(done.battle, null);
|
|
assert.equal(done.lastBattleContext!.winner, 'atreides');
|
|
assert.equal(seat(done, 'atreides').tanks, 1);
|
|
assert.equal(seat(done, 'guild').tanks, 3);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(done))), reload(done));
|
|
inventory(done);
|
|
});
|
|
|
|
void test('a real held Portable Snooper gives a nonvoting invader an owned late-defense choice before automatic Homeworld resolution', () => {
|
|
for (const weaponKind of ['poison', 'projectile'])
|
|
for (const use of [false, true]) {
|
|
let g = setup(['atreides', 'guild', 'harkonnen']);
|
|
invade(g, 'atreides', 'homeworld:harkonnen', 3);
|
|
invade(g, 'guild', 'homeworld:harkonnen', 3);
|
|
seat(g, 'harkonnen').tanks += seat(g, 'harkonnen').reserves;
|
|
seat(g, 'harkonnen').reserves = 0;
|
|
g = prepared(g, 'atreides', 'guild', 'homeworld:harkonnen');
|
|
// Explicit single Richese component seam after genuine base setup. The
|
|
// canonical supplemental card has one physical owner; this does not claim
|
|
// that a complete Richese deck, sale or expansion start was exercised.
|
|
const portable = richeseCards().find(
|
|
(card) => card.effect === 'portableSnooper',
|
|
)!;
|
|
seat(g, 'atreides').hand.push(portable);
|
|
const weapon = hold(g, 'guild', weaponKind);
|
|
const leader = seat(g, 'atreides').leaders.find(
|
|
(leader) => leader.strength === 5,
|
|
)!;
|
|
g = commit(g, 'atreides', 1, { leader: leader.id });
|
|
g = commit(reload(g), 'guild', 2, { weapon });
|
|
assert.equal(g.decision?.kind, 'homeworldDefense');
|
|
if (g.decision?.kind !== 'homeworldDefense')
|
|
throw new Error('Missing invader late-defense opportunity.');
|
|
assert.equal(g.decision.player, 'atreides');
|
|
const action: Action = { type: 'decision', event: g.decision.event, use };
|
|
const pending = reload(g);
|
|
for (const candidate of legalBots(pending, 'atreides', 1))
|
|
assert.equal(candidate.use, weaponKind === 'poison');
|
|
assert.deepEqual(
|
|
reload(normalizeAutomaticGame(reload(pending))),
|
|
pending,
|
|
);
|
|
reject(pending, 'guild', action);
|
|
reject(pending, 'atreides', { ...action, event: 'stale' });
|
|
g = applyAction(reload(pending), 'atreides', action);
|
|
assert.equal(
|
|
seat(g, 'atreides').leaders.find(
|
|
(candidate) => candidate.id === leader.id,
|
|
)!.dead,
|
|
!(use && weaponKind === 'poison'),
|
|
);
|
|
assert.equal(
|
|
g.lastBattleContext!.winner,
|
|
use && weaponKind === 'poison' ? 'atreides' : 'guild',
|
|
);
|
|
while (g.decision?.kind === 'battleCards')
|
|
g = applyAction(reload(g), g.decision.player, {
|
|
type: 'decision',
|
|
discard: [],
|
|
});
|
|
assert.equal(g.battle, null);
|
|
reject(g, 'atreides', action);
|
|
assert.equal(
|
|
seat(g, 'atreides').hand.filter((card) => card.id === portable.id)
|
|
.length,
|
|
use && weaponKind === 'projectile' ? 0 : 1,
|
|
);
|
|
assert.equal(
|
|
g.discard.filter((card) => card.id === portable.id).length,
|
|
use && weaponKind === 'projectile' ? 1 : 0,
|
|
);
|
|
inventory(g, 1);
|
|
}
|
|
});
|