Files
Dune/tests/portable-snooper-review.test.ts
T

318 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
normalizeAutomaticGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck, ixBattleCards } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
const portable = 'richese-portable-snooper';
function fixture() {
const g = createGame(
'PORTABLEREVIEW',
newPlayer('r', 'Richese', 'richese'),
false,
['choam', 'ix'],
);
g.players.push(
newPlayer('g', 'Guild', 'guild'),
newPlayer('e', 'Emperor', 'emperor'),
);
Object.assign(g, {
status: 'playing',
phase: 6,
turn: 2,
active: 'r',
order: ['r', 'g', 'e'],
storm: 18,
deck: [...baseDeck(), ...ixBattleCards()],
richeseCache: richeseCards(),
});
for (const p of g.players) {
p.hand = [];
p.spice = 10;
p.forces = {};
p.reserves = 20;
p.traitors = [];
for (const l of p.leaders) l.strength = 0;
}
for (const p of g.players.slice(0, 2)) {
p.forces = { 'arrakeen:10': 5 };
p.reserves = 15;
}
hold(g, 'r', 'Portable Snooper');
return g;
}
function hold(g: Game, id: string, name: string) {
const source = g.richeseCache!.some((c) => c.name === name)
? g.richeseCache!
: g.deck;
const i = source.findIndex((c) => c.name === name);
assert.ok(i >= 0, name);
const c = source.splice(i, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(c);
return c.id;
}
const send = (g: Game, id: string, a: Action) => applyAction(g, id, a);
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const start = (g: Game) =>
send(g, 'r', { type: 'chooseBattle', territory: 'arrakeen', target: 'g' });
function ready(g: Game) {
for (const id of ['r', 'g'])
g = send(g, id, { type: 'battlePreparationReady', event: g.battle!.event });
return g;
}
const plan = (g: Game, id: string, extra: Partial<Action> = {}) =>
send(g, id, {
type: 'battlePlan',
dial: id === 'r' ? 3 : 1,
leader: g.players.find((p) => p.id === id)!.leaders[0].id,
...extra,
});
function reveal(
g: Game,
own: Partial<Action> = {},
other: Partial<Action> = {},
) {
g = ready(g);
return plan(plan(g, 'r', own), 'g', other);
}
const late = (g: Game) =>
send(g, 'r', {
type: 'portableSnooper',
card: portable,
event: g.battle!.event,
});
function finish(g: Game) {
for (const id of ['r', 'g'])
if (g.battle?.traitorCalls[id] === undefined)
g = send(g, id, { type: 'traitorCall', call: false });
// Fresh Richese battles retain their shared final Sapho opportunity after
// traitor declarations. Finish that actual native window before asserting
// outcomes or winner/loser card-cleanup decisions.
while (g.battle?.outcomeWindow && !g.battle.outcomeWindow.closed) {
const window = g.battle.outcomeWindow;
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id));
assert.ok(actor, 'The final battle window retains an unfinished combatant');
g = send(g, actor, { type: 'battleOutcomeReady', event: window.event });
}
return g;
}
function cleanup(g: Game) {
for (
let i = 0;
g.decision &&
['battleCards', 'battleLosses', 'ixSubstitution'].includes(
g.decision.kind,
) &&
i < 5;
i++
)
g = send(
g,
g.decision.player,
g.decision.kind === 'battleCards'
? { type: 'decision', discard: [] }
: g.decision.kind === 'battleLosses'
? { type: 'decision', choice: 0 }
: { type: 'decision', amount: 0 },
);
return g;
}
const inventory = (g: Game) =>
[
...g.deck,
...g.discard,
...g.richeseCache!,
...g.players.flatMap((p) => p.hand),
]
.map((c) => c.id)
.sort();
void test('ordinary and late Portable both protect and enter the normal winner retention choice without changing sealed commitments', () => {
for (const normal of [true, false]) {
let g = fixture();
const weapon = hold(g, 'g', 'Chaumas');
const before = inventory(g);
g = reveal(start(g), normal ? { defense: portable } : {}, { weapon });
const original = structuredClone(g.battle!.plans);
if (!normal) g = late(reload(g));
assert.deepEqual(g.battle!.plans, original);
g = finish(g);
assert.equal(g.players[0].leaders[0].dead, false);
assert.equal(g.decision?.kind, 'battleCards');
assert.ok(
g.decision?.kind === 'battleCards' && g.decision.cards.includes(portable),
);
g = cleanup(reload(g));
assert.ok(g.players[0].hand.some((c) => c.id === portable));
assert.equal(
g.discard.some((c) => c.id === portable),
false,
);
assert.deepEqual(inventory(g), before);
}
});
void test('a losing Moritani ally may retain the late physical Portable while the original lone Worthless is discarded once', () => {
let g = fixture();
g.players[2] = newPlayer('e', 'Moritani', 'moritani');
g.players[2].ally = 'r';
g.players[0].ally = 'e';
const worthless = hold(g, 'r', 'Baliset'),
weapon = hold(g, 'g', 'Chaumas'),
before = inventory(g);
g = reveal(start(g), { dial: 1, defense: worthless }, { dial: 3, weapon });
const sealed = structuredClone(g.battle!.plans.r);
g = late(g);
assert.deepEqual(g.battle!.plans.r, sealed);
g = cleanup(finish(g));
assert.equal(g.decision?.kind, 'moritaniRetention');
assert.deepEqual(
g.moritaniRetention!.played.sort(),
[portable, worthless].sort(),
);
assert.ok(g.moritaniRetention!.eligible.includes(portable));
g = send(reload(g), 'r', { type: 'decision', keep: portable });
assert.ok(g.players[0].hand.some((c) => c.id === portable));
assert.equal(g.discard.filter((c) => c.id === worthless).length, 1);
assert.deepEqual(inventory(g), before);
});
void test('real Voice sees Portable as compulsory original poison defense and forbids its late use when prohibited', () => {
for (const must of [true, false]) {
let g = fixture();
g.players[2] = newPlayer('e', 'Bene Gesserit', 'beneGesserit');
g.players[2].ally = 'g';
g.players[1].ally = 'e';
g = start(g);
g = send(g, 'e', { type: 'voice', kind: 'snooper', must });
g = ready(g);
if (must) {
assert.throws(() => plan(g, 'r'), /Voice/);
g = plan(g, 'r', { defense: portable });
assert.equal(g.battle!.plans.r.defense, portable);
} else {
assert.throws(() => plan(g, 'r', { defense: portable }), /Voice/);
g = plan(plan(g, 'r'), 'g');
const before = structuredClone(g);
assert.throws(() => late(g), /Voice/);
assert.deepEqual(g, before);
}
}
});
function ask(g: Game, name: string | null) {
const card = hold(g, 'g', 'Truthtrance');
g = send(g, 'g', { type: 'card', card });
while (g.truthtrance!.stage === 'priority')
g = send(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
g = send(g, 'g', {
type: 'truthAsk',
question: {
kind: 'battlePlan',
target: 'r',
claim: { kind: 'defense', name },
},
});
return send(g, 'r', { type: 'truthAnswer', answer: 'yes' });
}
void test('real Prescience and Truthtrance preserve an inspected empty original slot while a canonical-name promise supports normal Portable', () => {
let g = fixture();
g.players[2] = newPlayer('e', 'Atreides', 'atreides');
g.players[2].ally = 'g';
g.players[1].ally = 'e';
g = start(g);
g = send(g, 'e', { type: 'prescience', field: 'defense' });
g = send(g, 'r', { type: 'prescienceAnswer', value: null });
g = ask(g, null);
g = reveal(g);
const plans = structuredClone(g.battle!.plans),
promises = structuredClone(g.battle!.truthPromises);
g = late(reload(g));
assert.deepEqual(g.battle!.plans, plans);
assert.deepEqual(g.battle!.truthPromises, promises);
assert.equal(g.battle!.prescience!.value, null);
let ordinary = ask(start(fixture()), 'Portable Snooper');
ordinary = ready(ordinary);
assert.throws(() => plan(ordinary, 'r'), /Truthtrance/);
ordinary = plan(ordinary, 'r', { defense: portable });
assert.equal(ordinary.battle!.plans.r.defense, portable);
});
void test('late played card cannot be transferred by Distrans or Richese gift and corrupted saved custody cannot normalize or resolve', () => {
let g = fixture();
g.players[0].ally = 'e';
g.players[2].ally = 'r';
hold(g, 'r', 'Distrans');
g = late(reveal(start(g)));
const before = structuredClone(g);
assert.throws(
() =>
send(g, 'r', {
type: 'card',
card: 'richese-distrans',
give: portable,
target: 'e',
}),
/reserved|played|committed/,
);
assert.throws(
() => send(g, 'r', { type: 'richeseGift', card: portable }),
/reserved|played|committed/,
);
assert.deepEqual(g, before);
const corrupt = reload(g);
corrupt.players[0].hand = corrupt.players[0].hand.filter(
(c) => c.id !== portable,
);
assert.throws(() => normalizeAutomaticGame(corrupt), /missing/);
assert.throws(() => finish(corrupt), /missing/);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
});
void test('Poison Tooth activation remains a genuine preceding choice and Portable never blocks its poison', () => {
let g = fixture();
const tooth = hold(g, 'g', 'Poison Tooth');
g = reveal(start(g), {}, { weapon: tooth });
assert.equal(g.decision?.kind, 'poisonTooth');
assert.throws(() => late(g), /interaction|decision/);
g = send(g, 'g', { type: 'decision', activate: true });
g = late(g);
g = finish(g);
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[1].leaders[0].dead, true);
assert.equal(g.discard.filter((c) => c.id === tooth).length, 1);
g = cleanup(g);
assert.ok(g.players[0].hand.some((c) => c.id === portable));
});
void test('public reveal contains Portable only after use and an opponent declaration cannot bypass the owner opportunity', (t) => {
let g = reveal(start(fixture()));
t.mock.method(crypto, 'getRandomValues', () => {
throw new Error('unexpected RNG');
});
assert.equal(viewGame(g, 'g').portableSnooper, null);
assert.deepEqual(viewGame(g, 'g').battle!.lateDefense, {});
assert.equal(viewGame(g, 'r').portableSnooper!.blocked, null);
g = send(g, 'g', { type: 'traitorCall', call: false });
assert.ok(g.battle);
g = late(g);
assert.equal(viewGame(g, 'g').battle!.lateDefense.r, portable);
assert.ok(viewGame(g, 'g').battle!.cards.some((c) => c.id === portable));
assert.throws(() => late(g), /already added/);
let passed = reveal(start(fixture()));
passed = send(passed, 'r', { type: 'traitorCall', call: false });
assert.throws(() => late(passed), /closed/);
});