376 lines
13 KiB
TypeScript
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);
|
|
});
|