154 lines
8.4 KiB
TypeScript
154 lines
8.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
|
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
|
|
|
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
|
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
|
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
|
function inventory(game: Game): string[] {
|
|
const ids = [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
|
|
...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
|
|
assert.equal(new Set(ids).size, ids.length);
|
|
return ids;
|
|
}
|
|
function take(game: Game, predicate: (card: Game['deck'][number]) => boolean) {
|
|
for (const zone of [game.deck, game.discard, game.richeseCache!, ...game.players.map(player => player.hand)]) {
|
|
const index = zone.findIndex(predicate);
|
|
if (index >= 0) return zone.splice(index, 1)[0];
|
|
}
|
|
throw new Error('Missing original physical card.');
|
|
}
|
|
function setup(kind: 'loser' | 'mandatory' | 'selected') {
|
|
let game = createGame('ADVSEMUTABATTLE', newPlayer('a', 'Atreides', 'atreides'), true, ['choam']);
|
|
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
|
joinGame(game, newPlayer('r', 'Richese', 'richese'));
|
|
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
|
game = act(game, 'a', {type:'start', advancedPreview:true});
|
|
assert.equal(game.semutaAdvancedOrdinaryPreview, true);
|
|
assert.equal(game.semutaPreview, undefined);
|
|
for (let n = 0; game.status === 'setup' && n < 100; n++) {
|
|
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, 'Native setup must advance.');
|
|
game = act(game, next.actor, next.action);
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
const cards = inventory(game);
|
|
// Preserve physical inventory while positioning the exact battle cards and
|
|
// keep the discard free from unrelated earlier setup choices.
|
|
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
|
const semuta = take(game, card => card.id === SEMUTA_DRUG_ID);
|
|
const winnerWeapon = take(game, card => card.kind === 'projectile');
|
|
const loserDefense = take(game, card => card.kind === 'shield');
|
|
const hero = kind === 'mandatory' ? take(game, card => card.kind === 'hero') : null;
|
|
seat(game, 'r').hand.push(semuta);
|
|
seat(game, 'a').hand.push(winnerWeapon, ...(hero ? [hero] : []));
|
|
seat(game, 'e').hand.push(loserDefense);
|
|
assert.deepEqual(inventory(game), cards);
|
|
// Put conserved physical force groups in a clear shared territory. Emperor's
|
|
// existing Advanced elite counters stay in reserves; both armies are normal.
|
|
for (const id of ['a', 'e']) {
|
|
const player = seat(game, id);
|
|
player.reserves += Object.values(player.forces).reduce((total, amount) => total + amount, 0);
|
|
player.forces = {};
|
|
if (player.elites) {
|
|
player.elites.reserves += Object.values(player.elites.forces).reduce((total, amount) => total + amount, 0);
|
|
player.elites.forces = {};
|
|
}
|
|
const amount = id === 'a' ? 6 : 2;
|
|
player.reserves -= amount;
|
|
player.forces['red_chasm:7'] = amount;
|
|
}
|
|
Object.assign(game, {phase:6, active:'a', order:['a','e','r'], storm:18, ready:[],
|
|
response:null, decision:null, phaseOpening:null, battle:null});
|
|
game = act(game, 'a', {type:'chooseBattle', territory:'red_chasm', target:'e'});
|
|
for (let n = 0; n < 80; n++) {
|
|
if (game.battle && !game.response && !game.decision && !game.phaseOpening &&
|
|
!game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed)) break;
|
|
const next = nextRicheseStrongholdsNativeStep(game);
|
|
assert.ok(next, 'The current native battle interaction must finish before preparation.');
|
|
game = act(game, next.actor, next.action);
|
|
}
|
|
assert.ok(game.battle && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed));
|
|
const winnerLeader = hero?.id ?? seat(game, 'a').leaders.filter(leader => !leader.dead && !leader.usedAt)
|
|
.sort((left, right) => right.strength - left.strength)[0]!.id;
|
|
const loserLeader = seat(game, 'e').leaders.filter(leader => !leader.dead && !leader.usedAt)
|
|
.sort((left, right) => left.strength - right.strength)[0]!.id;
|
|
game = act(game, 'a', {type:'battlePlan', dial:hero ? 5 : 1, support:hero ? 5 : 1,
|
|
leader:winnerLeader, weapon:winnerWeapon.id, defense:null});
|
|
game = act(game, 'e', {type:'battlePlan', dial:0, support:0,
|
|
leader:loserLeader, weapon:null, defense:loserDefense.id});
|
|
const voters = viewGame(game, 'a').battle!.traitorVoters;
|
|
assert.equal(voters.length, 2);
|
|
game = act(game, voters[0], {type:'traitorCall', call:false});
|
|
return {game, finalActor:voters[1], loserDefense, winnerWeapon, hero, cards};
|
|
}
|
|
function react(game: Game, claim: boolean, selected: string): Game {
|
|
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
|
const event = pending.batch.event;
|
|
if (claim) {
|
|
game = act(game, 'r', {type:'semutaCommit', event});
|
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
|
game = act(game, 'r', {type:'semutaSelect', event, card:selected});
|
|
} else {
|
|
for (const player of game.players) {
|
|
if (!game.pendingTreacheryDiscard) break;
|
|
game = act(game, player.id, {type:'semutaPass', event});
|
|
}
|
|
}
|
|
return game;
|
|
}
|
|
function advance(game: Game, until: (state: Game) => boolean): Game {
|
|
for (let n = 0; n < 400 && !until(game); n++) {
|
|
if (game.pendingTreacheryDiscard?.reaction) game = react(game, false, '');
|
|
else {
|
|
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, `Native continuation needed at ${game.phase}/${game.decision?.kind}`);
|
|
game = act(game, next.actor, next.action);
|
|
}
|
|
}
|
|
assert.ok(until(game));
|
|
return game;
|
|
}
|
|
|
|
for (const kind of ['loser', 'mandatory', 'selected'] as const)
|
|
for (const claim of [false, true]) void test(`public Advanced Semuta ${kind} battle discard: ${claim ? 'claim' : 'pass'}`, () => {
|
|
const f = setup(kind);
|
|
let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, {type:'traitorCall',call:false}));
|
|
if (kind !== 'loser') {
|
|
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory');
|
|
game = react(game, false, f.loserDefense.id);
|
|
if (kind === 'selected') {
|
|
game = advance(game, state => state.decision?.kind === 'battleCards');
|
|
game = act(game, 'a', {type:'decision', discard:[f.winnerWeapon.id]});
|
|
}
|
|
}
|
|
const card = kind === 'loser' ? f.loserDefense : kind === 'mandatory' ? f.hero! : f.winnerWeapon;
|
|
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
|
assert.equal(pending.batch.cause, kind === 'loser' ? 'battle:mandatory' :
|
|
kind === 'mandatory' ? 'battle:winnerMandatory' : 'battle:winner');
|
|
assert.ok(pending.batch.entries.some(entry => entry.card.id === card.id));
|
|
const original = structuredClone(game), event = pending.batch.event;
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const botGame = structuredClone(game);
|
|
seat(botGame, 'r').bot = difficulty;
|
|
const action = botActions(viewGame(botGame, 'r')).find(candidate =>
|
|
candidate.type === 'semutaCommit' || candidate.type === 'semutaPass');
|
|
assert.ok(action, difficulty);
|
|
assert.doesNotThrow(() => act(botGame, 'r', action), difficulty);
|
|
}
|
|
game = react(game, claim, card.id);
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
assert.equal(seat(game, 'r').hand.some(held => held.id === card.id), claim);
|
|
assert.equal(game.discard.some(discarded => discarded.id === card.id), !claim);
|
|
assert.equal(seat(game, 'e').tanks, seat(original, 'e').tanks);
|
|
for (const player of game.players) assert.equal(player.spice, seat(original, player.id).spice);
|
|
assert.deepEqual(inventory(game), f.cards);
|
|
assert.throws(() => act(game, 'r', {type:'semutaCommit', event}));
|
|
assert.equal(game.lastBattleContext?.event, original.lastBattleContext?.event);
|
|
game = advance(game, state => state.phase >= 7 && clean(state));
|
|
assert.deepEqual(inventory(game), f.cards);
|
|
});
|