Include available ordinary captives, captured foreign gholas and shared Duke in the original random pool. Apply one public ordinary death without execution income, preserve foreign revival custody and other concealed captives, and end Duke tenure. Find the signed Semuta victim by canonical physical identity after captivity ends. Explain the provisional normal-Tanks source composition in controls and developer reference. Validation:9 files74/74, types/lint/build and two actual-rule/rendered-table programmes plus four finite policy continuations pass on one frozen source. Details and exact unchanged-source proof are recorded in the canonical Residual Poison contract. Genuine public Advanced cache purchase, funded deployments, native victory/capture/keep and second battle cover native and captive deaths plus finite completion. Four minimal profiles use legal offered card/readiness actions. Separate controlled cases cover ghola/Duke, last-native return and Semuta claim; no strategic AI refinement. This interpretation remains provisional. Wider interactions, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
371 lines
13 KiB
TypeScript
371 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 the automatic Residual Poison suffix', () => {
|
|
let g = fixture();
|
|
g.semutaPreview = true;
|
|
hold(g, 'e', 'Semuta Drug');
|
|
g = poison(ready(start(g), 'g'));
|
|
assert.equal(g.pendingTreacheryDiscard ?? null, 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);
|
|
});
|