Files
Dune/tests/residual-poison-engine-review.test.ts

376 lines
13 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, leaders } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { createDukeVidal, acquireDuke } from '../game/duke-vidal';
function fixture() {
const g = createGame(
'RESIDUALREVIEW',
newPlayer('r', 'Richese', 'richese'),
false,
['choam'],
);
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(),
richeseCache: richeseCards(),
});
for (const p of g.players) {
p.hand = [];
p.spice = 10;
p.forces = {};
p.reserves = 20;
}
for (const p of g.players.slice(0, 2)) {
p.forces = { 'arrakeen:10': 2 };
p.reserves = 18;
}
hold(g, 'r', 'Residual Poison');
return g;
}
function hold(g: Game, id: string, name: string) {
const source = g.richeseCache!.some((c) => c.name === name)
? g.richeseCache!
: g.deck;
const index = source.findIndex((c) => c.name === name);
assert.ok(index >= 0, name);
const card = source.splice(index, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(card);
return card.id;
}
const send = (g: Game, id: string, action: Action) =>
applyAction(g, id, action);
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const start = (g: Game) =>
send(g, 'r', { type: 'chooseBattle', target: 'g', territory: 'arrakeen' });
const ready = (g: Game, id: string) =>
send(g, id, { type: 'battlePreparationReady', event: g.battle!.event });
const poison = (g: Game) =>
send(g, 'r', {
type: 'card',
card: 'richese-residual-poison',
target: 'g',
event: g.battle!.event,
});
const plan = (g: Game, id: string) =>
send(g, id, {
type: 'battlePlan',
leader: g.players.find((p) => p.id === id)!.leaders.find((l) => !l.dead)!
.id,
dial: 1,
});
const inventory = (g: Game) =>
[
...g.deck,
...g.discard,
...g.richeseCache!,
...g.players.flatMap((p) => p.hand),
]
.map((c) => c.id)
.sort();
void test('clean Residual Poison death offers Semuta once before shared battle preparation', () => {
let g = fixture();
g.semutaPreview = true;
hold(g, 'e', 'Semuta Drug');
const original = inventory(g);
g = poison(start(g));
const event = g.pendingTreacheryDiscard!.batch.event;
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(g.battle!.preLeader?.closed, false);
assert.equal(viewGame(g, 'e').semutaReaction?.canCommit, true);
assert.equal(viewGame(g, 'g').semutaReaction?.canCommit, false);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
const corrupt = reload(g);
const victim = corrupt.players[1].leaders.find(l => l.dead)!;
victim.dead = false;
const before = reload(corrupt);
assert.throws(() => send(corrupt, 'e', { type: 'semutaCommit', event }));
assert.deepEqual(corrupt, before);
let declined = reload(g);
for (const id of ['r', 'g', 'e'])
declined = send(declined, id, { type: 'semutaPass', event });
assert.equal(declined.pendingTreacheryDiscard, null);
assert.deepEqual(inventory(declined), original);
const claimed = send(reload(g), 'e', { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(claimed.players[2].hand.filter(c => c.id === 'richese-residual-poison').length, 1);
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(inventory(claimed), original);
assert.deepEqual(claimed.battle!.preLeader?.ready, []);
assert.equal(ready(claimed, 'r').battle!.preLeader?.ready.includes('r'), true);
assert.throws(() => send(claimed, 'e', { type: 'semutaCommit', event }));
});
void test('an already-ready opponent retains readiness through the Residual Poison Semuta offer', () => {
let g = fixture();
g.semutaPreview = true;
hold(g, 'e', 'Semuta Drug');
g = poison(ready(start(g), 'g'));
const event = g.pendingTreacheryDiscard?.batch.event;
assert.ok(event);
assert.deepEqual(g.battle?.preLeader?.ready, ['g']);
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
for (const id of ['r', 'g', 'e']) g = send(g, id, { type: 'semutaPass', event });
assert.equal(g.pendingTreacheryDiscard, null);
assert.deepEqual(g.battle?.preLeader?.ready, ['g']);
assert.equal(g.players[1].leaders.filter(l => l.dead).length, 1);
assert.equal(g.discard.filter(c => c.id === 'richese-residual-poison').length, 1);
});
void test('a fast opponent cannot commit before both genuine preparation declarations, independent of card possession', () => {
const withCard = start(fixture());
const noCard = fixture();
const card = noCard.players[0].hand.pop()!;
noCard.richeseCache!.push(card);
const without = start(noCard);
assert.deepEqual(
{ ...withCard.battle!.preLeader, event: 'same' },
{ ...without.battle!.preLeader, event: 'same' },
);
assert.deepEqual(withCard.battle!.preLeader!.ready, []);
const fast = ready(withCard, 'g');
assert.throws(() => plan(fast, 'g'), /Both combatants/);
const hit = poison(reload(fast));
assert.deepEqual(hit.battle!.preLeader!.ready, ['g']);
assert.equal(hit.players[1].leaders.filter((l) => l.dead).length, 1);
const allowed = ready(hit, 'r');
assert.equal(allowed.battle!.preLeader!.closed, true);
assert.ok(plan(allowed, 'g').battle!.plans.g);
assert.deepEqual(normalizeAutomaticGame(reload(withCard)), reload(withCard));
});
void test('private views and all malformed/stale timing rejections consume no random draw', (t) => {
let g = start(fixture());
t.mock.method(crypto, 'getRandomValues', () => {
throw new Error('unexpected RNG');
});
const before = structuredClone(g);
assert.equal(viewGame(g, 'r').residualPoison!.blocked, null);
assert.equal(viewGame(g, 'g').residualPoison, null);
assert.equal(viewGame(g, 'e').residualPoison, null);
assert.throws(
() =>
send(g, 'r', {
type: 'card',
card: 'richese-residual-poison',
target: 'g',
event: 'old',
}),
/stale/,
);
assert.throws(
() =>
send(g, 'r', {
type: 'card',
card: 'richese-residual-poison',
target: 'g',
event: g.battle!.event,
leader: 'guild-0',
}),
/server/,
);
assert.deepEqual(g, before);
g = ready(ready(g, 'r'), 'g');
g = plan(g, 'g');
assert.throws(() => poison(g), /commits a leader/);
const legacy = reload(before);
delete legacy.battle!.event;
delete legacy.battle!.preLeader;
assert.match(viewGame(legacy, 'r').residualPoison!.blocked!, /predates/);
assert.equal(normalizeAutomaticGame(legacy).battle!.event, undefined);
});
void test('an invalid death transition in any candidate is rejected before random selection and leaves the whole battle unchanged', (t) => {
const g = start(fixture());
g.players[1].leaders[2].deaths = Number.MAX_SAFE_INTEGER;
const before = structuredClone(g);
t.mock.method(crypto, 'getRandomValues', () => {
throw new Error('unexpected RNG before candidate validation');
});
assert.throws(() => poison(g), /overflow/);
assert.deepEqual(g, before);
assert.match(viewGame(g, 'r').residualPoison!.blocked!, /overflow/);
});
void test('an involuntary last-leader death releases a real Truthtrance leader promise without rejecting or rerolling', () => {
let g = fixture();
for (const l of g.players[1].leaders.slice(1)) {
l.dead = true;
l.deaths = 1;
}
const sole = g.players[1].leaders[0].id,
truth = hold(g, 'r', 'Truthtrance');
g = start(g);
g = send(g, 'r', { type: 'card', card: truth });
assert.throws(() => poison(g), /Truth|answer|interaction/i);
while (g.truthtrance!.stage === 'priority')
g = send(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
g = send(g, 'r', {
type: 'truthAsk',
question: {
kind: 'battlePlan',
target: 'g',
claim: { kind: 'leader', leader: sole },
},
});
g = send(g, 'g', { type: 'truthAnswer', answer: 'yes' });
assert.equal(g.battle!.truthPromises![0].released, undefined);
const before = inventory(g),
spice = g.players.map((p) => p.spice),
forces = g.players.map((p) => p.forces);
g = poison(reload(g));
assert.equal(g.battle!.truthPromises![0].released, true);
assert.equal(g.players[1].leaders[0].dead, true);
assert.deepEqual(
g.players.map((p) => p.spice),
spice,
);
assert.deepEqual(
g.players.map((p) => p.forces),
forces,
);
assert.deepEqual(inventory(g), before);
assert.equal(
g.discard.filter((c) => c.id === 'richese-residual-poison').length,
1,
);
g = ready(ready(g, 'r'), 'g');
g = send(g, 'g', { type: 'battlePlan', leader: null, dial: 1 });
assert.equal(g.battle!.plans.g.leader, null);
});
void test('real nonleader Prescience that becomes impossible reopens the same private element, while leader answers wait for readiness', () => {
let g = fixture();
g.players[2] = newPlayer('e', 'Atreides', 'atreides');
g.players[2].ally = 'r';
g.players[0].ally = 'e';
for (const l of g.players[1].leaders.slice(1)) {
l.dead = true;
l.deaths = 1;
}
const weapon = hold(g, 'g', 'Maula Pistol');
g = start(g);
g = send(g, 'e', { type: 'prescience', field: 'weapon' });
g = send(g, 'g', { type: 'prescienceAnswer', value: weapon });
assert.equal(g.battle!.prescience!.value, weapon);
g = poison(reload(g));
assert.equal(g.battle!.preparation!.kind, 'prescienceAnswer');
assert.equal(g.battle!.prescience!.field, 'weapon');
assert.equal('value' in g.battle!.prescience!, false);
assert.ok(!JSON.stringify(viewGame(g, 'e').log).includes(weapon));
g = send(g, 'g', { type: 'prescienceAnswer', value: null });
assert.equal(g.battle!.prescience!.value, null);
let leader = start(fixture());
leader.battle!.prescience = { player: 'r', field: 'leader' };
leader.battle!.preparation = {
kind: 'prescienceAnswer',
owner: 'g',
beneficiary: 'r',
};
assert.throws(
() => send(leader, 'g', { type: 'prescienceAnswer', value: 'guild-0' }),
/Both combatants/,
);
leader = ready(ready(leader, 'r'), 'g');
leader = send(leader, 'g', { type: 'prescienceAnswer', value: 'guild-0' });
assert.throws(() => poison(leader), /commits a leader/);
});
void test('foreign ghola, ordinary captive and separate Duke deaths retain exact physical Tanks history with no bounty', () => {
for (const kind of ['ghola', 'captive', 'duke'] as const) {
let g = fixture();
for (const l of g.players[1].leaders) {
l.dead = true;
l.deaths = 1;
}
let victim: string;
if (kind === 'duke') {
g.dukeVidal = acquireDuke(createDukeVidal(), 'g', 2, 'moritani');
victim = g.dukeVidal.leader.id;
} else {
const l = g.players[2].leaders[0];
victim = l.id;
if (kind === 'ghola') l.gholaBy = 'g';
else l.capturedBy = 'g';
}
g = start(g);
const before = inventory(g);
g = poison(reload(g));
const dead =
kind === 'duke' ? g.dukeVidal!.leader : g.players[2].leaders[0];
assert.equal(dead.id, victim);
assert.equal(dead.dead, true);
assert.equal(dead.deaths, 1);
if (kind === 'ghola') assert.equal(dead.gholaBy, 'g');
if (kind === 'captive') assert.equal(dead.capturedBy, undefined);
if (kind === 'duke') {
assert.equal(g.dukeVidal!.controller, null);
assert.equal(
g.players.flatMap((p) => p.leaders).some((l) => l.id === victim),
false,
);
}
assert.deepEqual(
g.players.map((p) => p.spice),
[10, 10, 10],
);
assert.deepEqual(inventory(g), before);
}
});
void test('Advanced Harkonnen no longer disables the card based on captive possession', () => {
let g = fixture();
g.advanced = true;
g.players[2] = newPlayer('e', 'Harkonnen', 'harkonnen');
g = start(g);
const first = viewGame(g, 'r').residualPoison!.blocked;
assert.equal(first, null);
g.players[1].leaders[0].capturedBy = 'e';
assert.equal(viewGame(g, 'r').residualPoison!.blocked, first);
});
void test('a legitimately reacquired physical Residual Poison may be played again', () => {
let g = start(fixture());
hold(g, 'r', 'Nullentropy Box');
const all = inventory(g);
g = poison(g);
g = send(g, 'r', { type: 'card', card: 'richese-nullentropy-box' });
assert.ok(g.players[0].hand.some((c) => c.id === 'richese-residual-poison'));
g = poison(reload(g));
assert.equal(g.players[1].leaders.filter((l) => l.dead).length, 2);
assert.equal(
g.discard.filter((c) => c.id === 'richese-residual-poison').length,
1,
);
assert.deepEqual(inventory(g), all);
assert.equal(g.players[0].spice, 8);
assert.ok(leaders('guild').length === 5);
});