538 lines
18 KiB
TypeScript
538 lines
18 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { baseDeck, ixBattleCards, type Card } from '../game/cards';
|
|
import { cashInCards } from '../game/choam-karama';
|
|
import type { FactionId } from '../game/catalog';
|
|
const cards = [...baseDeck(), ...ixBattleCards()];
|
|
const card = (kind: Card['kind']) =>
|
|
structuredClone(cards.find((c) => c.kind === kind)!);
|
|
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
/** Fixture transfers use the one physical catalog inventory. Replaced setup
|
|
* cards return to its deck; no gameplay action or invalid snapshot is repaired. */
|
|
function setHand(g: Game, id: string, selected: Card[]) {
|
|
const owner = p(g, id);
|
|
const keep = new Set(selected.map((c) => c.id));
|
|
assert.equal(keep.size, selected.length);
|
|
const previous = new Map(owner.hand.map((c) => [c.id, c]));
|
|
for (const held of owner.hand)
|
|
if (!keep.has(held.id)) {
|
|
assert.ok(!g.deck.some((c) => c.id === held.id));
|
|
g.deck.push(held);
|
|
}
|
|
owner.hand = selected.map((wanted) => {
|
|
if (previous.has(wanted.id)) return previous.get(wanted.id)!;
|
|
assert.ok(
|
|
!g.players.some(
|
|
(other) =>
|
|
other.id !== id && other.hand.some((c) => c.id === wanted.id),
|
|
),
|
|
);
|
|
const at = g.deck.findIndex((c) => c.id === wanted.id);
|
|
assert.ok(
|
|
at >= 0,
|
|
`Physical fixture card ${wanted.id} must be in the deck`,
|
|
);
|
|
return g.deck.splice(at, 1)[0];
|
|
});
|
|
}
|
|
function replaceCard(g: Game, id: string, index: number, replacement: Card) {
|
|
setHand(
|
|
g,
|
|
id,
|
|
p(g, id).hand.map((held, at) => (at === index ? replacement : held)),
|
|
);
|
|
}
|
|
function fixture(loser: FactionId = 'guild', winner: FactionId = 'emperor') {
|
|
const g = createGame('MRETAIN1', newPlayer('w', 'Winner', winner));
|
|
g.players.push(
|
|
newPlayer('l', 'Defeated ally', loser),
|
|
newPlayer('m', 'Moritani', 'moritani'),
|
|
);
|
|
g.status = 'playing';
|
|
g.phase = 6;
|
|
g.turn = 2;
|
|
g.storm = 18;
|
|
g.order = ['w', 'l', 'm'];
|
|
g.active = 'w';
|
|
g.deck = structuredClone(cards);
|
|
for (const player of g.players) {
|
|
player.forces = player.id === 'm' ? {} : { 'arrakeen:10': 5 };
|
|
player.reserves = player.id === 'm' ? 20 : 15;
|
|
player.spice = 20;
|
|
player.hand = [];
|
|
player.traitors = [];
|
|
for (const l of player.leaders) l.strength = 0;
|
|
}
|
|
p(g, 'm').ally = 'l';
|
|
p(g, 'l').ally = 'm';
|
|
setHand(g, 'l', [card('projectile'), card('snooper'), card('worthless')]);
|
|
return g;
|
|
}
|
|
function allow(g: Game) {
|
|
for (let i = 0; g.response && i < 40; i++)
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
assert.equal(g.response, null);
|
|
return g;
|
|
}
|
|
function fight(
|
|
state = fixture(),
|
|
opts: {
|
|
weapon?: string | null;
|
|
defense?: string | null;
|
|
hero?: string;
|
|
calls?: string[];
|
|
tooth?: boolean;
|
|
winnerWeapon?: string;
|
|
winnerDefense?: string;
|
|
winnerSupport?: number;
|
|
} = {},
|
|
) {
|
|
let g = allow(
|
|
applyAction(state, 'w', {
|
|
type: 'chooseBattle',
|
|
territory: 'arrakeen',
|
|
target: 'l',
|
|
}),
|
|
);
|
|
for (let i = 0; g.battle?.preparation && i < 20; i++)
|
|
g = allow(
|
|
applyAction(g, g.battle.preparation.owner, {
|
|
type: 'declineBattlePower',
|
|
}),
|
|
);
|
|
g = applyAction(g, 'w', {
|
|
type: 'battlePlan',
|
|
dial: 3,
|
|
support: opts.winnerSupport ?? 3,
|
|
leader: p(g, 'w').leaders[0].id,
|
|
weapon: opts.winnerWeapon,
|
|
defense: opts.winnerDefense,
|
|
});
|
|
g = applyAction(g, 'l', {
|
|
type: 'battlePlan',
|
|
dial: 0,
|
|
support: 0,
|
|
leader: opts.hero ?? p(g, 'l').leaders[0].id,
|
|
weapon: opts.weapon === undefined ? p(g, 'l').hand[0].id : opts.weapon,
|
|
defense: opts.defense === undefined ? p(g, 'l').hand[1].id : opts.defense,
|
|
});
|
|
while (g.decision?.kind === 'poisonTooth')
|
|
g = applyAction(g, g.decision.player, {
|
|
type: 'decision',
|
|
activate: opts.tooth ?? false,
|
|
});
|
|
for (const id of ['w', 'l'])
|
|
if (g.battle)
|
|
g = applyAction(g, id, {
|
|
type: 'traitorCall',
|
|
call: opts.calls?.includes(id) ?? false,
|
|
});
|
|
return g;
|
|
}
|
|
function cleanup(g: Game) {
|
|
for (
|
|
let i = 0;
|
|
g.decision &&
|
|
['battleLosses', 'battleCards', 'ixSubstitution'].includes(
|
|
g.decision.kind,
|
|
) &&
|
|
i < 10;
|
|
i++
|
|
) {
|
|
const d = g.decision;
|
|
g = applyAction(
|
|
g,
|
|
d.player,
|
|
d.kind === 'battleCards'
|
|
? { type: 'decision', discard: [] }
|
|
: d.kind === 'ixSubstitution'
|
|
? { type: 'decision', amount: 0 }
|
|
: { type: 'decision', choice: 0 },
|
|
);
|
|
}
|
|
return g;
|
|
}
|
|
function reject(g: Game, id: string, action: Action) {
|
|
const old = structuredClone(g);
|
|
assert.throws(() => applyAction(g, id, action));
|
|
assert.deepEqual(g, old);
|
|
}
|
|
function counts(g: Game) {
|
|
return g.players
|
|
.flatMap((p) => p.hand)
|
|
.concat(g.discard)
|
|
.map((c) => c.id)
|
|
.sort();
|
|
}
|
|
|
|
void test('unopposed defeated ally retains exactly one played card automatically through JSON recovery', () => {
|
|
for (const pick of [0, 1]) {
|
|
const before = fixture();
|
|
const inventory = counts(before);
|
|
let g = cleanup(fight(before));
|
|
assert.equal(g.decision?.kind, 'moritaniRetention');
|
|
assert.equal(g.decision?.player, 'l');
|
|
assert.equal(p(g, 'l').tanks, 5);
|
|
assert.equal(p(g, 'w').tanks, 3);
|
|
const played = g.moritaniRetention!.played;
|
|
const keep = played[pick];
|
|
assert.equal(played.length, 2);
|
|
assert.ok(played.every((id) => p(g, 'l').hand.some((c) => c.id === id)));
|
|
const settled = g.players.map((p) => ({
|
|
id: p.id,
|
|
spice: p.spice,
|
|
tanks: p.tanks,
|
|
}));
|
|
g = applyAction(reload(g), 'l', { type: 'decision', keep });
|
|
assert.equal(
|
|
g.response,
|
|
null,
|
|
'no eligible cancellation requires no pass clicks',
|
|
);
|
|
g = allow(reload(g));
|
|
assert.equal(g.moritaniRetention, null);
|
|
assert.equal(g.phase, 7);
|
|
assert.deepEqual(
|
|
g.players.map((p) => ({ id: p.id, spice: p.spice, tanks: p.tanks })),
|
|
settled,
|
|
);
|
|
assert.deepEqual(counts(g), inventory);
|
|
assert.ok(p(g, 'l').hand.some((c) => c.id === keep));
|
|
assert.equal(g.discard.filter((c) => played.includes(c.id)).length, 1);
|
|
reject(g, 'l', { type: 'decision', keep });
|
|
}
|
|
});
|
|
void test('declining needs no Karama window and keeps unrelated hand cards untouched', () => {
|
|
let g = cleanup(fight());
|
|
const unplayed = p(g, 'l').hand[2].id;
|
|
g = applyAction(g, 'l', { type: 'decision', keep: null });
|
|
assert.equal(g.response, null);
|
|
assert.deepEqual(
|
|
p(g, 'l').hand.map((c) => c.id),
|
|
[unplayed],
|
|
);
|
|
assert.equal(g.discard.length, 2);
|
|
});
|
|
void test('only the defeated ally may choose one eligible exact card', () => {
|
|
const g = cleanup(fight());
|
|
const id = g.moritaniRetention!.eligible[0];
|
|
reject(g, 'm', { type: 'decision', keep: id });
|
|
reject(g, 'w', { type: 'decision', keep: id });
|
|
for (const keep of [
|
|
undefined,
|
|
[],
|
|
[id],
|
|
p(g, 'l').hand[2].id,
|
|
'opposing-card',
|
|
])
|
|
reject(g, 'l', { type: 'decision', keep });
|
|
});
|
|
void test('Karama cancels retention without reversing combat or duplicating disposal', () => {
|
|
let g = cleanup(fight());
|
|
const k = baseDeck().find((c) => c.effect === 'karama')!;
|
|
setHand(g, 'w', [k]);
|
|
const losses = g.players.map((p) => p.tanks);
|
|
g = applyAction(g, 'l', {
|
|
type: 'decision',
|
|
keep: g.moritaniRetention!.eligible[0],
|
|
});
|
|
g = applyAction(reload(g), 'w', { type: 'card', card: k.id, mode: 'cancel' });
|
|
assert.equal(g.moritaniRetention, null);
|
|
assert.deepEqual(
|
|
g.players.map((p) => p.tanks),
|
|
losses,
|
|
);
|
|
assert.equal(g.discard.length, 3);
|
|
assert.equal(p(g, 'l').hand.length, 1);
|
|
assert.equal(g.phase, 7);
|
|
});
|
|
void test('no alliance and Moritani itself do not receive the losing-ally benefit', () => {
|
|
const g = fixture();
|
|
p(g, 'm').ally = null;
|
|
p(g, 'l').ally = null;
|
|
assert.equal(cleanup(fight(g)).moritaniRetention, undefined);
|
|
const self = fixture('moritani');
|
|
p(self, 'm').faction = 'guild';
|
|
assert.equal(cleanup(fight(self)).moritaniRetention, undefined);
|
|
});
|
|
void test('a loss with no retainable played card skips the optional decision', () => {
|
|
for (const heroOnly of [false, true]) {
|
|
const g = fixture();
|
|
const hero = card('hero');
|
|
setHand(g, 'l', heroOnly ? [hero] : []);
|
|
const out = cleanup(
|
|
fight(g, {
|
|
weapon: null,
|
|
defense: null,
|
|
...(heroOnly ? { hero: hero.id } : {}),
|
|
}),
|
|
);
|
|
assert.equal(out.moritaniRetention, undefined);
|
|
assert.equal(out.decision, null);
|
|
assert.equal(out.phase, 7);
|
|
assert.equal(p(out, 'l').hand.length, 0);
|
|
assert.equal(
|
|
out.discard.filter((c) => c.id === hero.id).length,
|
|
heroOnly ? 1 : 0,
|
|
);
|
|
}
|
|
});
|
|
void test('Cheap Hero cannot be retained but an eligible defense can', () => {
|
|
const g = fixture();
|
|
const hero = card('hero');
|
|
setHand(g, 'l', [...p(g, 'l').hand, hero]);
|
|
const out = cleanup(fight(g, { hero: hero.id }));
|
|
assert.ok(!out.moritaniRetention!.eligible.includes(hero.id));
|
|
const kept = allow(
|
|
applyAction(out, 'l', {
|
|
type: 'decision',
|
|
keep: out.moritaniRetention!.eligible[1],
|
|
}),
|
|
);
|
|
assert.equal(kept.discard.filter((c) => c.id === hero.id).length, 1);
|
|
});
|
|
void test('used Tooth and Artillery are excluded; unused Tooth remains eligible', () => {
|
|
for (const kind of ['poisonTooth', 'artillery'] as const)
|
|
for (const tooth of [false, true]) {
|
|
const g = fixture();
|
|
const special = card(kind);
|
|
replaceCard(g, 'l', 0, special);
|
|
const out = cleanup(fight(g, { tooth }));
|
|
assert.equal(
|
|
out.moritaniRetention!.eligible.includes(special.id),
|
|
kind === 'poisonTooth' && !tooth,
|
|
);
|
|
}
|
|
});
|
|
void test('single traitor creates retention; mutual traitors and actual explosions do not', () => {
|
|
const single = fixture();
|
|
p(single, 'w').traitors = [p(single, 'l').leaders[0].id];
|
|
assert.equal(
|
|
cleanup(fight(single, { calls: ['w'] })).decision?.kind,
|
|
'moritaniRetention',
|
|
);
|
|
const mutual = fixture();
|
|
p(mutual, 'w').traitors = [p(mutual, 'l').leaders[0].id];
|
|
p(mutual, 'l').traitors = [p(mutual, 'w').leaders[0].id];
|
|
assert.equal(
|
|
cleanup(fight(mutual, { calls: ['w', 'l'] })).moritaniRetention,
|
|
undefined,
|
|
);
|
|
const boom = fixture();
|
|
setHand(boom, 'w', [card('lasgun')]);
|
|
replaceCard(boom, 'l', 1, card('shield'));
|
|
assert.equal(
|
|
cleanup(fight(boom, { winnerWeapon: p(boom, 'w').hand[0].id }))
|
|
.moritaniRetention,
|
|
undefined,
|
|
);
|
|
});
|
|
void test('single traitor preempts explosion and Tooth effect-discard', () => {
|
|
const g = fixture();
|
|
p(g, 'w').traitors = [p(g, 'l').leaders[0].id];
|
|
setHand(g, 'w', [card('lasgun')]);
|
|
replaceCard(g, 'l', 1, card('shield'));
|
|
assert.equal(
|
|
cleanup(fight(g, { calls: ['w'], winnerWeapon: p(g, 'w').hand[0].id }))
|
|
.decision?.kind,
|
|
'moritaniRetention',
|
|
);
|
|
const tooth = fixture();
|
|
p(tooth, 'w').traitors = [p(tooth, 'l').leaders[0].id];
|
|
replaceCard(tooth, 'l', 0, card('poisonTooth'));
|
|
const out = cleanup(fight(tooth, { calls: ['w'], tooth: true }));
|
|
assert.ok(out.moritaniRetention!.eligible.includes(card('poisonTooth').id));
|
|
});
|
|
void test('reservation begins before winner casualty cleanup and blocks cash-in', () => {
|
|
const g = fixture();
|
|
g.advanced = true;
|
|
p(g, 'w').elites = {
|
|
reserves: 3,
|
|
tanks: 0,
|
|
forces: { 'arrakeen:10': 2 },
|
|
revived: 0,
|
|
};
|
|
const out = fight(g, { winnerSupport: 1 });
|
|
assert.equal(out.decision?.kind, 'battleLosses');
|
|
assert.ok(
|
|
out.decision?.kind === 'battleLosses' && out.decision.options.length > 1,
|
|
);
|
|
assert.equal(p(out, 'w').forces['arrakeen:10'], 5);
|
|
assert.equal(p(out, 'w').tanks, 0);
|
|
assert.ok(out.moritaniRetention);
|
|
const reserved = out.moritaniRetention!.played;
|
|
assert.ok(
|
|
cashInCards(out, p(out, 'l')).every((c) => !reserved.includes(c.id)),
|
|
);
|
|
const after = cleanup(reload(out));
|
|
assert.equal(after.decision?.kind, 'moritaniRetention');
|
|
assert.ok(p(after, 'w').tanks > 0);
|
|
});
|
|
void test('Bene Gesserit cannot convert a reserved Worthless card during retention', () => {
|
|
const g = fixture('beneGesserit');
|
|
g.advanced = true;
|
|
const w = card('worthless');
|
|
setHand(g, 'l', [w, card('snooper')]);
|
|
setHand(g, 'w', [baseDeck().find((c) => c.effect === 'karama')!]);
|
|
let out = cleanup(fight(g));
|
|
out = applyAction(out, 'l', { type: 'decision', keep: w.id });
|
|
reject(out, 'l', { type: 'card', mode: 'cancel', card: w.id });
|
|
assert.equal(out.moritaniRetention?.stage, 'response');
|
|
assert.ok(p(out, 'l').hand.some((c) => c.id === w.id));
|
|
});
|
|
void test('CHOAM cannot cash in any pending played card, including during winner cleanup', () => {
|
|
const g = fixture();
|
|
g.advanced = true;
|
|
let out = fight(g);
|
|
p(out, 'l').faction = 'choam';
|
|
const k = baseDeck().find((c) => c.effect === 'karama')!;
|
|
setHand(out, 'l', [...p(out, 'l').hand, k]);
|
|
reject(out, 'l', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: k.id,
|
|
cards: [out.moritaniRetention!.played[0]],
|
|
});
|
|
out = cleanup(out);
|
|
reject(out, 'l', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: k.id,
|
|
cards: [out.moritaniRetention!.played[0]],
|
|
});
|
|
});
|
|
void test('public retention data contains only revealed played cards and pure views do not leak unplayed hand', () => {
|
|
const g = cleanup(fight());
|
|
const before = structuredClone(g);
|
|
const unplayed = p(g, 'l').hand[2].id;
|
|
for (const id of ['w', 'm']) {
|
|
const view = viewGame(g, id);
|
|
assert.ok(!JSON.stringify(view.moritaniRetention).includes(unplayed));
|
|
assert.equal(view.players.find((p) => p.id === 'l')!.hand, undefined);
|
|
}
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('Harkonnen capture and Face Dancer continuations wait until retention settles', () => {
|
|
const g = fixture('guild', 'harkonnen');
|
|
g.advanced = true;
|
|
g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
|
g.order.push('t');
|
|
let out = cleanup(fight(g));
|
|
assert.equal(out.decision?.kind, 'moritaniRetention');
|
|
assert.ok(out.pendingCapture);
|
|
assert.ok(out.pendingFaceDance);
|
|
out = applyAction(reload(out), 'l', { type: 'decision', keep: null });
|
|
assert.equal(out.decision?.kind, 'captureOffer');
|
|
out = applyAction(out, 'w', { type: 'decision', accept: false });
|
|
assert.equal(out.decision?.kind, 'faceDance');
|
|
out = applyAction(out, 't', { type: 'decision', reveal: false });
|
|
assert.equal(out.phase, 7);
|
|
assert.equal(out.pendingFaceDance, null);
|
|
});
|
|
|
|
void test('nested Worthless conversion preserves pending card custody whether conversion succeeds or is canceled', () => {
|
|
for (const cancelConversion of [false, true]) {
|
|
const g = fixture();
|
|
g.advanced = true;
|
|
g.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
|
g.order.push('b');
|
|
const worthless = baseDeck()
|
|
.filter((c) => c.kind === 'worthless')
|
|
.at(-1)!;
|
|
setHand(g, 'b', [worthless]);
|
|
const k = baseDeck().find((c) => c.effect === 'karama')!;
|
|
setHand(g, 'w', [k]);
|
|
let out = cleanup(fight(g));
|
|
const keep = out.moritaniRetention!.eligible[0];
|
|
const played = [...out.moritaniRetention!.played];
|
|
out = applyAction(out, 'l', { type: 'decision', keep });
|
|
out = applyAction(out, 'b', {
|
|
type: 'card',
|
|
card: worthless.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(out.response?.kind, 'worthlessKarama');
|
|
assert.deepEqual(out.moritaniRetention!.played, played);
|
|
if (cancelConversion) {
|
|
out = applyAction(reload(out), 'w', {
|
|
type: 'card',
|
|
card: k.id,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(
|
|
out.response,
|
|
null,
|
|
'exhausted nested blockers allow retention automatically',
|
|
);
|
|
assert.equal(out.moritaniRetention, null);
|
|
assert.ok(p(out, 'l').hand.some((c) => c.id === keep));
|
|
}
|
|
out = allow(reload(out));
|
|
assert.equal(out.moritaniRetention, null);
|
|
assert.equal(
|
|
p(out, 'l').hand.some((c) => c.id === keep),
|
|
cancelConversion,
|
|
);
|
|
for (const id of played)
|
|
assert.equal(
|
|
p(out, 'l').hand.filter((c) => c.id === id).length +
|
|
out.discard.filter((c) => c.id === id).length,
|
|
1,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('retention resumes the already calculated CHOAM combat income once', () => {
|
|
const g = fixture();
|
|
g.advanced = true;
|
|
g.players.push(newPlayer('c', 'CHOAM', 'choam'));
|
|
g.order.push('c');
|
|
let out = cleanup(fight(g));
|
|
assert.equal(out.decision?.kind, 'moritaniRetention');
|
|
const pending = out.pendingChoamBattleIncome;
|
|
assert.ok(pending);
|
|
const spice = p(out, 'c').spice;
|
|
out = applyAction(reload(out), 'l', { type: 'decision', keep: null });
|
|
assert.equal(
|
|
out.response,
|
|
null,
|
|
'unopposed combat income settles once after retention',
|
|
);
|
|
out = allow(reload(out));
|
|
assert.equal(p(out, 'c').spice, spice + pending.amount);
|
|
assert.equal(out.pendingChoamBattleIncome, null);
|
|
});
|
|
|
|
void test('Karama responders can inspect the declared already-revealed card without receiving the remaining hand', () => {
|
|
const initial = fixture();
|
|
setHand(initial, 'w', [baseDeck().find((c) => c.effect === 'karama')!]);
|
|
let g = cleanup(fight(initial));
|
|
assert.equal(viewGame(g, 'm').moritaniRetentionCard, null);
|
|
const keep = g.moritaniRetention!.eligible[0];
|
|
g = applyAction(g, 'l', { type: 'decision', keep });
|
|
for (const id of ['w', 'l', 'm'])
|
|
assert.deepEqual(
|
|
viewGame(g, id).moritaniRetentionCard,
|
|
p(g, 'l').hand.find((c) => c.id === keep),
|
|
);
|
|
assert.equal(
|
|
viewGame(g, 'w').players.find((p) => p.id === 'l')!.hand,
|
|
undefined,
|
|
);
|
|
g = allow(g);
|
|
assert.equal(viewGame(g, 'm').moritaniRetentionCard, null);
|
|
});
|