Files
Dune/tests/semuta-independent-residual.test.ts
T
Quintine ea3b21f92c
Verify and publish checkpoint container / container (push) Canceled after 0s
Extend Basic Semuta poison continuation
2026-10-28 22:56:03 +11:00

88 lines
5.0 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actTruthFlightPoison as act, createTruthFlightPoisonFixture,
truthFlightPoisonAction, truthFlightPoisonInventory, truthFlightPoisonPlayer as player,
} from './fixture-truth-flight-poison';
/** Actual public Basic independent-card setup, paid cache purchase, shipment
* and pre-leader battle. Only Semuta is transferred from the original cache
* to Guild's hand; it is not claimed as a real purchase. */
function fixture() {
const source = createTruthFlightPoisonFixture({ effect: 'residualPoison', independentCards: true });
const game = structuredClone(source.game), holder = 'guild';
assert.equal(game.semutaRecruitsPreview, true);
const index = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(index >= 0);
assert.ok(player(game, holder).hand.length < 4);
player(game, holder).hand.push(game.richeseCache!.splice(index, 1)[0]);
assert.deepEqual(truthFlightPoisonInventory(game), source.originalCards);
assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed);
return { ...source, game, holder };
}
function open(source: ReturnType<typeof fixture>, who: 'owner' | 'opponent') {
const readyActor = who === 'owner' ? source.target : source.asker;
const ready = act(source.game, readyActor, { type: 'battlePreparationReady', event: source.event });
assert.deepEqual(ready.battle!.preLeader!.ready, [readyActor]);
const beforeDead = player(ready, source.asker).leaders.filter(leader => leader.dead).map(leader => leader.id);
const pending = act(ready, source.target, truthFlightPoisonAction(ready, source));
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'residualPoisonDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.battle!.preLeader!.ready, [readyActor]);
const newlyDead = player(pending, source.asker).leaders.filter(leader => leader.dead && !beforeDead.includes(leader.id));
assert.equal(newlyDead.length, 1);
assert.equal(viewGame(pending, source.holder).semutaReaction?.canCommit, true);
return { pending, victim: newlyDead[0].id, event: pending.pendingTreacheryDiscard!.batch.event };
}
function react(game: Game, holder: string, event: string, claim: boolean): Game {
if (claim) return act(game, holder, { type: 'semutaCommit', event });
for (const seat of game.players) {
if (!game.pendingTreacheryDiscard?.reaction) break;
game = act(game, seat.id, { type: 'semutaPass', event });
}
return game;
}
for (const who of ['owner', 'opponent'] as const) for (const claim of [false, true]) {
void test(`independent-card ${who}-ready Residual Poison ${claim ? 'claim' : 'pass'} resumes the original preparation once`, () => {
const f = fixture(), { pending, victim, event } = open(f, who);
const dead = player(pending, f.asker).leaders.filter(leader => leader.dead).length;
const resumed = react(pending, f.holder, event, claim);
assert.equal(resumed.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(resumed.battle!.preLeader!.ready, who === 'owner' ? [] : [f.asker]);
assert.deepEqual(resumed.battle!.plans, pending.battle!.plans);
assert.equal(player(resumed, f.asker).leaders.find(leader => leader.id === victim)?.dead, true);
assert.equal(player(resumed, f.asker).leaders.filter(leader => leader.dead).length, dead);
assert.equal(player(resumed, f.holder).hand.some(card => card.id === f.card), claim);
assert.equal(resumed.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
assert.equal(resumed.discard.some(card => card.id === f.card), !claim);
assert.deepEqual(truthFlightPoisonInventory(resumed), f.originalCards);
assert.throws(() => act(resumed, f.holder, { type: 'semutaCommit', event }));
});
}
void test('all profiles can answer an independent-card Residual Poison offer legally', () => {
const f = fixture(), { pending, event } = open(f, 'opponent');
for (const difficulty of DIFFICULTIES) {
const game = structuredClone(pending);
const view = viewGame(game, f.holder);
view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass'));
let continued = act(game, f.holder, action);
for (const seat of continued.players) {
if (!continued.pendingTreacheryDiscard?.reaction) break;
const offer = viewGame(continued, seat.id).semutaReaction;
if (offer && !offer.passed) continued = act(continued, seat.id, { type: 'semutaPass', event });
}
assert.equal(continued.pendingTreacheryDiscard ?? null, null);
assert.deepEqual(continued.battle!.preLeader!.ready, [f.asker]);
assert.deepEqual(truthFlightPoisonInventory(continued), f.originalCards);
}
});