Files
Dune/tests/semuta-homeworld-residual-poison.test.ts
T

220 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { residualPoisonCandidates, residualPoisonDeath } from '../game/residual-poison';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import { semutaHomeworldWinnerResources as resources, semutaHomeworldWinnerStock as stock } from './fixture-semuta-homeworld-battle-winner';
import { createSemutaHomeworldResidualPoisonFixture, disposeSemutaHomeworldResidualPoison,
finishSemutaHomeworldResidualPoison, type SemutaHomeworldResidualPoisonFixture } from './fixture-semuta-homeworld-residual-poison';
function rejectUnchanged(game: Game, actor: string, action: Action) {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, actor, action));
assert.equal(JSON.stringify(game), before, 'Rejected actions preserve the original physical game');
}
function assertNativePoison(f: SemutaHomeworldResidualPoisonFixture, game: Game, before = f.game) {
const prior = player(before, f.target).leaders;
const changed = player(game, f.target).leaders.filter(l =>
JSON.stringify(l) !== JSON.stringify(prior.find(old => old.id === l.id)));
assert.equal(changed.length, 1, 'Exactly one original Emperor disc changes at the Poison cost boundary');
const victim = changed[0];
assert.ok(residualPoisonCandidates(prior, 'red_chasm').some(l => l.id === victim.id));
assert.deepEqual(victim, residualPoisonDeath(prior.find(l => l.id === victim.id)!));
const expected = resources(before);
expected.players.find(p => p.id === f.target)!.leaders = prior.map(l =>
l.id === victim.id ? residualPoisonDeath(l) : l);
assert.deepEqual(resources(game), expected,
'Poison awards no spice, loses no counters, and leaves every other leader/custody/resource unchanged');
assert.equal(player(game, f.owner).forces[f.location], 4);
assert.equal(player(game, f.target).forces[f.location], 1);
assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), false);
assert.equal(game.discard.filter(c => c.id === f.card).length, 1);
assert.equal(game.battle?.event, f.event);
assert.deepEqual(game.battle?.plans, {});
assert.deepEqual(stock(game), stock(f.setup));
return victim.id;
}
function assertExchange(f: SemutaHomeworldResidualPoisonFixture, pending: Game, game: Game,
claim: boolean, holderHasSemuta = true) {
assert.ok(!game.pendingTreacheryDiscard);
assert.deepEqual(resources(game), resources(pending), 'Resume neither redraws nor repeats the committed native death');
assert.deepEqual(game.battle, pending.battle, 'The original shared preparation event and readiness resume unchanged');
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.card).length, Number(claim));
assert.equal(game.discard.filter(c => c.id === f.card).length, Number(!claim));
if (holderHasSemuta) {
assert.equal(game.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
} else
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false,
'The absent-holder fork never returns the Semuta Drug to this seat');
for (const seat of game.players) {
const expected = player(pending, seat.id).hand.map(c => c.id)
.filter(id => seat.id !== f.holder || !claim || id !== SEMUTA_DRUG_ID);
if (seat.id === f.holder && claim) expected.push(f.card);
assert.deepEqual(seat.hand.map(c => c.id).sort(), expected.sort());
assert.ok(seat.hand.length <= handLimit(seat));
}
assert.deepEqual(stock(game), stock(f.setup));
}
function finishOnce(f: SemutaHomeworldResidualPoisonFixture, state: Game, victim: string) {
const acceptedVictim = structuredClone(player(state, f.target).leaders.find(l => l.id === victim));
const finished = finishSemutaHomeworldResidualPoison(f, state);
const { beforePlans, afterBattle, beforeCollection, game } = finished;
assert.equal(beforePlans.battle?.event, f.event);
assert.equal(beforePlans.battle?.preLeader?.closed, true);
assert.deepEqual(beforePlans.battle?.preLeader?.ready.slice().sort(), [f.owner, f.target].sort());
assert.notEqual(finished.loserLeader, victim, 'Future native plans select a currently living Emperor disc, never the random victim');
assert.equal(player(beforePlans, f.target).leaders.find(l => l.id === finished.loserLeader)?.dead, false);
assert.equal(afterBattle.lastBattleContext?.winner, f.owner);
assert.equal(afterBattle.lastBattleContext?.result, 'normal');
assert.equal(afterBattle.lastBattleContext?.territory, 'red_chasm');
assert.ok(!afterBattle.battle);
assert.equal(player(afterBattle, f.owner).forces[f.location], 3);
assert.equal(player(afterBattle, f.owner).reserves, 16);
assert.equal(player(afterBattle, f.owner).tanks, player(beforePlans, f.owner).tanks + 1);
assert.equal(player(afterBattle, f.owner).battleLosses, player(beforePlans, f.owner).battleLosses + 1);
assert.equal(player(afterBattle, f.target).forces[f.location] ?? 0, 0);
assert.equal(player(afterBattle, f.target).reserves, 19);
assert.equal(player(afterBattle, f.target).tanks, player(beforePlans, f.target).tanks + 1);
const combatLeader = player(beforePlans, f.target).leaders.find(l => l.id === finished.loserLeader)!;
assert.equal(player(afterBattle, f.target).leaders.find(l => l.id === combatLeader.id)?.dead, true);
assert.equal(player(afterBattle, f.target).leaders.find(l => l.id === combatLeader.id)?.deaths, combatLeader.deaths + 1);
assert.deepEqual(player(afterBattle, f.target).leaders.find(l => l.id === victim), acceptedVictim);
assert.equal(player(afterBattle, f.owner).spice,
player(beforePlans, f.owner).spice - Number(game.advanced) + combatLeader.strength,
'Only actual supported dial cost and the distinct killed combat leader produce battle spice');
assert.equal(player(afterBattle, f.target).spice, player(beforePlans, f.target).spice);
assert.deepEqual(afterBattle.homeworlds?.custody, f.shipments[1].after.homeworlds?.custody);
const collection = quoteSpiceCollection(beforeCollection), expected = resources(beforeCollection);
assert.equal(collection.receipts.find(r => r.player === f.owner)?.desert, 6,
'Three surviving counters collect six from the original eight-spice Red Chasm blow');
for (const receipt of collection.receipts) expected.players.find(p => p.id === receipt.player)!.spice = receipt.balance;
assert.deepEqual(resources(game), expected, 'Native Collection credits legitimate board/stronghold income once');
assert.deepEqual(game.spice, collection.spice);
assert.deepEqual(game.lastBattleContext, afterBattle.lastBattleContext);
assert.deepEqual(player(game, f.target).leaders.find(l => l.id === victim), acceptedVictim);
assert.deepEqual(stock(game), stock(f.setup));
assert.equal(game.phase, 7);
assert.equal(game.turn, 1);
assert.ok(!game.response && !game.decision && !game.pendingTreacheryDiscard);
rejectUnchanged(game, f.owner, { type: 'decision', done: true });
}
for (const advanced of [false, true]) {
const label = `${advanced ? 'Advanced' : 'Basic'} paid Homeworld Residual Poison`;
for (const full of [false, true]) for (const claim of [false, true])
void test(`${label}: ${full ? 'full four-card' : 'thin'} hand ${claim ? 'claim' : 'all-pass'} preserves native death and future battle`, () => {
const f = createSemutaHomeworldResidualPoisonFixture(advanced, full);
assert.equal(f.chooser, f.target);
assert.equal(viewGame(f.game, f.owner).residualPoison?.blocked, null);
assert.equal(viewGame(f.game, f.owner).residualPoison?.target, f.target);
assert.equal(player(f.game, f.holder).hand.length === 4, full);
for (const shipment of f.shipments) {
const amount = shipment.actor === f.target ? 1 : 4;
assert.equal(player(shipment.after, shipment.actor).spice, player(shipment.before, shipment.actor).spice - 2 * amount);
assert.equal(player(shipment.after, shipment.actor).reserves, player(shipment.before, shipment.actor).reserves - amount);
assert.equal(player(shipment.after, shipment.actor).forces[f.location], amount);
assert.deepEqual(player(shipment.after, shipment.actor).elites, player(shipment.before, shipment.actor).elites);
}
rejectUnchanged(f.game, f.holder, f.action);
rejectUnchanged(f.game, f.owner, { ...f.action, target: f.holder });
rejectUnchanged(f.game, f.owner, { ...f.action, event: `${f.event}-stale` });
rejectUnchanged(f.beforeBattle, f.owner, f.action);
const pending = disposeSemutaHomeworldResidualPoison(f);
const victim = assertNativePoison(f, pending);
const event = pending.pendingTreacheryDiscard!.batch.event;
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'residualPoisonDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.card]);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
rejectUnchanged(pending, f.owner, f.action);
rejectUnchanged(pending, f.target, { type: 'battlePreparationReady', event: f.event });
rejectUnchanged(pending, f.target, { type: 'semutaCommit', event });
rejectUnchanged(pending, f.holder, { type: 'semutaCommit', event: `${event}-stale` });
rejectUnchanged(pending, f.holder, { type: 'semutaSelect', event, card: f.shield });
const acknowledged = applyAction(pending, f.target, { type: 'semutaPass', event });
assert.deepEqual(resources(acknowledged), resources(pending));
rejectUnchanged(acknowledged, f.target, { type: 'semutaPass', event });
const done = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(pending)) as Game, f.holder, claim);
assertExchange(f, pending, done, claim);
assert.deepEqual(player(done, f.target).leaders.find(l => l.id === victim), player(pending, f.target).leaders.find(l => l.id === victim));
rejectUnchanged(done, f.holder, { type: 'semutaCommit', event });
rejectUnchanged(done, f.holder, { type: 'semutaPass', event });
rejectUnchanged(done, f.holder, { type: 'semutaSelect', event, card: f.card });
rejectUnchanged(done, f.target, { type: 'battlePreparationReady', event: `${f.event}-stale` });
const ready = applyAction(done, f.target, { type: 'battlePreparationReady', event: f.event });
assert.deepEqual(ready.battle?.preLeader?.ready, [f.target]);
assert.deepEqual(resources(ready), resources(done));
finishOnce(f, ready, victim);
});
void test(`${label}: full-hand entitlement is private and absent-holder pass changes no death or resource`, () => {
const f = createSemutaHomeworldResidualPoisonFixture(advanced, true);
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
const pending = disposeSemutaHomeworldResidualPoison(f);
const neutral = disposeSemutaHomeworldResidualPoison(f, absent);
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
for (const seat of pending.players.filter(p => p.id !== f.holder)) {
assert.deepEqual(JSON.parse(JSON.stringify(viewGame(f.game, seat.id))),
JSON.parse(JSON.stringify(viewGame(absent, seat.id))),
'The conserved private Semuta position is invisible to other seats');
// Native programs can select different victims. Compare neutral offer
// entitlement separately from its opaque event.
const { event: _heldEvent, ...heldOffer } = viewGame(pending, seat.id).semutaReaction!;
const { event: _absentEvent, ...absentOffer } = viewGame(neutral, seat.id).semutaReaction!;
assert.equal(heldOffer.canCommit, false);
assert.deepEqual(heldOffer, absentOffer);
}
const victim = assertNativePoison(f, neutral, absent);
const done = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(neutral)) as Game, f.holder, false);
assertExchange(f, neutral, done, false, false);
finishOnce(f, done, victim);
});
void test(`${label}: actual opponent readiness permits native Poison but excludes Semuta's clean producer`, () => {
const f = createSemutaHomeworldResidualPoisonFixture(advanced);
const ready = applyAction(f.game, f.target, { type: 'battlePreparationReady', event: f.event });
assert.deepEqual(ready.battle?.preLeader?.ready, [f.target]);
assert.equal(viewGame(ready, f.owner).residualPoison?.blocked, null);
const done = disposeSemutaHomeworldResidualPoison(f, ready);
const victim = assertNativePoison(f, done, ready);
assert.ok(!done.pendingTreacheryDiscard);
assert.ok(!viewGame(done, f.holder).semutaReaction);
assert.deepEqual(done.battle?.preLeader?.ready, [f.target]);
finishOnce(f, done, victim);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: legal minimum ${label} consumer and original future plans`, () => {
const f = createSemutaHomeworldResidualPoisonFixture(advanced);
const pending = disposeSemutaHomeworldResidualPoison(f);
const victim = assertNativePoison(f, pending);
let game = pending;
const own = viewGame(game, f.holder);
own.players.find(p => p.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass'));
game = applyAction(game, f.holder, action);
for (const seat of pending.players) {
if (!game.pendingTreacheryDiscard) break;
const view = viewGame(game, seat.id);
if (!view.semutaReaction || view.semutaReaction.passed) continue;
view.players.find(p => p.id === seat.id)!.bot = difficulty;
const pass = botActions(view)[0];
assert.equal(pass?.type, 'semutaPass');
game = applyAction(game, seat.id, pass);
}
assertExchange(f, pending, game, action.type === 'semutaCommit');
finishOnce(f, game, victim);
});
}