173 lines
8.9 KiB
TypeScript
173 lines
8.9 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { viewGame, type Game } from '../game/engine';
|
|
import { act, createKullBattleSemutaFixture, inventory, player } from './fixture-kull-battle-semuta';
|
|
|
|
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
|
type Fixture = ReturnType<typeof createKullBattleSemutaFixture>;
|
|
const held = (game: Game, id: string, card: string) => player(game, id).hand.some(item => item.id === card);
|
|
|
|
function attempt(f: Fixture) {
|
|
const game = act(reload(f.game), f.actor, { type: 'card', mode: 'cancel', card: f.karama });
|
|
assert.equal(game.pendingKull?.stage, 'offer');
|
|
const choice = viewGame(game, f.reactor).kullReaction; assert.ok(choice);
|
|
assert.ok(choice.plays.some(row => row.source === 'printed' && row.card.id === f.kull && !row.blocked),
|
|
`Kull is missing in the actual Voice frame: ${JSON.stringify({ preLeader: game.battle?.preLeader,
|
|
preparation: game.battle?.preparation, frame: game.pendingKull?.intent, resume: game.pendingKull?.resume })}`);
|
|
assert.ok(held(game, f.actor, f.karama));
|
|
return game;
|
|
}
|
|
|
|
function declare(f: Fixture, game: Game) {
|
|
const choice = viewGame(game, f.reactor).kullReaction; assert.ok(choice);
|
|
const next = act(game, f.reactor, { type: 'kullDecision', event: choice.event, source: 'printed', card: f.kull });
|
|
assert.equal(next.pendingKull?.stage, 'counter');
|
|
return next;
|
|
}
|
|
|
|
function passResponsesUntilDiscard(game: Game) {
|
|
for (let count = 0; count < 16 && !game.pendingTreacheryDiscard; count++) {
|
|
assert.ok(game.response, 'The native Kull counter must finish before its discard.');
|
|
const actor = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(actor);
|
|
game = act(game, actor.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
return game;
|
|
}
|
|
|
|
function react(game: Game, f: Fixture, expectedCard: string, claim: boolean) {
|
|
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
|
|
const event = pending.batch.event;
|
|
const hadSemuta = held(game, f.holder, f.semuta);
|
|
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [expectedCard]);
|
|
if (claim) {
|
|
assert.equal(viewGame(game, f.holder).semutaReaction?.canCommit, true);
|
|
game = act(game, f.holder, { type: 'semutaCommit', event });
|
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
|
game = act(game, f.holder, { type: 'semutaSelect', event, card: expectedCard });
|
|
} else {
|
|
for (let count = 0; game.pendingTreacheryDiscard?.batch.event === event && count < game.players.length; count++) {
|
|
const reaction = game.pendingTreacheryDiscard!.reaction; assert.ok(reaction?.stage === 'offer');
|
|
const actor = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor);
|
|
game = act(game, actor.id, { type: 'semutaPass', event });
|
|
}
|
|
}
|
|
assert.notEqual(game.pendingTreacheryDiscard?.batch.event, event);
|
|
assert.equal(held(game, f.holder, expectedCard), claim);
|
|
assert.equal(held(game, f.holder, f.semuta), claim ? false : hadSemuta);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
assert.throws(() => act(game, f.holder, { type: 'semutaCommit', event }));
|
|
return game;
|
|
}
|
|
|
|
/** Finish only native responses and readiness, then seal real no-card plans.
|
|
* This proves the same battle remains actionable without replaying its setup. */
|
|
function continueBattle(game: Game, f: Fixture, powerApplies: boolean) {
|
|
for (let count = 0; game.response && count < 16; count++) {
|
|
const actor = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(actor);
|
|
game = act(game, actor.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(game.response, null);
|
|
assert.ok(game.battle);
|
|
assert.equal(game.battle.territory, f.target.territory);
|
|
if (f.power === 'voice') assert.equal(!!game.battle.voice, powerApplies);
|
|
else assert.equal(!!game.battle.prescience, powerApplies);
|
|
for (let count = 0; game.battle?.preparation && count < 4; count++) {
|
|
const preparation = game.battle.preparation;
|
|
game = act(game, preparation.owner, preparation.kind === 'prescienceAnswer'
|
|
? { type: 'prescienceAnswer', value: 0 }
|
|
: { type: 'declineBattlePower' });
|
|
}
|
|
const event = game.battle!.event;
|
|
for (const actor of [f.actor, f.owner]) {
|
|
if (game.battle?.preLeader && !game.battle.preLeader.closed && !game.battle.preLeader.ready.includes(actor))
|
|
game = act(game, actor, { type: 'battlePreparationReady', event });
|
|
}
|
|
for (const actor of [f.actor, f.owner]) {
|
|
const leader = player(game, actor).leaders.find(row => !row.dead && !row.capturedBy); assert.ok(leader);
|
|
game = act(game, actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
|
|
}
|
|
assert.ok(game.battle?.plans[f.actor] && game.battle.plans[f.owner]);
|
|
assert.equal(game.battle.event, event);
|
|
for (const actor of [f.actor, f.owner]) game = act(game, actor, { type: 'traitorCall', call: false });
|
|
assert.equal(game.battle?.revealed, true);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
return game;
|
|
}
|
|
|
|
for (const claim of [false, true]) {
|
|
void test(`Basic Kull stops Prescience Karama with one combatant ready and Semuta ${claim ? 'claims' : 'passes'} Kull`, () => {
|
|
const f = createKullBattleSemutaFixture({ power: 'prescience', preReady: true });
|
|
assert.deepEqual(f.game.battle!.preLeader!.ready, [f.actor]);
|
|
const pending = passResponsesUntilDiscard(declare(f, attempt(f)));
|
|
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'kullSuccessDiscard');
|
|
assert.deepEqual(pending.battle!.preLeader!.ready, [f.actor]);
|
|
const game = react(pending, f, f.kull, claim);
|
|
assert.ok(held(game, f.actor, f.karama));
|
|
assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]);
|
|
continueBattle(game, f, true);
|
|
});
|
|
}
|
|
|
|
for (const claim of [false, true]) {
|
|
void test(`Basic Prescience Kull counter preserves readiness and Semuta ${claim ? 'claims' : 'passes'} the counter`, () => {
|
|
const f = createKullBattleSemutaFixture({ power: 'prescience', preReady: true });
|
|
const declared = declare(f, attempt(f));
|
|
let game = act(declared, f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter });
|
|
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'kullCounterDiscard');
|
|
assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]);
|
|
game = react(game, f, f.counter, claim);
|
|
if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim);
|
|
assert.equal(game.pendingKull, null);
|
|
assert.equal(held(game, f.actor, f.karama), false);
|
|
assert.deepEqual(game.battle!.preLeader!.ready, [f.actor]);
|
|
continueBattle(game, f, false);
|
|
});
|
|
}
|
|
|
|
for (const claim of [false, true]) {
|
|
void test(`Basic Kull stops battle Karama and Semuta ${claim ? 'claims' : 'passes'} Kull`, () => {
|
|
const f = createKullBattleSemutaFixture();
|
|
const pending = passResponsesUntilDiscard(declare(f, attempt(f)));
|
|
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'kullSuccessDiscard');
|
|
assert.ok(held(pending, f.actor, f.karama));
|
|
const game = react(pending, f, f.kull, claim);
|
|
assert.ok(held(game, f.actor, f.karama));
|
|
assert.equal(game.pendingKull, null);
|
|
assert.ok(viewGame(game, f.actor).karamaBlocked);
|
|
continueBattle(game, f, true);
|
|
});
|
|
}
|
|
|
|
void test('declining Kull permits the original battle Karama and its fresh Semuta reaction', () => {
|
|
const f = createKullBattleSemutaFixture();
|
|
let game = attempt(f);
|
|
const event = game.pendingKull!.event;
|
|
game = act(game, f.reactor, { type: 'kullDecision', event, decline: true });
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'karamaCombatDiscard');
|
|
game = react(game, f, f.karama, true);
|
|
assert.ok(held(game, f.reactor, f.kull));
|
|
assert.equal(game.pendingKull, null);
|
|
continueBattle(game, f, false);
|
|
});
|
|
|
|
for (const claim of [false, true]) {
|
|
void test(`a distinct counter stops battle Kull, then Semuta ${claim ? 'claims' : 'passes'} the counter`, () => {
|
|
const f = createKullBattleSemutaFixture();
|
|
const declared = declare(f, attempt(f));
|
|
assert.ok(viewGame(declared, f.counterOwner).responseControls?.cancelCards.includes(f.counter));
|
|
let game = act(declared, f.counterOwner, { type: 'card', mode: 'cancel', card: f.counter });
|
|
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'kullCounterDiscard');
|
|
assert.ok(held(game, f.reactor, f.kull));
|
|
game = react(game, f, f.counter, claim);
|
|
assert.ok(held(game, f.reactor, f.kull));
|
|
// The original attempted Karama is a separate fresh discard if its native
|
|
// combat cost resumes. It cannot reuse the already-spent Semuta card.
|
|
if (game.pendingTreacheryDiscard) game = react(game, f, f.karama, !claim);
|
|
assert.equal(game.pendingKull, null);
|
|
assert.equal(held(game, f.actor, f.karama), false);
|
|
continueBattle(game, f, false);
|
|
});
|
|
}
|