Enable Residual Poison against Advanced Harkonnen captives

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.
This commit is contained in:
Quintine committed 2026-10-18 14:18:28 +11:00
1 parent 6c83148ead
commit 377e9c5160
13 files changed
+619 -28

No files matched your search

+205
View File
@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { controlsLeader } from '../game/leader-control';
import { residualPoisonCandidates } from '../game/residual-poison';
import { richeseCards } from '../game/richese-cards';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
const poison = richeseCards().find(card => card.effect === 'residualPoison'); assert.ok(poison);
export const CAPTURE_RESIDUAL_POISON = poison.id;
export type ResidualPoisonCaptureStep = { actor: string; action: Action };
export const residualPoisonCapturePlayer = (game: Game, actor = 'richese') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const residualPoisonCaptureClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
function lottery<T>(rolls: readonly number[], operation: () => T): T {
const native = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actResidualPoisonCapture(game: Game, actor: string, action: Action,
actions: ResidualPoisonCaptureStep[] = [], rolls?: readonly number[]): Game {
const operation = () => applyAction(game, actor, action);
const next = rolls ? lottery(rolls, operation) : withClassicDiscoveryNexusLottery(operation);
actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Capture and private plans are owned explicit source choices. Ordinary
* optional powers, native responses and auctions continue without spending. */
export function nextResidualPoisonCaptureStep(game: Game): ResidualPoisonCaptureStep | null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'captureOffer' || decision?.kind === 'capturedLeader') return null;
if (decision?.kind === 'guildTiming') return { actor: decision.player,
action: { type: 'decision', take: false } };
if (decision?.kind === 'richeseCache') {
const card = game.richeseCache!.find(card => card.id === CAPTURE_RESIDUAL_POISON) ?? game.richeseCache![0]; assert.ok(card);
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
card: card.id, method: 'onceAround', direction: 'clockwise' } };
}
if (decision?.kind === 'richeseUnbid') return { actor: decision.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
}
const lot = game.richeseAuction;
if (residualPoisonCaptureClean(game) && lot?.cardId === CAPTURE_RESIDUAL_POISON && !lot.outcome && lot.active) {
// Once Around gives the seller a final raise only after a positive bid.
if (lot.active === 'richese' || lot.bid === 0)
return { actor: lot.active, action: { type: 'richeseBid', event: lot.event,
amount: lot.active === 'richese' ? 2 : 1, allyPayment: 0 } };
}
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceResidualPoisonCapture(game: Game, until: (state: Game) => boolean,
actions: ResidualPoisonCaptureStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original capture/Poison programme ended before its next control');
const step = nextResidualPoisonCaptureStep(game); assert.ok(step, 'Choose the actual capture or sealed Battle Plan before advancing');
game = actResidualPoisonCapture(game, step.actor, step.action, actions);
}
throw Error('The original capture/Poison programme did not reach its finite boundary');
}
export function residualPoisonCaptureAction(game: Game): Action {
const option = viewGame(game, 'richese').residualPoison;
assert.ok(option && !option.blocked && option.event && option.target === 'harkonnen',
option?.blocked ?? 'The genuine native capture must leave an owned pre-leader Poison opportunity');
return { type: 'card', card: option.card.id, target: option.target, event: option.event };
}
/** Actual Advanced public start, paid cache2, native funded deployments,
* Harkonnen victory followed by capture/keep, and a second original battle.
* Only unopened first-turn Spice positions and original scalar lotteries are
* selected. No dealt hand, leader, force, wallet, clock or capture is installed. */
export function createResidualPoisonCaptureFixture() {
let game = createGame('RESIDUALCAPTURE', newPlayer('harkonnen', 'Harkonnen', 'harkonnen'), true, ['choam']);
for (const faction of ['emperor', 'richese', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: ResidualPoisonCaptureStep[] = [];
const act = (actor: string, action: Action) => { game = actResidualPoisonCapture(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceResidualPoisonCapture(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
for (let count = 0; count < 2; count++) {
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(index >= 0);
front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
reach(state => residualPoisonCapturePlayer(state).hand.some(card => card.id === CAPTURE_RESIDUAL_POISON));
const acquired = structuredClone(game), shipments: {
before: Game; step: ResidualPoisonCaptureStep; after: Game;
}[] = [];
for (const [actor, territory, sector, amount] of [
['harkonnen', 'red_chasm', 7, 3], ['emperor', 'red_chasm', 7, 1], ['richese', 'carthag', 11, 1],
] as const) {
reach(state => state.phase === 5 && state.active === actor && residualPoisonCaptureClean(state));
assert.equal(game.turn, 1);
const before = structuredClone(game), step: ResidualPoisonCaptureStep = { actor, action: {
type: 'ship', territory, sector, amount, elite: 0, allyPayment: 0,
} };
act(step.actor, step.action); reach(residualPoisonCaptureClean);
shipments.push({ before, step, after: structuredClone(game) });
act(actor, { type: 'endMovement' });
}
reach(state => state.phase === 6 && state.active === 'harkonnen' && residualPoisonCaptureClean(state));
const beforeFirstBattle = structuredClone(game), firstChoice: ResidualPoisonCaptureStep = { actor: 'harkonnen',
action: { type: 'chooseBattle', territory: 'red_chasm', target: 'emperor' } };
act(firstChoice.actor, firstChoice.action);
reach(state => !!state.battle?.preLeader?.closed && !state.battle.preparation && residualPoisonCaptureClean(state));
const firstBattle = structuredClone(game);
const winnerLeader = [...residualPoisonCapturePlayer(game, 'harkonnen').leaders]
.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0];
const loserLeader = [...residualPoisonCapturePlayer(game, 'emperor').leaders]
.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(winnerLeader && loserLeader && winnerLeader.strength + 1 > loserLeader.strength);
const firstPlans: ResidualPoisonCaptureStep[] = [
{ actor: 'harkonnen', action: { type: 'battlePlan', leader: winnerLeader.id, dial: 1, support: 1 } },
{ actor: 'emperor', action: { type: 'battlePlan', leader: loserLeader.id, dial: 0, support: 0 } },
];
for (const step of firstPlans) act(step.actor, step.action);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'captureOffer');
const captureOffer = structuredClone(game);
act('harkonnen', { type: 'decision', accept: true });
const captureDeclared = structuredClone(game);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'capturedLeader');
const captured = structuredClone(game), decision = game.decision;
assert.ok(decision?.kind === 'capturedLeader');
const captive = decision.leader;
assert.equal(decision.owner, 'emperor');
act('harkonnen', { type: 'decision', mode: 'keep' });
reach(state => !state.battle && residualPoisonCaptureClean(state));
const afterCapture = structuredClone(game);
assert.ok(residualPoisonCapturePlayer(game, 'emperor').leaders.find(leader => leader.id === captive)?.concealed);
assert.equal(game.active, 'harkonnen');
const beforeBattle = structuredClone(game), choice: ResidualPoisonCaptureStep = { actor: 'harkonnen',
action: { type: 'chooseBattle', territory: 'carthag', target: 'richese' } };
act(choice.actor, choice.action);
reach(state => !!state.battle?.preLeader && !state.battle.preLeader.closed && !state.battle.preparation && residualPoisonCaptureClean(state));
assert.deepEqual(game.battle!.plans, {});
return { game, lobby, setup, afterSetup, acquired, shipments, beforeFirstBattle, firstChoice, firstBattle,
firstPlans, captureOffer, captureDeclared, captured, afterCapture, beforeBattle, choice, captive,
firstHarkonnenLeader: winnerLeader.id, owner: 'richese', target: 'harkonnen', nativeOwner: 'emperor',
observer: 'guild', card: CAPTURE_RESIDUAL_POISON, sourceActionCount: actions.length, actions,
staging: ['Only unopened original land Spice positions and scalar lotteries are selected. Public Advanced host/setup, an actual opening bid1 and native seller raise/purchase2, Harkonnen3/Emperor1 desert and Richese1 Carthag shipments, actual Harkonnen victory, random capture and keep create this source. No postdeal hand, leader, counter, wallet, phase, battle or custody receipt is installed.'] };
}
export type ResidualPoisonCaptureFixture = ReturnType<typeof createResidualPoisonCaptureFixture>;
/** The test lottery observes the actual controlled source pool. The request
* carries no victim, pool or random index, and the engine owns the death. */
export function useResidualPoisonCapture(fixture: ResidualPoisonCaptureFixture, branch: 'native' | 'captive') {
let game = structuredClone(fixture.game);
const target = residualPoisonCapturePlayer(game, fixture.target);
const pool = residualPoisonCandidates(game.players.flatMap(owner => owner.leaders.filter(leader => controlsLeader(target, leader))),
game.battle!.territory);
const selected = branch === 'captive' ? pool.find(leader => leader.id === fixture.captive)
: pool.find(leader => leader.faction === 'harkonnen');
assert.ok(selected);
const working = pool.map(leader => leader.id), desired = [selected.id, ...working.filter(id => id !== selected.id)], rolls: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(desired[index]); assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[working[index], working[from]] = [working[from], working[index]];
}
const actions: ResidualPoisonCaptureStep[] = [], action = residualPoisonCaptureAction(game);
const before = structuredClone(game);
game = actResidualPoisonCapture(game, fixture.owner, action, actions, rolls);
assert.equal(game.players.flatMap(player => player.leaders).find(leader => leader.id === selected.id)!.dead, true);
return { game, before, action, victim: selected.id, branch, rolls, poolIds: pool.map(leader => leader.id), actions };
}
/** Both participants finish the real shared opportunity and seal ordinary
* no-weapon plans using available native discs. Richese wins without killing
* another leader; an unused living captive remains controlled and concealed. */
export function completeResidualPoisonCapture(fixture: ResidualPoisonCaptureFixture, state: Game) {
const actions: ResidualPoisonCaptureStep[] = [];
let game = advanceResidualPoisonCapture(structuredClone(state),
game => !!game.battle?.preLeader?.closed && !game.battle.preparation && residualPoisonCaptureClean(game), actions);
const beforePlans = structuredClone(game);
const own = [...residualPoisonCapturePlayer(game, fixture.owner).leaders]
.filter(leader => !leader.dead && !leader.capturedBy && (!leader.usedAt || leader.usedAt === 'carthag'))
.sort((a, b) => b.strength - a.strength)[0];
const enemy = [...residualPoisonCapturePlayer(game, fixture.target).leaders]
.filter(leader => !leader.dead && !leader.capturedBy && (!leader.usedAt || leader.usedAt === 'carthag'))
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(own && enemy && own.strength > enemy.strength);
const plans: ResidualPoisonCaptureStep[] = [
{ actor: fixture.target, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
{ actor: fixture.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
];
for (const step of plans) game = actResidualPoisonCapture(game, step.actor, step.action, actions);
game = advanceResidualPoisonCapture(game, game => !game.battle && residualPoisonCaptureClean(game), actions);
return { game, beforePlans, plans, actions };
}
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
actResidualPoisonCapture, advanceResidualPoisonCapture, completeResidualPoisonCapture,
createResidualPoisonCaptureFixture, residualPoisonCaptureClean, residualPoisonCapturePlayer as player,
useResidualPoisonCapture, type ResidualPoisonCaptureFixture,
} from './fixture-residual-poison-capture';
const physicalLeader = (game: Game, id: string) => {
const leader = game.players.flatMap(player => player.leaders).find(leader => leader.id === id); assert.ok(leader); return leader;
};
const publicEmperorLeader = (game: Game, f: ResidualPoisonCaptureFixture) =>
viewGame(game, f.observer).players.find(player => player.id === f.nativeOwner)!.leaders.find(leader => leader.id === f.captive)!;
function source(f: ResidualPoisonCaptureFixture) {
assert.equal(f.game.advanced, true);
assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'start' && step.action.advancedPreview === true));
assert.ok(f.actions.some(step => step.actor !== f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 2));
assert.ok(player(f.acquired).hand.some(card => card.id === f.card));
assert.equal(player(f.afterSetup, f.target).forces['carthag:11'], 10);
assert.deepEqual(f.shipments.map(shipment => [shipment.step.actor, shipment.step.action.amount]),
[[f.target, 3], [f.nativeOwner, 1], [f.owner, 1]]);
for (const shipment of f.shipments) {
const cost = shipment.step.actor === f.target ? 6 : shipment.step.actor === f.nativeOwner ? 2 : 1;
assert.equal(player(shipment.before, shipment.step.actor).spice - player(shipment.after, shipment.step.actor).spice, cost);
assert.equal(player(shipment.before, shipment.step.actor).reserves - player(shipment.after, shipment.step.actor).reserves,
shipment.step.action.amount);
}
assert.equal(f.captureOffer.decision?.kind, 'captureOffer');
assert.ok(f.actions.some(step => step.actor === f.target && step.action.type === 'decision' && step.action.accept === true));
assert.ok(f.actions.some(step => step.actor === f.target && step.action.type === 'decision' && step.action.mode === 'keep'));
assert.equal(physicalLeader(f.afterCapture, f.captive).capturedBy, f.target);
assert.ok(physicalLeader(f.afterCapture, f.captive).concealed);
assert.equal(physicalLeader(f.game, f.captive).dead, false);
assert.equal(physicalLeader(f.game, f.firstHarkonnenLeader).usedAt, 'red_chasm');
assert.equal(publicEmperorLeader(f.game, f).capturedBy, undefined);
assert.equal(publicEmperorLeader(f.game, f).dead, false);
assert.equal(viewGame(f.game, f.observer).players.find(player => player.id === f.target)!.leaders.some(leader => leader.id === f.captive), false);
assert.equal(f.game.battle!.territory, 'carthag');
assert.equal(f.game.battle!.attacker, f.target);
assert.equal(f.game.battle!.defender, f.owner);
assert.deepEqual(f.game.battle!.plans, {});
assert.equal(f.game.battle!.preLeader!.closed, false);
assert.equal(viewGame(f.game, f.owner).residualPoison!.blocked, null);
assert.equal(f.sourceActionCount, f.actions.length);
}
function death(f: ResidualPoisonCaptureFixture, result: ReturnType<typeof useResidualPoisonCapture>) {
const before = result.before, after = result.game;
assert.equal(result.poolIds.length, 5);
assert.ok(result.poolIds.includes(f.captive));
assert.equal(result.poolIds.includes(f.firstHarkonnenLeader), false);
const changed = before.players.flatMap(player => player.leaders)
.filter(leader => leader.deaths !== physicalLeader(after, leader.id).deaths);
assert.deepEqual(changed.map(leader => leader.id), [result.victim]);
assert.equal(physicalLeader(after, result.victim).dead, true);
assert.equal(physicalLeader(after, result.victim).deaths, physicalLeader(before, result.victim).deaths + 1);
assert.equal(physicalLeader(after, result.victim).usedAt, undefined);
assert.equal(physicalLeader(after, result.victim).capturedBy, undefined);
assert.equal(physicalLeader(after, result.victim).concealed, undefined);
assert.deepEqual(after.players.map(player => player.spice), before.players.map(player => player.spice),
'Residual death pays neither a bounty nor the Harkonnen execution2');
assert.deepEqual(after.players.map(player => ({ forces: player.forces, reserves: player.reserves, tanks: player.tanks })),
before.players.map(player => ({ forces: player.forces, reserves: player.reserves, tanks: player.tanks })));
assert.equal(after.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(after).hand.some(card => card.id === f.card), false);
assert.equal(after.richeseCache!.some(card => card.id === f.card), false);
assert.equal(after.battle!.event, before.battle!.event);
assert.deepEqual(after.battle!.plans, {});
assert.throws(() => actResidualPoisonCapture(after, f.owner, result.action));
}
void test('genuine Advanced native Harkonnen Poison death preserves the actually captured nonselected Emperor disc concealed', () => {
const f = createResidualPoisonCaptureFixture(); source(f);
const result = useResidualPoisonCapture(f, 'native'); death(f, result);
assert.equal(physicalLeader(result.game, result.victim).faction, 'harkonnen');
assert.deepEqual(physicalLeader(result.game, f.captive), physicalLeader(f.game, f.captive));
assert.deepEqual(publicEmperorLeader(result.game, f), publicEmperorLeader(f.game, f));
assert.equal(result.game.log.slice(f.game.log.length).some(entry => entry.text.includes(physicalLeader(f.game, f.captive).name)), false);
const finished = completeResidualPoisonCapture(f, result.game);
assert.equal(finished.game.battle, null);
assert.equal(finished.game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(physicalLeader(finished.game, f.captive), physicalLeader(f.game, f.captive));
assert.equal(player(finished.game).forces['carthag:11'], 1);
assert.ok(finished.actions.some(step => step.action.type === 'battlePreparationReady'));
assert.ok(finished.actions.some(step => step.action.type === 'battleOutcomeReady'));
});
void test('genuine Advanced captured Emperor Poison death publishes only its selected normal Tanks death and clears its actual captivity', () => {
const f = createResidualPoisonCaptureFixture(); source(f);
const result = useResidualPoisonCapture(f, 'captive'); death(f, result);
assert.equal(result.victim, f.captive);
assert.equal(physicalLeader(result.game, f.captive).faction, 'emperor');
assert.equal(publicEmperorLeader(result.game, f).dead, true);
assert.equal(publicEmperorLeader(result.game, f).deaths, 1);
assert.equal(publicEmperorLeader(result.game, f).capturedBy, undefined);
assert.ok(result.game.log.slice(f.game.log.length).some(entry => entry.text.includes(physicalLeader(f.game, f.captive).name)));
assert.equal(player(result.game, f.target).leaders.filter(leader => leader.dead).length,
player(f.game, f.target).leaders.filter(leader => leader.dead).length);
const finished = completeResidualPoisonCapture(f, result.game);
assert.equal(finished.game.battle, null);
assert.equal(physicalLeader(finished.game, f.captive).dead, true);
assert.equal(physicalLeader(finished.game, f.captive).deaths, 1);
assert.equal(physicalLeader(finished.game, f.captive).capturedBy, undefined);
assert.equal(finished.game.discard.filter(card => card.id === f.card).length, 1);
});
void test('all four minimal profiles play the original offered Poison against the real captor then continue the actual shared preparation', () => {
const f = createResidualPoisonCaptureFixture();
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(f.game, f.owner);
view.players.find(player => player.id === f.owner)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'card' && action.card === f.card);
assert.ok(action, `${difficulty} uses the real offered owned Poison source`);
assert.equal(action.target, f.target);
assert.equal(action.event, f.game.battle!.event);
let game = actResidualPoisonCapture(structuredClone(f.game), f.owner, action);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
const owned = viewGame(game, f.owner);
owned.players.find(player => player.id === f.owner)!.bot = difficulty;
const ready = botActions(owned).find(action => action.type === 'battlePreparationReady'); assert.ok(ready);
game = actResidualPoisonCapture(game, f.owner, ready);
game = advanceResidualPoisonCapture(game,
game => !!game.battle?.preLeader?.closed && residualPoisonCaptureClean(game));
const planning = viewGame(game, f.owner);
planning.players.find(player => player.id === f.owner)!.bot = difficulty;
const plan = botActions(planning).find(action => action.type === 'battlePlan'); assert.ok(plan);
assert.doesNotThrow(() => actResidualPoisonCapture(game, f.owner, plan));
}
});
+107
View File
@@ -0,0 +1,107 @@
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 }));
});
+6 -3
View File
@@ -340,16 +340,19 @@ void test('foreign ghola, ordinary captive and separate Duke deaths retain exact
}
});
void test('public advanced Harkonnen guard is invariant to captive possession and a legitimately reacquired physical card may be played again', () => {
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);
assert.match(first!, /advanced Harkonnen/);
g = start(fixture());
});
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);
+1 -2
View File
@@ -48,14 +48,13 @@ void test('normal foreign ghola control and Duke tank history are preserved with
}
});
void test('invalid timing, target, identity and state fail before random selection and preserve the card',t=>{
for(const problem of ['stale','outsider','target','sealed','prescienceLeader','empty','overflow','advancedCapture','forgedVictim'] as const){
for(const problem of ['stale','outsider','target','sealed','prescienceLeader','empty','overflow','forgedVictim'] as const){
const g=fixture();const extra:Partial<Action>={};let actor='p';
if(problem==='stale')extra.event='stale';if(problem==='outsider')actor='r';if(problem==='target')extra.target='p';
if(problem==='sealed')g.battle!.plans.q={dial:1,leader:g.players[1].leaders[0].id,weapon:null,defense:null,support:0};
if(problem==='prescienceLeader')g.battle!.prescience={player:'p',field:'leader',value:g.players[1].leaders[0].id};
if(problem==='empty')g.players[1].leaders.forEach(l=>{l.dead=true;l.deaths=1;});
if(problem==='overflow')g.players[1].leaders[0].deaths=Number.MAX_SAFE_INTEGER;
if(problem==='advancedCapture'){g.advanced=true;g.players[0].faction='harkonnen';}
if(problem==='forgedVictim')extra.leader=g.players[1].leaders[0].id;
const before=structuredClone(g);let calls=0;const mock=t.mock.method(crypto,'getRandomValues',()=>{calls++;throw Error('RNG must not run');});
assert.throws(()=>applyAction(g,actor,{type:'card',card,target:'q',event:g.battle!.event,...extra}),problem);assert.deepEqual(g,before);assert.equal(calls,0,problem);mock.mock.restore();