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.
108 lines
6.1 KiB
TypeScript
108 lines
6.1 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, createGame, newPlayer, viewGame, type Game } from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { acquireDuke, captureDuke, createDukeVidal } from '../game/duke-vidal';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
|
|
const card = 'richese-residual-poison';
|
|
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
|
// Controlled custody probes. The separate capture programme earns the real
|
|
// cache card, deployments and captive through original actions.
|
|
function fixture(kind: 'ordinary' | 'ghola' | 'duke' = 'ordinary', semuta = false) {
|
|
let g = createGame('POISONCUSTODY', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
|
|
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'),
|
|
newPlayer('e', 'Original owner', kind === 'duke' ? 'ecaz' : 'emperor'));
|
|
if (kind === 'ghola') g.players.push(newPlayer('t', 'Tleilaxu', 'tleilaxu'));
|
|
Object.assign(g, { status: 'playing', phase: 6, turn: 2, storm: 18,
|
|
active: 'r', order: g.players.map(p => p.id), deck: baseDeck(), richeseCache: richeseCards() });
|
|
for (const p of g.players) { p.hand = []; p.forces = {}; p.spice = 10; }
|
|
for (const id of ['r', 'h']) player(g, id).forces = { 'arrakeen:10': 2 };
|
|
const take = (effect: string, owner: string) => {
|
|
const index = g.richeseCache!.findIndex(c => c.effect === effect); assert.ok(index >= 0);
|
|
player(g, owner).hand.push(g.richeseCache!.splice(index, 1)[0]);
|
|
};
|
|
take('residualPoison', 'r');
|
|
if (semuta) { g.semutaPreview = true; take('semutaDrug', 'e'); }
|
|
// Native discs remain alive, but their earlier territory use excludes them.
|
|
for (const leader of player(g, 'h').leaders) leader.usedAt = 'carthag';
|
|
let victim: string;
|
|
if (kind === 'duke') {
|
|
g.dukeVidal = captureDuke(acquireDuke(createDukeVidal(), 'e', 2, 'ecaz'), 'h', 'e', 2, 'carthag');
|
|
victim = g.dukeVidal.leader.id;
|
|
} else {
|
|
const leader = player(g, 'e').leaders[0]; victim = leader.id;
|
|
leader.capturedBy = 'h';
|
|
if (kind === 'ghola') leader.gholaBy = 't';
|
|
leader.concealed = { captor: 'h', controller: kind === 'ghola' ? 't' : 'e', dead: false, deaths: 0 };
|
|
}
|
|
g = applyAction(g, 'r', { type: 'chooseBattle', territory: 'arrakeen', target: 'h' });
|
|
return { game: g, victim };
|
|
}
|
|
function poison(g: Game) {
|
|
return applyAction(g, 'r', { type: 'card', card, target: 'h', event: g.battle!.event });
|
|
}
|
|
function physical(g: Game, id: string) {
|
|
return [...g.players.flatMap(p => p.leaders), ...(g.dukeVidal ? [g.dukeVidal.leader] : [])].find(l => l.id === id)!;
|
|
}
|
|
|
|
for (const kind of ['ordinary', 'ghola', 'duke'] as const) void test(
|
|
`Residual Poison ends ${kind} captivity with one public death and original revival custody`, () => {
|
|
const { game, victim } = fixture(kind), before = structuredClone(game);
|
|
assert.equal(viewGame(game, 'r').residualPoison?.blocked, null);
|
|
const done = poison(game), dead = physical(done, victim);
|
|
assert.deepEqual(game, before);
|
|
assert.equal(dead.dead, true); assert.equal(dead.deaths, 1);
|
|
assert.equal(dead.capturedBy, undefined); assert.equal(dead.concealed, undefined);
|
|
assert.equal(dead.gholaBy, kind === 'ghola' ? 't' : undefined);
|
|
if (kind === 'duke') {
|
|
assert.equal(done.dukeVidal!.controller, null);
|
|
assert.equal(done.dukeVidal!.captureVisibility, undefined);
|
|
assert.equal(done.dukeVidal!.acquiredTurn, null);
|
|
assert.equal(done.dukeVidal!.source, null);
|
|
assert.equal(done.players.some(p => p.leaders.some(l => l.id === victim)), false);
|
|
} else {
|
|
assert.equal(player(done, 'e').leaders.filter(l => l.id === victim).length, 1);
|
|
const publicDead = viewGame(done, 'r').players.find(p => p.id === 'e')!.leaders.find(l => l.id === victim)!;
|
|
assert.equal(publicDead.dead, true); assert.equal(publicDead.deaths, 1);
|
|
assert.equal(publicDead.capturedBy, undefined);
|
|
}
|
|
assert.deepEqual(done.players.map(p => p.spice), game.players.map(p => p.spice));
|
|
assert.deepEqual(done.players.map(p => p.forces), game.players.map(p => p.forces));
|
|
assert.equal(done.discard.filter(c => c.id === card).length, 1);
|
|
assert.equal(done.battle?.event, game.battle?.event);
|
|
assert.deepEqual(done.battle?.plans, {});
|
|
assert.match(done.log.at(-1)!.text, /No spice is awarded/);
|
|
});
|
|
|
|
void test('killing the last native Harkonnen disc returns living captives without executing them', () => {
|
|
const { game, victim } = fixture();
|
|
const natives = player(game, 'h').leaders;
|
|
delete natives[0].usedAt;
|
|
for (const leader of natives.slice(1)) { leader.dead = true; leader.deaths = 1; }
|
|
physical(game, victim).usedAt = 'carthag';
|
|
const done = poison(game), returned = physical(done, victim);
|
|
assert.equal(player(done, 'h').leaders[0].dead, true);
|
|
assert.equal(returned.dead, false); assert.equal(returned.deaths, 0);
|
|
assert.equal(returned.capturedBy, undefined); assert.equal(returned.concealed, undefined);
|
|
assert.equal(returned.usedAt, 'carthag');
|
|
assert.deepEqual(done.players.map(p => p.spice), game.players.map(p => p.spice));
|
|
assert.ok(done.log.some(row => /returned 1 captive leaders/.test(row.text)));
|
|
});
|
|
|
|
for (const kind of ['ordinary', 'duke'] as const) void test(
|
|
`Semuta finds the dead ${kind} physical disc after temporary control ends`, () => {
|
|
const { game, victim } = fixture(kind, true);
|
|
const pending = poison(game), event = pending.pendingTreacheryDiscard!.batch.event;
|
|
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'residualPoisonDiscard');
|
|
assert.equal(physical(pending, victim).capturedBy, undefined);
|
|
assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true);
|
|
const done = applyAction(pending, 'e', { type: 'semutaCommit', event });
|
|
assert.equal(done.pendingTreacheryDiscard, null);
|
|
assert.equal(physical(done, victim).dead, true); assert.equal(physical(done, victim).deaths, 1);
|
|
assert.equal(player(done, 'e').hand.filter(c => c.id === card).length, 1);
|
|
assert.equal(done.battle?.event, game.battle?.event);
|
|
assert.deepEqual(done.players.map(p => p.spice), game.players.map(p => p.spice));
|
|
assert.throws(() => applyAction(done, 'e', { type: 'semutaCommit', event }));
|
|
});
|