Files
Dune/tests/semuta-battle-karama.test.ts
T

159 lines
9.8 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { battleKaramaInventory, battleKaramaPlayer, finishBattleKarama, reloadBattleKarama,
semutaBattleKaramaFixture, settleBattleKaramaCost, type SemutaBattleKaramaKind } from './fixture-semuta-battle-karama';
const kinds: SemutaBattleKaramaKind[] = ['capture', 'choamAudit', 'choamBattleIncome'];
function battleQuantities(game: Game) {
return game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces,
reserves: p.reserves, tanks: p.tanks, leaders: p.leaders }));
}
for (const kind of kinds) for (const claim of [false, true]) void test(
`printed Karama canceling native ${kind} offers only its cost before ${claim ? 'claim' : 'all-seat pass'} and finishes once`, () => {
const fixture = semutaBattleKaramaFixture(kind);
const original = reloadBattleKarama(fixture.originalResponse);
const native = reloadBattleKarama(original);
delete native.semutaPreview;
const nativeCanceled = applyAction(native, fixture.spender, fixture.cancel);
const winner = battleKaramaPlayer(original, fixture.winner);
const loser = battleKaramaPlayer(original, fixture.loser);
assert.equal(winner.spice, 10 - 2 + fixture.bounty);
assert.equal(loser.spice, kind === 'choamAudit' ? 11 : 10);
assert.deepEqual(winner.forces, { 'arrakeen:10': 3 });
assert.deepEqual(loser.forces, {});
assert.equal(winner.tanks, 2);
assert.equal(loser.tanks, 5);
assert.equal(winner.reserves, 15);
assert.equal(loser.reserves, 15);
assert.equal(loser.leaders.find(l => l.id === fixture.loserLeader)!.dead, true);
assert.equal(loser.leaders.find(l => l.id === fixture.loserLeader)!.deaths, 1);
if (kind === 'capture') {
assert.equal(fixture.nativeOffer.decision?.kind, 'captureOffer');
assert.deepEqual(original.pendingCapture, { player: 'e', loser: 'a', territory: 'arrakeen' });
} else if (kind === 'choamAudit') {
assert.equal(fixture.nativeOffer.decision?.kind, 'choamAudit');
assert.equal(original.pendingAuditor?.stage, 'response');
assert.equal(original.pendingAuditor?.survived, false);
assert.deepEqual(original.pendingAuditor?.usedCards, [fixture.weapon]);
assert.deepEqual(battleKaramaPlayer(original, 'a').hand.map(c => c.id).sort(),
[fixture.karama, fixture.privateCard].sort());
} else assert.deepEqual(original.pendingChoamBattleIncome, { owner: 'e', amount: 1 });
const pending = applyAction(original, fixture.spender, fixture.cancel);
// This is the missing producer on the baseline: the cost used to skip Semuta entirely.
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaBattleDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const receipt = pending.pendingTreacheryDiscard!;
assert.equal(receipt.batch.cause, `karama:${kind}`);
assert.equal(receipt.batch.entries.length, 1);
assert.equal(receipt.batch.entries[0].card.id, fixture.karama);
assert.equal(receipt.batch.entries[0].card.effect, 'karama');
assert.equal(receipt.batch.entries[0].discardedBy, fixture.spender);
assert.equal(receipt.batch.entries[0].publicFace, true);
assert.equal(pending.discard.filter(c => c.id === fixture.karama).length, 1);
assert.equal(battleKaramaPlayer(pending, fixture.spender).hand.some(c => c.id === fixture.karama), false);
assert.equal(pending.response, null);
assert.deepEqual(pending.pendingCapture, original.pendingCapture);
assert.deepEqual(pending.pendingAuditor, original.pendingAuditor);
assert.deepEqual(pending.pendingChoamBattleIncome, original.pendingChoamBattleIncome);
assert.deepEqual(battleQuantities(pending), battleQuantities(original));
assert.equal(pending.auditorInsight == null, true);
assert.deepEqual(battleKaramaInventory(pending), fixture.inventory);
assert.deepEqual(normalizeAutomaticGame(reloadBattleKarama(pending)), reloadBattleKarama(pending));
assert.equal(viewGame(pending, fixture.holder).semutaReaction?.canCommit, true);
for (const oldEvent of fixture.earlierEvents)
assert.throws(() => applyAction(pending, fixture.holder, { type: 'semutaCommit', event: oldEvent }));
assert.throws(() => applyAction(pending, fixture.spender, fixture.cancel));
if (kind === 'capture') {
assert.equal(pending.players.some(p => p.leaders.some(l => l.capturedBy || l.concealed)), false);
assert.throws(() => applyAction(pending, 'e', { type: 'decision', mode: 'keep' }));
} else if (kind === 'choamAudit') {
assert.throws(() => applyAction(pending, 'a', { type: 'decision',
event: original.pendingAuditor!.event, pay: false }));
}
const event = receipt.batch.event;
// Save at the physically discarded, still-unsettled boundary; a neutral pass must not retire it.
let saved = applyAction(reloadBattleKarama(pending), fixture.spender, { type: 'semutaPass', event });
assert.equal(saved.pendingTreacheryDiscard?.batch.event, event);
assert.deepEqual(battleQuantities(saved), battleQuantities(original));
assert.throws(() => applyAction(saved, fixture.spender, { type: 'semutaPass', event }));
saved = reloadBattleKarama(saved);
let resumed: Game;
if (claim) resumed = settleBattleKaramaCost(saved, true);
else {
resumed = saved;
for (const seat of resumed.players.filter(p => p.id !== fixture.spender))
resumed = applyAction(resumed, seat.id, { type: 'semutaPass', event });
}
resumed = reloadBattleKarama(resumed);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.pendingCapture == null, true);
assert.equal(resumed.pendingAuditor == null, true);
assert.equal(resumed.auditorInsight == null, true);
assert.deepEqual(battleQuantities(resumed), battleQuantities(nativeCanceled));
assert.equal(battleKaramaPlayer(resumed, fixture.holder).hand.filter(c => c.id === fixture.karama).length, Number(claim));
assert.equal(resumed.discard.filter(c => c.id === fixture.karama).length, Number(!claim));
assert.equal(resumed.discard.filter(c => c.id === fixture.semuta).length, Number(claim));
assert.equal(battleKaramaPlayer(resumed, 'a').hand.some(c => c.id === fixture.privateCard), true);
assert.deepEqual(battleKaramaInventory(resumed), fixture.inventory);
assert.throws(() => applyAction(resumed, fixture.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(resumed, fixture.spender, fixture.cancel));
if (kind === 'choamAudit') {
assert.equal(resumed.pendingChoamBattleIncome, null);
assert.equal(resumed.decision?.kind, 'choamMarket');
assert.throws(() => applyAction(resumed, 'e', { type: 'decision',
event: original.pendingAuditor!.event, audit: true }));
} else if (kind === 'choamBattleIncome') {
assert.equal(resumed.pendingChoamBattleIncome, null);
assert.equal(resumed.decision?.kind, 'choamMarket');
} else {
assert.equal(resumed.phase, 7);
assert.equal(resumed.players.some(p => p.leaders.some(l => l.capturedBy || l.concealed)), false);
assert.throws(() => applyAction(resumed, 'e', { type: 'decision', mode: 'execute' }));
}
const finished = finishBattleKarama(resumed);
assert.equal(finished.turn, 2);
assert.equal(finished.pendingChoamBattleIncome == null, true);
assert.equal(finished.auditorInsight == null, true);
assert.equal(battleKaramaPlayer(finished, 'a').spice, kind === 'choamBattleIncome' ? 12 : 10);
assert.equal(battleKaramaPlayer(finished, 'e').spice, kind === 'capture' ? 15 : kind === 'choamAudit' ? 11 : 10);
assert.equal(battleKaramaPlayer(finished, 'r').spice, 10);
assert.deepEqual(battleKaramaPlayer(finished, fixture.winner).forces, { 'arrakeen:10': 3 });
assert.equal(battleKaramaPlayer(finished, fixture.winner).tanks, 2);
assert.equal(battleKaramaPlayer(finished, fixture.loser).tanks, 5);
assert.deepEqual(finished.players.map(p => p.leaders), original.players.map(p => p.leaders));
assert.deepEqual(battleKaramaInventory(finished), fixture.inventory);
for (const seat of finished.players)
assert.equal(viewGame(finished, seat.id).auditorInsight == null, true);
assert.throws(() => applyAction(finished, fixture.holder, { type: 'semutaCommit', event }));
assert.throws(() => applyAction(finished, 'e', { type: 'decision', done: true }));
if (claim) assert.throws(() => applyAction(finished, fixture.holder, fixture.cancel),
'acquiring the printed cost cannot reopen its original canceled effect');
});
for (const kind of kinds) void test(`all four AI profiles legally participate in the native ${kind} cost receipt`, () => {
const fixture = semutaBattleKaramaFixture(kind);
const pending = applyAction(fixture.originalResponse, fixture.spender, fixture.cancel);
const native = reloadBattleKarama(fixture.originalResponse);
delete native.semutaPreview;
const nativeCanceled = applyAction(native, fixture.spender, fixture.cancel);
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaBattleDiscard');
for (const profile of DIFFICULTIES) for (const id of ['a', 'r', 'e']) {
const game = reloadBattleKarama(pending);
battleKaramaPlayer(game, id).bot = profile;
const action = botActions(viewGame(game, id))[0];
assert.ok(action, `${profile} ${id} must have a neutral reaction action`);
assert.ok(['semutaPass', 'semutaCommit'].includes(action.type));
const after = applyAction(game, id, action);
assert.deepEqual(battleKaramaInventory(after), fixture.inventory);
assert.deepEqual(battleQuantities(after), battleQuantities(after.pendingTreacheryDiscard ? fixture.originalResponse : nativeCanceled));
}
});