Files
Dune/tests/semuta-advanced-public-battle.test.ts
T
Quintine b2bc3f2f01
Verify and publish checkpoint container / container (push) Successful in 10m54s
Extend Advanced Semuta discard continuations
2026-10-29 04:20:17 +11:00

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);
});