595 lines
19 KiB
TypeScript
595 lines
19 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeBaseGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
|
import type { FactionId } from '../game/catalog';
|
|
import { isAdvisor } from '../game/advisors';
|
|
|
|
const BASE: FactionId[] = [
|
|
'emperor',
|
|
'atreides',
|
|
'harkonnen',
|
|
'fremen',
|
|
'beneGesserit',
|
|
];
|
|
const player = (g: Game, id = 'p') => g.players.find((p) => p.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
|
|
/** Real Advanced initialization and setup, followed by conserved scenario staging. */
|
|
function fixture(faction: FactionId = 'emperor', extra: FactionId[] = []) {
|
|
const roster = [...new Set([faction, ...extra, ...BASE])].slice(
|
|
0,
|
|
Math.max(3, 1 + extra.length),
|
|
);
|
|
const ids = ['p', 'a', 'o', 'x', 'y', 'z'];
|
|
let g = createGame(
|
|
'ADVANCEDSHIPTRUTH',
|
|
newPlayer('p', faction, faction),
|
|
true,
|
|
);
|
|
for (let i = 1; i < roster.length; i++)
|
|
joinGame(g, newPlayer(ids[i], roster[i], roster[i]));
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = initializeBaseGameForAudit(g);
|
|
for (let n = 0; g.status === 'setup' && n < 30; n++) {
|
|
let advanced = false;
|
|
for (const p of g.players) {
|
|
const view = viewGame(g, p.id);
|
|
view.players.find((other) => other.id === p.id)!.bot = 'Hard';
|
|
const action = botActions(view)[0];
|
|
if (!action) continue;
|
|
g = applyAction(g, p.id, action);
|
|
advanced = true;
|
|
break;
|
|
}
|
|
assert.ok(advanced, 'genuine Advanced setup has an entitled next actor');
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
assert.equal(g.advanced, true);
|
|
assert.equal(g.setupStage, undefined);
|
|
Object.assign(g, {
|
|
phase: 5,
|
|
turn: 2,
|
|
active: 'p',
|
|
storm: 18,
|
|
order: g.players.map((p) => p.id),
|
|
movementRemaining: g.players.map((p) => p.id),
|
|
response: null,
|
|
decision: null,
|
|
phaseOpening: null,
|
|
});
|
|
for (const p of g.players) {
|
|
g.deck.push(...p.hand);
|
|
p.hand = [];
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
p.tanks = 0;
|
|
p.spice = 20;
|
|
p.shipped = false;
|
|
p.moved = 0;
|
|
p.revived = 0;
|
|
if (p.advisors) p.advisors = {};
|
|
if (p.elites) {
|
|
p.elites.reserves = p.faction === 'emperor' ? 5 : 3;
|
|
p.elites.tanks = 0;
|
|
p.elites.forces = {};
|
|
p.elites.revived = 0;
|
|
}
|
|
}
|
|
return g;
|
|
}
|
|
|
|
function hold(g: Game, id: string, effectOrKind: string) {
|
|
const index = g.deck.findIndex(
|
|
(c) => c.effect === effectOrKind || c.kind === effectOrKind,
|
|
);
|
|
assert.ok(index >= 0, `available physical ${effectOrKind}`);
|
|
const [card] = g.deck.splice(index, 1);
|
|
player(g, id).hand.push(card);
|
|
return card.id;
|
|
}
|
|
|
|
function pool(
|
|
g: Game,
|
|
reserves: number,
|
|
tanks = 0,
|
|
eliteReserves = 0,
|
|
eliteTanks = 0,
|
|
revived = 0,
|
|
) {
|
|
const p = player(g),
|
|
onBoard = 20 - reserves - tanks;
|
|
p.reserves = reserves;
|
|
p.tanks = tanks;
|
|
p.forces = onBoard ? { 'arrakeen:10': onBoard } : {};
|
|
if (p.elites) {
|
|
const total = p.faction === 'emperor' ? 5 : 3;
|
|
const boardElites = total - eliteReserves - eliteTanks;
|
|
p.elites = {
|
|
reserves: eliteReserves,
|
|
tanks: eliteTanks,
|
|
revived,
|
|
forces: boardElites ? { 'arrakeen:10': boardElites } : {},
|
|
};
|
|
}
|
|
assertCustody(g);
|
|
}
|
|
|
|
function assertCustody(g: Game) {
|
|
for (const p of g.players) {
|
|
assert.equal(
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
|
20,
|
|
);
|
|
if (p.elites)
|
|
assert.equal(
|
|
p.elites.reserves +
|
|
p.elites.tanks +
|
|
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
|
|
p.faction === 'emperor' ? 5 : 3,
|
|
);
|
|
}
|
|
const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)];
|
|
assert.equal(new Set(cards.map((c) => c.id)).size, cards.length);
|
|
}
|
|
|
|
function openQuestion(state: Game) {
|
|
const g = reload(state),
|
|
card = hold(g, 'a', 'truthtrance');
|
|
let next = applyAction(g, 'a', { type: 'card', card });
|
|
for (let n = 0; next.truthtrance?.stage === 'priority' && n < 10; n++) {
|
|
const actor = next.players.find(
|
|
(p) => !next.truthtrance!.passed.includes(p.id),
|
|
)!;
|
|
next = applyAction(reload(next), actor.id, { type: 'truthPass' });
|
|
}
|
|
assert.equal(next.truthtrance?.stage, 'ask');
|
|
return next;
|
|
}
|
|
|
|
function ask(g: Game, minimum = 6, territory = 'carthag') {
|
|
return applyAction(openQuestion(g), 'a', {
|
|
type: 'truthAsk',
|
|
question: { kind: 'shipment', target: 'p', territory, minimum },
|
|
});
|
|
}
|
|
|
|
function bind(g: Game, minimum = 6, territory = 'carthag', answer = 'yes') {
|
|
return applyAction(reload(ask(g, minimum, territory)), 'p', {
|
|
type: 'truthAnswer',
|
|
answer,
|
|
});
|
|
}
|
|
|
|
function reject(g: Game, id: string, action: Action) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, id, action));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
|
|
function allow(state: Game) {
|
|
let g = reload(state);
|
|
for (let n = 0; g.response && n < 20; n++) {
|
|
const actor = g.players.find((p) => !g.response!.passed.includes(p.id));
|
|
assert.ok(actor);
|
|
g = applyAction(reload(g), actor.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(g.response, null);
|
|
return g;
|
|
}
|
|
|
|
function witness(g: Game) {
|
|
const projected = viewGame(g, 'p');
|
|
const next = projected.shipmentCompletion?.actions[0];
|
|
assert.ok(
|
|
next,
|
|
'a feasible positive answer supplies an executable next step',
|
|
);
|
|
for (const other of g.players.filter((p) => p.id !== 'p'))
|
|
assert.equal(viewGame(g, other.id).shipmentCompletion, null);
|
|
return next;
|
|
}
|
|
|
|
function finish(state: Game, difficulty?: Difficulty) {
|
|
let g = reload(state);
|
|
for (let n = 0; !player(g).shipped && n < 12; n++) {
|
|
if (g.response) {
|
|
g = allow(g);
|
|
continue;
|
|
}
|
|
const next = witness(g);
|
|
if (difficulty) {
|
|
const view = viewGame(g, 'p');
|
|
view.players.find((p) => p.id === 'p')!.bot = difficulty;
|
|
const before = structuredClone(view),
|
|
proposals = botActions(view);
|
|
assert.deepEqual(view, before);
|
|
assert.deepEqual(
|
|
proposals[0],
|
|
next,
|
|
`${difficulty} uses the authoritative first step`,
|
|
);
|
|
for (const action of proposals)
|
|
assert.doesNotThrow(
|
|
() => applyAction(g, 'p', action),
|
|
JSON.stringify(action),
|
|
);
|
|
}
|
|
g = applyAction(reload(g), 'p', next);
|
|
assertCustody(g);
|
|
}
|
|
assert.equal(player(g).shipped, true);
|
|
assert.equal(g.shipmentPromises?.[0].fulfilled, true);
|
|
assert.equal(g.shipmentPromises?.[0].released, undefined);
|
|
return g;
|
|
}
|
|
|
|
void test('Advanced mixed Ghola returns honor the one-elite cap and retain a feasible smaller physical shipment', () => {
|
|
for (const revived of [0, 1]) {
|
|
const g = fixture();
|
|
pool(g, 0, 5, 0, 2, revived);
|
|
const ghola = hold(g, 'p', 'ghola'),
|
|
maximum = revived ? 3 : 4;
|
|
assert.deepEqual(viewGame(ask(g, maximum + 1), 'p').truthShipmentAnswers, [
|
|
'no',
|
|
]);
|
|
const promised = bind(g, maximum),
|
|
next = witness(promised);
|
|
assert.equal(next.card, ghola);
|
|
assert.equal(next.amount, maximum);
|
|
assert.equal(next.elite, revived ? 0 : 1);
|
|
const done = finish(promised);
|
|
assert.equal(player(done).forces['carthag:11'], maximum);
|
|
assert.equal(
|
|
player(done).elites!.forces['carthag:11'] ?? 0,
|
|
revived ? 0 : 1,
|
|
);
|
|
assert.equal(player(done).elites!.revived, 1);
|
|
assert.equal(player(done).tanks, 5 - maximum);
|
|
assert.equal(done.discard.filter((c) => c.id === ghola).length, 1);
|
|
}
|
|
});
|
|
|
|
void test('Advanced elite-only Tanks permit exactly one Ghola return, or none after the shared quota is spent', () => {
|
|
for (const faction of ['emperor', 'fremen'] as const) {
|
|
const g = fixture(faction);
|
|
pool(g, 0, 2, 0, 2);
|
|
hold(g, 'p', 'ghola');
|
|
const destination = faction === 'fremen' ? 'the_great_flat' : 'carthag';
|
|
assert.deepEqual(
|
|
viewGame(ask(g, 2, destination), 'p').truthShipmentAnswers,
|
|
['no'],
|
|
);
|
|
const promised = bind(g, 1, destination),
|
|
next = witness(promised);
|
|
assert.equal(next.amount, 1);
|
|
assert.equal(next.elite, 1);
|
|
const done = finish(promised);
|
|
assert.equal(player(done).tanks, 1);
|
|
assert.equal(player(done).elites!.tanks, 1);
|
|
assert.equal(player(done).elites!.revived, 1);
|
|
const exhausted = reload(g);
|
|
player(exhausted).elites!.revived = 1;
|
|
assert.deepEqual(
|
|
viewGame(ask(exhausted, 1, destination), 'p').truthShipmentAnswers,
|
|
['no'],
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('Advanced Fremen promise counts physical Fedaykin once and fulfills free reserve reinforcement', () => {
|
|
const g = fixture('fremen');
|
|
pool(g, 6, 0, 3);
|
|
player(g).spice = 0;
|
|
const promised = bind(g, 6, 'the_great_flat');
|
|
reject(promised, 'p', {
|
|
type: 'ship',
|
|
territory: 'the_great_flat',
|
|
sector: 15,
|
|
amount: 3,
|
|
elite: 3,
|
|
});
|
|
const done = applyAction(reload(promised), 'p', {
|
|
type: 'ship',
|
|
territory: 'the_great_flat',
|
|
sector: 15,
|
|
amount: 6,
|
|
elite: 3,
|
|
});
|
|
assert.equal(player(done).forces['the_great_flat:15'], 6);
|
|
assert.equal(player(done).elites!.forces['the_great_flat:15'], 3);
|
|
assert.equal(player(done).spice, 0);
|
|
assert.equal(done.shipmentPromises?.[0].fulfilled, true);
|
|
assertCustody(done);
|
|
});
|
|
|
|
void test('Advanced BG joins existing advisors with a qualifying direct shipment and retains its stance', () => {
|
|
const g = fixture('beneGesserit');
|
|
pool(g, 19);
|
|
player(g).forces = { 'carthag:11': 1 };
|
|
player(g).advisors = { carthag: {} };
|
|
player(g, 'a').forces = { 'carthag:11': 1 };
|
|
player(g, 'a').reserves = 19;
|
|
const done = finish(bind(g, 6));
|
|
assert.equal(player(done).forces['carthag:11'], 7);
|
|
assert.equal(isAdvisor(player(done), 'carthag'), true);
|
|
assertCustody(done);
|
|
});
|
|
|
|
void test('a fulfilled Advanced promise survives a saved BG accompaniment choice without counting the free advisor', () => {
|
|
const g = fixture('emperor', ['beneGesserit']);
|
|
const pending = finish(bind(g));
|
|
assert.equal(pending.decision?.kind, 'advisor');
|
|
assert.equal(pending.decision?.player, 'a');
|
|
const promised = structuredClone(pending.shipmentPromises);
|
|
for (const accept of [true, false]) {
|
|
const continued = allow(
|
|
applyAction(reload(pending), 'a', {
|
|
type: 'decision',
|
|
accept,
|
|
accompany: true,
|
|
sector: 11,
|
|
}),
|
|
);
|
|
assert.deepEqual(continued.shipmentPromises, promised);
|
|
assert.equal(player(continued).forces['carthag:11'], 6);
|
|
assert.equal(
|
|
player(continued, 'a').forces['carthag:11'] ?? 0,
|
|
accept ? 1 : 0,
|
|
);
|
|
assert.equal(isAdvisor(player(continued, 'a'), 'carthag'), accept);
|
|
assert.equal(continued.active, 'p');
|
|
assert.equal(
|
|
continued.discard.filter((c) => c.effect === 'truthtrance').length,
|
|
1,
|
|
);
|
|
assertCustody(continued);
|
|
}
|
|
});
|
|
|
|
void test('BG Worthless shipment preparation remains binding while a real response waits and completes once after allowance', () => {
|
|
const g = fixture('beneGesserit');
|
|
player(g).spice = 3;
|
|
const worthless = hold(g, 'p', 'worthless');
|
|
hold(g, 'a', 'karama');
|
|
const asked = ask(g);
|
|
assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['yes', 'no']);
|
|
for (const id of ['a', 'o'])
|
|
assert.equal(viewGame(asked, id).truthShipmentAnswers, null);
|
|
const promised = applyAction(asked, 'p', {
|
|
type: 'truthAnswer',
|
|
answer: 'yes',
|
|
});
|
|
const next = witness(promised);
|
|
assert.equal(next.card, worthless);
|
|
assert.equal(next.mode, 'shipment');
|
|
const pending = applyAction(reload(promised), 'p', next);
|
|
assert.equal(pending.response?.kind, 'worthlessKarama');
|
|
assert.equal(pending.karamaShipping, null);
|
|
assert.equal(pending.shipmentPromises?.[0].released, undefined);
|
|
assert.equal(pending.shipmentPromises?.[0].fulfilled, undefined);
|
|
assert.equal(pending.discard.filter((c) => c.id === worthless).length, 1);
|
|
const continued = normalizeAutomaticGame(reload(pending));
|
|
assert.equal(continued.response?.kind, 'worthlessKarama');
|
|
const granted = allow(continued);
|
|
assert.equal(granted.karamaShipping?.card, worthless);
|
|
const done = finish(granted);
|
|
assert.equal(player(done).forces['carthag:11'], 6);
|
|
assert.equal(player(done).spice, 0);
|
|
assert.equal(done.discard.filter((c) => c.id === worthless).length, 1);
|
|
assert.equal(
|
|
done.discard.filter((c) => c.effect === 'truthtrance').length,
|
|
1,
|
|
);
|
|
});
|
|
|
|
void test('canceling BG conversion releases an impossible promise once without spending its shipment or spice', () => {
|
|
const g = fixture('beneGesserit');
|
|
player(g).spice = 3;
|
|
const worthless = hold(g, 'p', 'worthless'),
|
|
karama = hold(g, 'a', 'karama');
|
|
const promised = bind(g),
|
|
pending = applyAction(promised, 'p', witness(promised));
|
|
assert.equal(pending.response?.kind, 'worthlessKarama');
|
|
const denied = applyAction(reload(pending), 'a', {
|
|
type: 'card',
|
|
card: karama,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(denied.shipmentPromises?.[0].released, true);
|
|
assert.equal(denied.shipmentPromises?.[0].fulfilled, undefined);
|
|
assert.equal(player(denied).shipped, false);
|
|
assert.equal(player(denied).spice, 3);
|
|
assert.equal(denied.pendingKarama, null);
|
|
assert.equal(denied.karamaShipping, null);
|
|
for (const card of [worthless, karama])
|
|
assert.equal(denied.discard.filter((c) => c.id === card).length, 1);
|
|
const continued = normalizeAutomaticGame(reload(denied));
|
|
assert.equal(
|
|
continued.log.filter((l) =>
|
|
l.text.includes('no longer fulfill a Truthtrance shipment'),
|
|
).length,
|
|
1,
|
|
);
|
|
assert.doesNotThrow(() =>
|
|
applyAction(continued, 'p', { type: 'endMovement' }),
|
|
);
|
|
reject(continued, 'a', { type: 'card', card: karama, mode: 'cancel' });
|
|
assertCustody(continued);
|
|
});
|
|
|
|
void test('an opposing BG conversion cancellation preserves a promise when an owned physical Karama still fulfills it', () => {
|
|
const g = fixture('beneGesserit');
|
|
player(g).spice = 3;
|
|
const worthless = hold(g, 'p', 'worthless');
|
|
const retained = hold(g, 'p', 'karama'),
|
|
counter = hold(g, 'a', 'karama');
|
|
const promised = bind(g);
|
|
assert.equal(witness(promised).card, worthless);
|
|
const pending = applyAction(promised, 'p', witness(promised));
|
|
const denied = applyAction(reload(pending), 'a', {
|
|
type: 'card',
|
|
card: counter,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(denied.shipmentPromises?.[0].released, undefined);
|
|
assert.equal(witness(denied).card, retained);
|
|
const done = finish(denied);
|
|
assert.equal(player(done).forces['carthag:11'], 6);
|
|
assert.equal(player(done).spice, 0);
|
|
for (const card of [worthless, retained, counter])
|
|
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
|
|
});
|
|
|
|
void test('Advanced Yes rejects movement, skip and voluntary evasion; No permits every nonmatching choice', () => {
|
|
const g = fixture();
|
|
pool(g, 6, 0, 2);
|
|
player(g).spice = 6;
|
|
const movement: Action = {
|
|
type: 'move',
|
|
from: 'arrakeen:10',
|
|
territory: 'imperial_basin',
|
|
sector: 10,
|
|
amount: 1,
|
|
elite: 0,
|
|
};
|
|
const positive = bind(g);
|
|
for (const action of [
|
|
movement,
|
|
{ type: 'endMovement' },
|
|
{ type: 'bribe', target: 'a', amount: 1 },
|
|
{ type: 'ship', territory: 'carthag', sector: 11, amount: 5, elite: 1 },
|
|
])
|
|
reject(positive, 'p', action);
|
|
const done = finish(positive);
|
|
assert.doesNotThrow(() =>
|
|
applyAction(reload(done), 'p', { type: 'endMovement' }),
|
|
);
|
|
const negative = bind(g, 6, 'carthag', 'no');
|
|
reject(negative, 'p', {
|
|
type: 'ship',
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
amount: 6,
|
|
elite: 2,
|
|
});
|
|
for (const action of [
|
|
movement,
|
|
{ type: 'endMovement' },
|
|
{ type: 'ship', territory: 'carthag', sector: 11, amount: 5, elite: 1 },
|
|
]) {
|
|
const allowed = applyAction(reload(negative), 'p', action);
|
|
assert.equal(allowed.shipmentPromises?.[0].fulfilled, true);
|
|
assertCustody(allowed);
|
|
}
|
|
});
|
|
|
|
void test('all four profiles execute private typed Ghola and BG conversion witnesses through saved continuations', () => {
|
|
for (const difficulty of DIFFICULTIES)
|
|
for (const faction of ['emperor', 'beneGesserit'] as const) {
|
|
const g = fixture(faction);
|
|
if (faction === 'emperor') {
|
|
pool(g, 2, 5, 0, 2);
|
|
hold(g, 'p', 'ghola');
|
|
player(g).spice = 6;
|
|
} else {
|
|
player(g).spice = 3;
|
|
hold(g, 'p', 'worthless');
|
|
hold(g, 'a', 'karama');
|
|
}
|
|
player(g).bot = difficulty;
|
|
const asked = ask(g),
|
|
view = viewGame(asked, 'p');
|
|
const answers = botActions(view);
|
|
assert.equal(answers.length, 1);
|
|
assert.equal(answers[0].type, 'truthAnswer');
|
|
assert.ok(
|
|
view.truthShipmentAnswers!.some(
|
|
(answer) => answer === answers[0].answer,
|
|
),
|
|
);
|
|
assert.doesNotThrow(() => applyAction(reload(asked), 'p', answers[0]));
|
|
// The profile may strategically prefer No; a legally selected Yes must
|
|
// still be fulfilled through the profile's authoritative preparation path.
|
|
const promised = applyAction(reload(asked), 'p', {
|
|
type: 'truthAnswer',
|
|
answer: 'yes',
|
|
});
|
|
const done = finish(promised, difficulty);
|
|
assert.equal(player(done).forces['carthag:11'], 6);
|
|
assert.equal(player(done).spice, 0);
|
|
assert.equal(
|
|
done.discard.filter((c) => c.effect === 'truthtrance').length,
|
|
1,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('unrelated opponent cards and wealth cannot change Advanced answer or completion projections', () => {
|
|
const g = fixture();
|
|
pool(g, 2, 5, 0, 2);
|
|
hold(g, 'p', 'ghola');
|
|
hold(g, 'o', 'harvester');
|
|
const asked = ask(g),
|
|
altered = reload(asked);
|
|
const rival = player(altered, 'o');
|
|
altered.deck.push(...rival.hand);
|
|
rival.hand = [];
|
|
hold(altered, 'o', 'hajr');
|
|
rival.spice = 999;
|
|
assert.deepEqual(viewGame(altered, 'p'), viewGame(asked, 'p'));
|
|
const original = applyAction(asked, 'p', {
|
|
type: 'truthAnswer',
|
|
answer: 'yes',
|
|
});
|
|
const changed = applyAction(altered, 'p', {
|
|
type: 'truthAnswer',
|
|
answer: 'yes',
|
|
});
|
|
assert.deepEqual(viewGame(changed, 'p'), viewGame(original, 'p'));
|
|
assert.deepEqual(witness(changed), witness(original));
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const left = viewGame(original, 'p'),
|
|
right = viewGame(changed, 'p');
|
|
left.players.find((p) => p.id === 'p')!.bot = difficulty;
|
|
right.players.find((p) => p.id === 'p')!.bot = difficulty;
|
|
assert.deepEqual(botActions(left), botActions(right));
|
|
}
|
|
});
|
|
|
|
void test('duplicate expansion declarations reject unsupported questions immutably before binding or discard', () => {
|
|
const g = fixture('emperor');
|
|
g.expansions = ['ix', 'ix'];
|
|
const pending = openQuestion(g);
|
|
reject(pending, 'a', {
|
|
type: 'truthAsk',
|
|
question: {
|
|
kind: 'shipment',
|
|
target: 'p',
|
|
territory: 'carthag',
|
|
minimum: 6,
|
|
},
|
|
});
|
|
assert.equal(pending.shipmentPromises?.length ?? 0, 0);
|
|
assert.equal(
|
|
pending.discard.filter((c) => c.effect === 'truthtrance').length,
|
|
0,
|
|
);
|
|
assert.equal(
|
|
player(pending, 'a').hand.filter((c) => c.effect === 'truthtrance')
|
|
.length,
|
|
1,
|
|
);
|
|
});
|