115 lines
6.7 KiB
TypeScript
115 lines
6.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { type Game, viewGame } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { act, clean, createIndependentSemutaBattleFixture as fixture, inventory, player } from './fixture-semuta-independent-battle';
|
|
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
|
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
|
|
|
function react(game: Game, holder: string, claim: boolean, selected: string) {
|
|
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
|
const event = pending.batch.event;
|
|
if (!claim) {
|
|
for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event});
|
|
return game;
|
|
}
|
|
game = act(game, holder, {type:'semutaCommit', event});
|
|
return game.pendingTreacheryDiscard?.reaction?.stage === 'select'
|
|
? act(game, holder, {type:'semutaSelect', event, card:selected}) : game;
|
|
}
|
|
|
|
function advance(game: Game, until: (state: Game) => boolean): Game {
|
|
for (let count = 0; count < 600 && !until(game); count++) {
|
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
|
game = react(game, '', false, '');
|
|
continue;
|
|
}
|
|
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
|
|
game = act(game, next.actor, next.action);
|
|
}
|
|
assert.ok(until(game)); return game;
|
|
}
|
|
|
|
for (const kind of ['loser-reinforcements', 'loser-harass', 'winner-mandatory', 'winner-selected'] as const)
|
|
for (const claim of [false, true]) void test(`Basic independent battle ${kind}: ${claim ? 'claim' : 'pass'} without replay`, () => {
|
|
const f = fixture(kind);
|
|
let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction));
|
|
if (kind === 'winner-selected') {
|
|
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory');
|
|
game = react(game, f.holder, false, f.card);
|
|
game = advance(game, state => state.decision?.kind === 'battleCards' && state.decision.player === f.winner);
|
|
assert.ok(player(game, f.winner).hand.some(card => card.id === f.shield));
|
|
game = act(game, f.winner, {type:'decision', discard:[f.shield]});
|
|
}
|
|
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
|
assert.equal(pending.batch.cause, kind === 'winner-selected' ? 'battle:winner' :
|
|
kind === 'winner-mandatory' ? 'battle:winnerMandatory' : 'battle:mandatory');
|
|
const card = kind === 'winner-selected' ? f.shield : f.card;
|
|
assert.ok(pending.batch.entries.some(entry => entry.card.id === card));
|
|
const before = structuredClone(game);
|
|
const ownTanks = player(before, f.winner).tanks;
|
|
const ownForces = player(before, f.winner).forces[f.location] ?? 0;
|
|
const casualties = pending.continuation.kind === 'battleResolved' ? pending.continuation.casualties : undefined;
|
|
const immediateLoss = casualties?.options.length === 1
|
|
? casualties.options[0].normal + casualties.options[0].elite : 0;
|
|
game = react(game, f.holder, claim, card);
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
assert.equal(player(game, f.loser).tanks, player(before, f.loser).tanks);
|
|
assert.equal(player(game, f.winner).tanks, ownTanks + immediateLoss);
|
|
assert.equal(player(game, f.winner).forces[f.location] ?? 0,
|
|
ownForces - immediateLoss);
|
|
assert.equal(game.lastBattleContext?.event, before.lastBattleContext?.event);
|
|
for (const seat of game.players) assert.equal(seat.spice, player(before, seat.id).spice);
|
|
assert.equal(player(game, f.holder).hand.some(held => held.id === card), claim);
|
|
assert.equal(game.discard.some(discarded => discarded.id === card), !claim);
|
|
assert.equal(game.discard.some(discarded => discarded.id === SEMUTA_DRUG_ID), claim);
|
|
assert.deepEqual(inventory(game), f.originalInventory);
|
|
assert.throws(() => act(game, f.holder, {type:'semutaCommit', event:pending.batch.event}));
|
|
game = advance(game, state => state.phase >= 7 && clean(state));
|
|
assert.equal(game.turn, 1);
|
|
assert.equal(player(game, f.winner).tanks,
|
|
player(f.game, f.winner).tanks + 1 + (kind === 'winner-mandatory' ? 3 : 0));
|
|
if (kind === 'winner-mandatory')
|
|
assert.equal(player(game, f.winner).reserves, player(f.game, f.winner).reserves - 3);
|
|
assert.equal(player(game, f.winner).forces[f.location], 3);
|
|
});
|
|
|
|
void test('each AI profile may legally answer the native independent battle discard', () => {
|
|
const f = fixture('loser-reinforcements'), pending = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction));
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const game = structuredClone(pending);
|
|
player(game, f.holder).bot = difficulty;
|
|
const action = botActions(viewGame(game, f.holder)).find(candidate =>
|
|
candidate.type === 'semutaCommit' || candidate.type === 'semutaPass');
|
|
assert.ok(action); assert.doesNotThrow(() => act(game, f.holder, action));
|
|
}
|
|
});
|
|
|
|
void test('an earlier pending battle discard resumes automatically without creating an offer', () => {
|
|
const f = fixture('loser-reinforcements');
|
|
const produced = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction));
|
|
assert.equal(produced.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory');
|
|
assert.ok(produced.pendingTreacheryDiscard?.reaction);
|
|
// Model a saved automatic continuation created before this battle source was
|
|
// admitted to the independent-card Semuta preview; all other facts are real.
|
|
const saved = structuredClone(produced);
|
|
delete saved.pendingTreacheryDiscard!.reaction;
|
|
assert.equal(viewGame(saved, f.holder).automaticContinuationPending, true);
|
|
assert.equal(viewGame(saved, f.holder).semutaReaction, null);
|
|
const resumed = act(saved, f.holder, {type:'advanceBots'});
|
|
assert.equal(resumed.pendingTreacheryDiscard, null);
|
|
assert.equal(viewGame(resumed, f.holder).semutaReaction, null);
|
|
assert.equal(resumed.discard.filter(card => card.id === f.card).length, 1);
|
|
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
|
assert.equal(player(resumed, f.loser).tanks, player(saved, f.loser).tanks);
|
|
const continuation = saved.pendingTreacheryDiscard!.continuation;
|
|
const casualties = continuation.kind === 'battleResolved' ? continuation.casualties : undefined;
|
|
const immediateLoss = casualties?.options.length === 1
|
|
? casualties.options[0].normal + casualties.options[0].elite : 0;
|
|
assert.equal(player(resumed, f.winner).tanks, player(saved, f.winner).tanks + immediateLoss);
|
|
assert.equal(resumed.lastBattleContext?.event, saved.lastBattleContext?.event);
|
|
assert.deepEqual(inventory(resumed), f.originalInventory);
|
|
});
|