515 lines
19 KiB
TypeScript
515 lines
19 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 { homeworldGameIntegrity } from '../game/homeworld-game';
|
|
import { casualtyOptions, type Casualties } from '../game/combat';
|
|
import { baseDeck } from '../game/cards';
|
|
import { homeworldSardaukarFreeRevivalBlock } from '../game/homeworld-benefits';
|
|
|
|
const emperor = (g: Game) => g.players.find((player) => player.id === 'e')!;
|
|
const atreides = (g: Game) => g.players.find((player) => player.id === 'a')!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
const sum = (forces: Record<string, number>) =>
|
|
Object.values(forces).reduce((a, b) => a + b, 0);
|
|
function inventory(g: Game) {
|
|
homeworldGameIntegrity(g);
|
|
for (const player of g.players)
|
|
assert.equal(player.reserves + player.tanks + sum(player.forces), 20);
|
|
const elites = emperor(g).elites!;
|
|
assert.equal(elites.reserves + elites.tanks + sum(elites.forces), 5);
|
|
const cards = [
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((player) => player.hand),
|
|
];
|
|
assert.equal(
|
|
cards.length,
|
|
baseDeck().length,
|
|
'Genuine two-player base setup retains its physical treachery deck.',
|
|
);
|
|
assert.equal(new Set(cards.map((card) => card.id)).size, cards.length);
|
|
}
|
|
function reject(g: Game, action: Action) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, 'e', action));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
function onlyCityIncome(g: Game) {
|
|
// Completing the last battle enters Collection automatically. The surviving
|
|
// Arrakeen army earns two city spice after its zero-cost battle settlement.
|
|
assert.equal(emperor(g).spice, 2);
|
|
assert.ok(
|
|
g.log.some(
|
|
(entry) => entry.text === 'Emperor received 2 spice from strongholds.',
|
|
),
|
|
);
|
|
}
|
|
/** Real Advanced setup and actual typed shipment. Only the later phase and
|
|
* reduced spice balance are staged; no cards, leaders or forces are created. */
|
|
function shipped(stars = 3, normal = 1) {
|
|
let g = createGame(
|
|
'SALUSAENGINE',
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
true,
|
|
);
|
|
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
|
g = applyAction(g, 'e', { type: 'homeworlds', enabled: true });
|
|
for (const player of g.players)
|
|
g = applyAction(g, player.id, { type: 'ready' });
|
|
g = initializeHomeworldGameForAudit(g);
|
|
while (g.setupStage === 'traitors') {
|
|
const player = g.players.find((player) => player.traitorChoices.length)!;
|
|
g = applyAction(g, player.id, {
|
|
type: 'traitor',
|
|
leader: player.traitorChoices[0],
|
|
});
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
for (const player of g.players) g.deck.push(...player.hand.splice(0));
|
|
Object.assign(g, {
|
|
phase: 5,
|
|
phaseOpening: null,
|
|
response: null,
|
|
decision: null,
|
|
active: 'e',
|
|
order: ['e', 'a'],
|
|
movementRemaining: ['e', 'a'],
|
|
ready: [],
|
|
storm: 18,
|
|
});
|
|
emperor(g).spice = stars + normal;
|
|
g = applyAction(g, 'e', {
|
|
type: 'ship',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount: stars + normal,
|
|
elite: stars,
|
|
homeworldSources: {
|
|
'homeworld:emperor': { normal, elite: 0 },
|
|
'homeworld:emperor:salusa': { normal: 0, elite: stars },
|
|
},
|
|
});
|
|
assert.equal(emperor(g).spice, 0);
|
|
assert.equal(emperor(g).forces['arrakeen:10'], stars + normal);
|
|
assert.equal(emperor(g).elites!.forces['arrakeen:10'], stars);
|
|
assert.equal(g.homeworlds!.custody!.salusa!.elite, 5 - stars);
|
|
assert.ok(atreides(g).forces['arrakeen:10'] > 0);
|
|
inventory(g);
|
|
return g;
|
|
}
|
|
function prepared(stars = 3, normal = 1, cancel = false) {
|
|
let g = shipped(stars, normal);
|
|
let karama: string | undefined;
|
|
if (cancel) {
|
|
const index = g.deck.findIndex((card) => card.effect === 'karama');
|
|
assert.ok(index >= 0);
|
|
const [card] = g.deck.splice(index, 1);
|
|
atreides(g).hand.push(card);
|
|
karama = card.id;
|
|
}
|
|
Object.assign(g, { phase: 6, active: 'e', movementRemaining: [], ready: [] });
|
|
g = applyAction(g, 'e', {
|
|
type: 'chooseBattle',
|
|
territory: 'arrakeen',
|
|
target: 'a',
|
|
});
|
|
let canceled = false;
|
|
for (
|
|
let step = 0;
|
|
(g.response || g.battle?.preparation || g.decision) && step < 30;
|
|
step++
|
|
) {
|
|
if (g.response) {
|
|
if (
|
|
cancel &&
|
|
g.response.kind === 'eliteStrength' &&
|
|
g.response.owner === 'e'
|
|
) {
|
|
g = applyAction(g, 'a', {
|
|
type: 'card',
|
|
card: karama!,
|
|
mode: 'cancel',
|
|
});
|
|
canceled = true;
|
|
} else {
|
|
const owner = g.players.find(
|
|
(player) => !g.response!.passed.includes(player.id),
|
|
)!;
|
|
assert.ok(owner);
|
|
g = applyAction(g, owner.id, { type: 'passResponse' });
|
|
}
|
|
} else if (g.decision) {
|
|
assert.equal(g.decision.kind, 'fullPlanOffer');
|
|
g = applyAction(g, g.decision.player, {
|
|
type: 'decision',
|
|
decline: true,
|
|
});
|
|
} else
|
|
g = applyAction(g, g.battle!.preparation!.owner, {
|
|
type: 'declineBattlePower',
|
|
});
|
|
}
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.battle!.preparation ?? null, null);
|
|
assert.equal(canceled, cancel);
|
|
if (cancel)
|
|
assert.equal(g.discard.filter((card) => card.id === karama).length, 1);
|
|
inventory(g);
|
|
return g;
|
|
}
|
|
function plan(g: Game, dial: number, support = 0): Action {
|
|
return {
|
|
type: 'battlePlan',
|
|
dial,
|
|
support,
|
|
leader: emperor(g)
|
|
.leaders.slice()
|
|
.sort((a, b) => b.strength - a.strength)[0].id,
|
|
};
|
|
}
|
|
function resolve(state: Game, dial: number, support = 0) {
|
|
let g = applyAction(state, 'e', plan(state, dial, support));
|
|
assert.deepEqual(viewGame(reload(g), 'a').battle!.plans, {});
|
|
assert.equal(viewGame(reload(g), 'e').battle!.plans.e.dial, dial);
|
|
assert.deepEqual(
|
|
viewGame(g, 'a').players.find((player) => player.id === 'e')!.hand,
|
|
undefined,
|
|
);
|
|
g = applyAction(reload(g), 'a', {
|
|
type: 'battlePlan',
|
|
dial: 0,
|
|
support: 0,
|
|
leader: atreides(g)
|
|
.leaders.slice()
|
|
.sort((a, b) => a.strength - b.strength)[0].id,
|
|
});
|
|
g = applyAction(g, 'e', { type: 'traitorCall', call: false });
|
|
g = applyAction(reload(g), 'a', { type: 'traitorCall', call: false });
|
|
assert.equal(g.lastBattleContext!.winner, 'e');
|
|
inventory(g);
|
|
return g;
|
|
}
|
|
|
|
void test('actual shipment leaving two Sardaukar on Salusa lets three attacking stars dial six at zero spice while a normal force still needs support', () => {
|
|
const g = prepared();
|
|
const forces = viewGame(g, 'e').battle!.ownForces!;
|
|
assert.equal(forces.normal, 1);
|
|
assert.equal(forces.elite, 3);
|
|
assert.equal(forces.eliteStrength, 2);
|
|
assert.equal(forces.eliteFreeSupport, true);
|
|
assert.equal(forces.freeSupport, false);
|
|
assert.equal(casualtyOptions(forces, 6.5, 0).length, 1);
|
|
assert.equal(casualtyOptions(forces, 7, 0).length, 0);
|
|
reject(g, plan(g, 7));
|
|
reject(g, plan(g, 7, 1));
|
|
const funded = reload(g);
|
|
emperor(funded).spice = 1;
|
|
const paid = applyAction(funded, 'e', plan(funded, 7, 1));
|
|
assert.equal(paid.battle!.plans.e.support, 1);
|
|
const done = resolve(g, 6);
|
|
onlyCityIncome(done);
|
|
assert.equal(emperor(done).elites!.tanks, 3);
|
|
assert.equal(emperor(done).tanks, 3);
|
|
assert.equal(emperor(done).forces['arrakeen:10'], 1);
|
|
assert.equal(done.homeworlds!.custody!.salusa!.elite, 2);
|
|
});
|
|
|
|
void test('actual shipment of a fourth star leaves Salusa low and restores ordinary paid-support battle legality', () => {
|
|
const g = prepared(4);
|
|
const forces = viewGame(g, 'e').battle!.ownForces!;
|
|
assert.equal(forces.eliteFreeSupport, undefined);
|
|
assert.equal(forces.eliteStrength, 2);
|
|
assert.equal(forces.freeSupport, false);
|
|
reject(g, plan(g, 8));
|
|
assert.ok(
|
|
casualtyOptions(forces, 8, 4).some(
|
|
(loss) => loss.elite === 4 && loss.normal === 0 && loss.paidElite === 4,
|
|
),
|
|
);
|
|
const done = resolve(g, 4);
|
|
onlyCityIncome(done);
|
|
assert.equal(emperor(done).elites!.tanks, 4);
|
|
assert.equal(emperor(done).forces['arrakeen:10'], 1);
|
|
assert.equal(done.homeworlds!.custody!.salusa!.elite, 1);
|
|
});
|
|
|
|
void test('a genuine saved casualty choice preserves full-strength free stars versus four unsupported normal losses', () => {
|
|
const g = resolve(prepared(3, 4), 2);
|
|
assert.equal(g.decision?.kind, 'battleLosses');
|
|
if (g.decision?.kind !== 'battleLosses')
|
|
throw new Error('Missing physical casualty choice.');
|
|
assert.deepEqual(
|
|
g.decision.options.map((option) => [
|
|
option.normal,
|
|
option.elite,
|
|
option.paidNormal,
|
|
option.paidElite,
|
|
]),
|
|
[
|
|
[4, 0, 0, 0],
|
|
[0, 1, 0, 0],
|
|
],
|
|
);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
|
|
for (let choice = 0; choice < g.decision.options.length; choice++) {
|
|
const losses: Casualties = g.decision.options[choice];
|
|
const done = applyAction(reload(g), 'e', { type: 'decision', choice });
|
|
assert.equal(emperor(done).tanks, losses.normal + losses.elite);
|
|
assert.equal(emperor(done).elites!.tanks, losses.elite);
|
|
onlyCityIncome(done);
|
|
assert.equal(done.homeworlds!.custody!.salusa!.elite, 2);
|
|
inventory(done);
|
|
reject(done, { type: 'decision', choice });
|
|
}
|
|
});
|
|
|
|
void test('actual ordinary Sardaukar Karama reduces strength but leaves Salusa high free support intact', () => {
|
|
const g = prepared(3, 1, true);
|
|
assert.deepEqual(g.battle!.eliteBlocked, ['e']);
|
|
const forces = viewGame(g, 'e').battle!.ownForces!;
|
|
assert.equal(forces.eliteStrength, 1);
|
|
assert.equal(forces.eliteFreeSupport, true);
|
|
assert.equal(forces.freeSupport, false);
|
|
reject(g, plan(g, 4));
|
|
const done = resolve(g, 3);
|
|
assert.equal(emperor(done).elites!.tanks, 3);
|
|
onlyCityIncome(done);
|
|
assert.equal(emperor(done).forces['arrakeen:10'], 1);
|
|
});
|
|
|
|
void test('all four AI policies use the public Salusa combat descriptor for legal zero-spice plans and restored casualty choices', () => {
|
|
for (const g of [
|
|
prepared(),
|
|
prepared(4),
|
|
prepared(3, 1, true),
|
|
resolve(prepared(3, 4), 2),
|
|
]) {
|
|
for (const level of DIFFICULTIES) {
|
|
const view = viewGame(reload(g), 'e');
|
|
view.players.find((player) => player.id === 'e')!.bot = level;
|
|
const beforeView = structuredClone(view);
|
|
const actions = botActions(view);
|
|
assert.ok(actions.length, `${level} must supply a decision.`);
|
|
assert.deepEqual(view, beforeView);
|
|
assert.ok(
|
|
actions.some(
|
|
(action) => action.type === (g.decision ? 'decision' : 'battlePlan'),
|
|
),
|
|
);
|
|
for (const action of actions) {
|
|
const before = structuredClone(g);
|
|
const done = applyAction(g, 'e', action);
|
|
assert.deepEqual(g, before);
|
|
inventory(done);
|
|
if (action.type === 'battlePlan') {
|
|
assert.equal(action.support ?? 0, 0);
|
|
assert.ok(
|
|
casualtyOptions(view.battle!.ownForces!, Number(action.dial), 0)
|
|
.length,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function gholaThresholdBattle(normal = 1) {
|
|
const g = prepared(4, normal);
|
|
const player = emperor(g);
|
|
// Conserved later battle position: one of the four shipped Sardaukar died
|
|
// before plans. Salusa still contains its one remaining native Sardaukar.
|
|
player.forces['arrakeen:10']--;
|
|
player.elites!.forces['arrakeen:10']--;
|
|
player.tanks++;
|
|
player.elites!.tanks++;
|
|
player.spice = 3;
|
|
const index = g.deck.findIndex((card) => card.effect === 'ghola');
|
|
assert.ok(index >= 0);
|
|
const [card] = g.deck.splice(index, 1);
|
|
player.hand.push(card);
|
|
inventory(g);
|
|
return { g, card: card.id };
|
|
}
|
|
|
|
void test('Low Salusa prohibits free Sardaukar Ghola before battle commitments', () => {
|
|
const { g: before, card } = gholaThresholdBattle();
|
|
const reason = homeworldSardaukarFreeRevivalBlock(before, 'e');
|
|
assert.match(reason!, /prohibits free Sardaukar revival/);
|
|
assert.equal(viewGame(before, 'e').ghola.eliteBlock, reason);
|
|
assert.equal(viewGame(before, 'e').ghola.eliteRemaining, 0);
|
|
const snapshot = structuredClone(before);
|
|
assert.throws(() => applyAction(before, 'e', { type: 'card', card, amount: 1, elite: 1 }), /prohibits free Sardaukar revival/);
|
|
assert.deepEqual(before, snapshot);
|
|
assert.equal(before.discard.filter((held) => held.id === card).length, 0);
|
|
assert.equal(viewGame(before, 'e').battle!.ownForces!.eliteFreeSupport, undefined);
|
|
let resolved = resolve(before, 6, 3);
|
|
if (resolved.decision?.kind === 'battleLosses') {
|
|
const choice = resolved.decision.options.findIndex(
|
|
(loss) => loss.normal === 0 && loss.elite === 3,
|
|
);
|
|
assert.ok(choice >= 0);
|
|
resolved = applyAction(reload(resolved), 'e', { type: 'decision', choice });
|
|
}
|
|
assert.equal(emperor(resolved).forces['arrakeen:10'], 1);
|
|
assert.equal(emperor(resolved).spice, 2, 'The paid battle support leaves only automatic Arrakeen collection.');
|
|
assert.equal(resolved.homeworlds!.custody!.salusa!.elite, 1);
|
|
inventory(resolved);
|
|
});
|
|
|
|
void test('the Low Salusa free revival prohibition remains through a sealed plan and completed battle', () => {
|
|
const { g: before, card } = gholaThresholdBattle();
|
|
const sealed = applyAction(before, 'e', plan(before, 6, 3));
|
|
for (const state of [sealed]) {
|
|
const g = reload(state);
|
|
const reason = homeworldSardaukarFreeRevivalBlock(g, 'e');
|
|
assert.ok(
|
|
reason,
|
|
'The Low Salusa prohibition must have an explicit reason.',
|
|
);
|
|
const view = viewGame(g, 'e');
|
|
assert.equal(view.ghola.eliteRemaining, 0);
|
|
assert.equal(view.ghola.eliteBlock, reason);
|
|
assert.equal(viewGame(g, 'a').ghola.eliteBlock, null);
|
|
const snapshot = structuredClone(g);
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { type: 'card', card, amount: 1, elite: 1 }),
|
|
(error) => error instanceof Error && error.message === reason,
|
|
);
|
|
assert.deepEqual(g, snapshot);
|
|
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), g);
|
|
assert.equal(g.homeworlds!.custody!.salusa!.elite, 1);
|
|
assert.equal(g.discard.filter((held) => held.id === card).length, 0);
|
|
assert.deepEqual(viewGame(g, 'a').battle!.plans, {});
|
|
}
|
|
let g = applyAction(reload(sealed), 'a', {
|
|
type: 'battlePlan',
|
|
dial: 0,
|
|
support: 0,
|
|
leader: atreides(sealed)
|
|
.leaders.slice()
|
|
.sort((a, b) => a.strength - b.strength)[0].id,
|
|
});
|
|
g = applyAction(g, 'e', { type: 'traitorCall', call: false });
|
|
g = applyAction(reload(g), 'a', { type: 'traitorCall', call: false });
|
|
assert.equal(g.lastBattleContext!.winner, 'e');
|
|
if (g.decision?.kind === 'battleLosses') {
|
|
const choice = g.decision.options.findIndex(
|
|
(loss) => loss.normal === 0 && loss.elite === 3,
|
|
);
|
|
assert.ok(choice >= 0);
|
|
g = applyAction(reload(g), 'e', { type: 'decision', choice });
|
|
}
|
|
assert.equal(g.battle, null);
|
|
assert.match(homeworldSardaukarFreeRevivalBlock(g, 'e')!, /prohibits free Sardaukar revival/);
|
|
assert.equal(viewGame(g, 'e').ghola.eliteRemaining, 0);
|
|
const finished = structuredClone(g);
|
|
assert.throws(() => applyAction(g, 'e', { type: 'card', card, amount: 1, elite: 1 }), /prohibits free Sardaukar revival/);
|
|
assert.deepEqual(g, finished);
|
|
assert.equal(g.homeworlds!.custody!.salusa!.elite, 1);
|
|
assert.equal(g.discard.filter((held) => held.id === card).length, 0);
|
|
inventory(g);
|
|
});
|
|
|
|
void test('the Salusa commitment guard leaves normal-force and eligible-leader Ghola choices available during battle', () => {
|
|
for (const kind of ['normal', 'leader'] as const) {
|
|
const fixture = gholaThresholdBattle();
|
|
const before = applyAction(fixture.g, 'e', plan(fixture.g, 6, 3));
|
|
const card = fixture.card;
|
|
const player = emperor(before);
|
|
let command: Action;
|
|
let leaderId: string | undefined;
|
|
if (kind === 'normal') {
|
|
player.reserves--;
|
|
player.tanks++;
|
|
command = { type: 'card', card, amount: 1, elite: 0 };
|
|
} else {
|
|
const leader = player.leaders[0];
|
|
leader.dead = true;
|
|
leader.deaths = 1;
|
|
leaderId = leader.id;
|
|
command = { type: 'card', card, leader: leader.id };
|
|
}
|
|
inventory(before);
|
|
const view = viewGame(before, 'e');
|
|
assert.ok(view.ghola.eliteBlock);
|
|
assert.equal(view.ghola.eliteRemaining, 0);
|
|
if (kind === 'normal') assert.ok(view.ghola.maxForces >= 1);
|
|
else assert.ok(view.ghola.leaders.some((leader) => leader.id === leaderId));
|
|
const done = applyAction(reload(before), 'e', command);
|
|
assert.equal(done.homeworlds!.custody!.salusa!.elite, 1);
|
|
assert.equal(done.discard.filter((held) => held.id === card).length, 1);
|
|
assert.ok(done.battle && !done.battle.revealed);
|
|
if (kind === 'normal') {
|
|
assert.equal(emperor(done).reserves, player.reserves + 1);
|
|
assert.equal(emperor(done).elites!.tanks, player.elites!.tanks);
|
|
} else
|
|
assert.equal(
|
|
emperor(done).leaders.find((leader) => leader.id === leaderId)!.dead,
|
|
false,
|
|
);
|
|
inventory(done);
|
|
}
|
|
});
|
|
|
|
void test('an opponent-only sealed plan keeps the Low Salusa free revival prohibition visible before Emperor submits', () => {
|
|
const fixture = gholaThresholdBattle();
|
|
const g = applyAction(fixture.g, 'a', {
|
|
type: 'battlePlan', dial: 0, support: 0, leader: atreides(fixture.g).leaders[0].id,
|
|
});
|
|
assert.equal(g.battle!.plans.e, undefined);
|
|
assert.ok(g.battle!.plans.a);
|
|
const view = viewGame(g, 'e');
|
|
assert.deepEqual(view.battle!.plans, {});
|
|
assert.equal(view.ghola.eliteRemaining, 0);
|
|
assert.match(view.ghola.eliteBlock!, /prohibits free Sardaukar revival/);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, 'e', {
|
|
type: 'card', card: fixture.card, amount: 1, elite: 1,
|
|
}), /prohibits free Sardaukar revival/);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
|
|
void test('a rejected Low Salusa Ghola leaves an answered Prescience dial binding', () => {
|
|
const fixture = gholaThresholdBattle(0);
|
|
const card = fixture.card;
|
|
let g = fixture.g;
|
|
// Restore the declined preparation opportunity in this conserved fixture,
|
|
// then make the public query and private answer through their actual actions.
|
|
g.battle!.preparation = { kind: 'prescience', owner: 'a', beneficiary: 'a' };
|
|
g = applyAction(g, 'a', { type: 'prescience', field: 'dial' });
|
|
for (let step = 0; g.response && step < 10; step++) {
|
|
const owner = g.players.find(
|
|
(player) => !g.response!.passed.includes(player.id),
|
|
)!;
|
|
g = applyAction(g, owner.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(g.battle!.preparation!.kind, 'prescienceAnswer');
|
|
g = applyAction(g, 'e', { type: 'prescienceAnswer', value: 1 });
|
|
assert.equal(g.battle!.prescience!.value, 1);
|
|
assert.deepEqual(g.battle!.plans, {});
|
|
assert.match(viewGame(g, 'e').ghola.eliteBlock!, /prohibits free Sardaukar revival/);
|
|
const snapshot = structuredClone(g);
|
|
assert.throws(() => applyAction(g, 'e', { type: 'card', card, amount: 1, elite: 1 }), /prohibits free Sardaukar revival/);
|
|
assert.deepEqual(g, snapshot);
|
|
assert.equal(g.battle!.prescience!.field, 'dial');
|
|
assert.equal(g.battle!.prescience!.value, 1);
|
|
assert.throws(() => applyAction(g, 'e', plan(g, 2)), /battle inspection/);
|
|
const done = resolve(g, 1);
|
|
assert.equal(done.lastBattleContext!.winner, 'e');
|
|
assert.equal(done.homeworlds!.custody!.salusa!.elite, 1);
|
|
assert.equal(done.discard.filter((held) => held.id === card).length, 0);
|
|
inventory(done);
|
|
});
|