159 lines
9.8 KiB
TypeScript
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));
|
|
}
|
|
});
|