137 lines
7.0 KiB
TypeScript
137 lines
7.0 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { viewGame, type Game } from '../game/engine';
|
|
import { TERRITORIES } from '../game/board';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { nextOccupyRichese, finishOccupyRichese } from './fixture-ecaz-occupy-richese';
|
|
import { act, inventory, player, createSaphoTruthOrderFixture,
|
|
truthOrderFrame as frame, playSaphoTruthOrder as play, finishTruthOrder as finish,
|
|
finishOriginalTruthQuestions as answer, passFreshDiscard } from './fixture-sapho-truth-order';
|
|
|
|
const clean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
|
|
!game.pendingTreacheryDiscard && !game.truthtrance;
|
|
function advance(game: Game, until: (game: Game) => boolean): Game {
|
|
for (let i = 0; i < 200 && !until(game); i++) game = nextOccupyRichese(game);
|
|
assert.ok(until(game), 'The native battle must reach the requested control.');
|
|
return game;
|
|
}
|
|
|
|
/** Genuine public Basic setup and paid cache purchase, then two native paid
|
|
* shipments and the actual battle choice. The inherited fixture swaps one
|
|
* original Truthtrance with another held card so its real Sapho holder can
|
|
* ask; the claim supplement swaps the original cached Semuta into Atreides'
|
|
* hand. No phase, battle, plan, answer or spice balance is installed. */
|
|
function battleSource(mode: 'first' | 'last', semutaClaim = false) {
|
|
const f = createSaphoTruthOrderFixture({ mode });
|
|
let game = structuredClone(f.before);
|
|
if (semutaClaim) {
|
|
const claimant = player(game, 'atreides'), cache = game.richeseCache;
|
|
assert.ok(cache && claimant.hand.length);
|
|
const at = cache.findIndex(card => card.effect === 'semutaDrug');
|
|
assert.ok(at >= 0);
|
|
const [semuta] = cache.splice(at, 1), [exchange] = claimant.hand.splice(0, 1);
|
|
claimant.hand.push(semuta); cache.push(exchange);
|
|
}
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
const site = TERRITORIES.find(t => t.id === 'the_great_flat')!;
|
|
const sector = site.sectors.find(value => value !== game.storm);
|
|
assert.notEqual(sector, undefined);
|
|
while (game.phase === 5) {
|
|
if (!clean(game)) { game = nextOccupyRichese(game); continue; }
|
|
const actor = game.active!;
|
|
if (actor === 'atreides' || actor === 'emperor') {
|
|
game = act(game, actor, { type: 'ship', territory: site.id, sector, amount: 1 });
|
|
game = advance(game, clean);
|
|
}
|
|
game = act(game, actor, { type: 'endMovement' });
|
|
}
|
|
game = advance(game, state => state.phase === 6 && clean(state) && !!state.active && !state.battle);
|
|
const choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === site.id);
|
|
assert.ok(choice);
|
|
game = act(game, choice.chooser, { type: 'chooseBattle', territory: site.id,
|
|
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
|
|
game = advance(game, state => !!state.battle && clean(state) && !state.battle.preparation &&
|
|
(!state.battle.preLeader || state.battle.preLeader.closed));
|
|
const battle = structuredClone(game.battle);
|
|
for (const actor of [f.owner, f.other]) {
|
|
const truth = player(game, actor).hand.find(card => card.effect === 'truthtrance');
|
|
assert.ok(truth);
|
|
game = act(game, actor, { type: 'card', card: truth.id });
|
|
}
|
|
while (game.truthtrance?.stage === 'priority') {
|
|
const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id));
|
|
assert.ok(seat);
|
|
game = act(game, seat.id, { type: 'truthPass' });
|
|
}
|
|
const current = frame(game);
|
|
assert.deepEqual([...current.holders].sort(), [f.owner, f.other].sort());
|
|
assert.equal(mode === 'first' ? game.truthtrance!.queue.at(-1)!.player : game.truthtrance!.queue[0].player, f.owner);
|
|
assert.deepEqual(game.battle, battle);
|
|
return { ...f, game, battle, semutaClaim };
|
|
}
|
|
|
|
for (const mode of ['first', 'last'] as const) for (const claim of [false, true])
|
|
void test(`Basic battle Truthtrance Sapho ${mode} survives Semuta ${claim ? 'claim' : 'pass'} and the native battle`, () => {
|
|
const f = battleSource(mode, claim);
|
|
const before = structuredClone(f.game.battle), order = [...f.game.order];
|
|
const option = viewGame(f.game, f.owner).saphoTruthOrderChoices.find(row => row.card === f.card && row.mode === mode);
|
|
assert.ok(option);
|
|
let game = play(f.game, f.owner, f.card, mode);
|
|
assert.ok(game.pendingTreacheryDiscard?.reaction);
|
|
if (claim) {
|
|
const event = game.pendingTreacheryDiscard.batch.event;
|
|
assert.equal(viewGame(game, 'atreides').semutaReaction?.canCommit, true);
|
|
game = act(game, 'atreides', { type: 'semutaCommit', event });
|
|
assert.ok(player(game, 'atreides').hand.some(card => card.id === f.card));
|
|
} else game = passFreshDiscard(game);
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(game.battle, before, 'No battle plans, preparations or original event are replayed or changed.');
|
|
assert.deepEqual(game.order, order);
|
|
game = finish(game);
|
|
assert.equal(mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player, f.owner);
|
|
game = answer(game);
|
|
assert.equal(game.truthtrance, null);
|
|
assert.deepEqual(game.battle, before);
|
|
const battle = game.battle!;
|
|
for (const actor of [battle.attacker, battle.defender]) {
|
|
const leader = player(game, actor).leaders.find(row => !row.dead && !row.usedAt);
|
|
assert.ok(leader);
|
|
game = act(game, actor, { type: 'battlePlan', dial: 0, support: 0, leader: leader.id });
|
|
}
|
|
assert.ok(game.battle?.revealed);
|
|
game = finishOccupyRichese(game);
|
|
assert.equal(game.battle, null);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('All minimal bot profiles can use the Basic battle Truth-order window legally', () => {
|
|
const f = battleSource('first');
|
|
for (const difficulty of DIFFICULTIES) for (const actor of [f.owner, f.other]) {
|
|
const view = viewGame(f.game, actor);
|
|
view.players.find(row => row.id === actor)!.bot = difficulty;
|
|
const action = botActions(view)[0];
|
|
assert.ok(action && (action.type === 'truthOrderPass' || action.type === 'saphoTruthOrder'));
|
|
const game = act(f.game, actor, action);
|
|
assert.deepEqual(game.battle, f.battle);
|
|
}
|
|
});
|
|
|
|
void test('Battle Sapho continuation rejects a changed battle and keeps committed cards reserved', () => {
|
|
const f = battleSource('first');
|
|
// Controlled reservation probe: put the actual Sapho into the owner's
|
|
// sealed weapon slot to exercise the shared reservation guard directly.
|
|
const reserved = structuredClone(f.game);
|
|
reserved.battle!.plans[f.owner] = { leader: null, dial: 0, support: 0,
|
|
weapon: f.card, defense: null };
|
|
assert.deepEqual(viewGame(reserved, f.owner).saphoTruthOrderChoices, []);
|
|
|
|
const pending = play(f.game, f.owner, f.card, 'first');
|
|
assert.ok(pending.pendingTreacheryDiscard?.reaction);
|
|
const changed = structuredClone(pending);
|
|
changed.battle!.event = 'different-battle';
|
|
const snapshot = structuredClone(changed);
|
|
assert.throws(() => passFreshDiscard(changed));
|
|
assert.deepEqual(changed, snapshot);
|
|
});
|