220 lines
14 KiB
TypeScript
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);
|
|
});
|
|
}
|