112 lines
6.6 KiB
TypeScript
112 lines
6.6 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 { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
|
import { basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador';
|
|
|
|
const act = (g: Game, id: string, action: Action) => applyAction(g, id, action);
|
|
type Native = 'tleilaxu' | 'ecaz';
|
|
|
|
/** Public selected-family setup; the later forces and held physical cards are
|
|
* deliberately moved between their original pools. Battle selection and both
|
|
* preparation controls, Poison and Semuta are actual actions. */
|
|
function fixture(native: Native) {
|
|
let game = createGame('SEMUTANATIVERESIDUAL', newPlayer(native, native, native), false,
|
|
['choam', 'ix', 'ecaz']);
|
|
for (const faction of ['atreides', 'richese', 'guild'] as const)
|
|
joinGame(game, newPlayer(faction, faction, faction));
|
|
game = act(game, native, {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
|
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
|
|
game = act(game, native, {type:'start'});
|
|
assert.equal(game.semutaRecruitsPreview, true);
|
|
for (let count = 0; game.status === 'setup' && count < 150; count++) {
|
|
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next);
|
|
game = act(game, next.actor, next.action);
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
const original = inventory(game);
|
|
for (const seat of game.players) {
|
|
game.deck.push(...seat.hand); seat.hand = [];
|
|
seat.reserves += seat.tanks + Object.values(seat.forces).reduce((sum, n) => sum + n, 0);
|
|
seat.tanks = 0; seat.forces = {}; seat.shipped = false; seat.moved = 0;
|
|
}
|
|
const take = (id: string) => {
|
|
for (const zone of [game.deck, game.discard, game.richeseCache!]) {
|
|
const index = zone.findIndex(card => card.id === id);
|
|
if (index >= 0) return zone.splice(index, 1)[0];
|
|
}
|
|
throw Error(`Missing physical card ${id}`);
|
|
};
|
|
const poisonId = game.richeseCache!.find(card => card.effect === 'residualPoison')!.id;
|
|
player(game, native).hand.push(take(poisonId));
|
|
player(game, 'guild').hand.push(take(SEMUTA_DRUG_ID));
|
|
const location = 'red_chasm:7';
|
|
for (const id of [native, 'atreides']) {
|
|
player(game, id).reserves -= 3; player(game, id).forces[location] = 3;
|
|
}
|
|
Object.assign(game, {turn:2, phase:6, storm:18, active:native,
|
|
order:[native, 'atreides', 'richese', 'guild'], ready:[], response:null,
|
|
decision:null, phaseOpening:null, movementRemaining:[]});
|
|
assert.deepEqual(inventory(game), original);
|
|
game = act(game, native, {type:'chooseBattle', territory:'red_chasm', target:'atreides'});
|
|
for (let count = 0; count < 30; count++) {
|
|
if (game.response) {
|
|
const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(next);
|
|
game = act(game, next.id, {type:'passResponse'});
|
|
} else if (game.battle?.preparation) {
|
|
game = act(game, game.battle.preparation.owner, {type:'declineBattlePower'});
|
|
} else if (game.decision?.kind === 'fullPlanOffer') {
|
|
game = act(game, game.decision.player, {type:'decision', decline:true});
|
|
} else break;
|
|
}
|
|
assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed);
|
|
const quote = viewGame(game, native).residualPoison;
|
|
assert.ok(quote && !quote.blocked && quote.event && quote.target);
|
|
return {game, native, holder:'guild', poisonId, event:game.battle.event!, original,
|
|
poisonAction:{type:'card', card:poisonId, target:quote.target, event:quote.event} as Action};
|
|
}
|
|
|
|
for (const native of ['tleilaxu', 'ecaz'] as const) for (const ready of ['owner', 'opponent'] as const)
|
|
for (const claim of [false, true]) {
|
|
void test(`Basic ${native} Residual Poison ${ready}-ready Semuta ${claim ? 'claim' : 'pass'} resumes preparation`, () => {
|
|
const f = fixture(native), readyPlayer = ready === 'owner' ? native : 'atreides';
|
|
let game = act(f.game, readyPlayer, {type:'battlePreparationReady', event:f.event});
|
|
const before = structuredClone(game), deaths = player(game, 'atreides').leaders.filter(leader => leader.dead).map(leader => leader.id);
|
|
game = act(game, native, f.poisonAction);
|
|
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
|
assert.equal(pending.continuation.kind, 'residualPoisonDiscard');
|
|
assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id, entry.discardedBy, entry.publicFace]), [[f.poisonId, native, true]]);
|
|
assert.deepEqual(game.battle!.preLeader!.ready, [readyPlayer]);
|
|
const newlyDead = player(game, 'atreides').leaders.filter(leader => leader.dead && !deaths.includes(leader.id));
|
|
assert.equal(newlyDead.length, 1);
|
|
for (const seat of game.players) {
|
|
const view = viewGame(game, seat.id);
|
|
assert.deepEqual(view.semutaReaction?.candidates, []);
|
|
assert.equal(view.semutaReaction?.canCommit, seat.id === f.holder);
|
|
}
|
|
if (ready === 'opponent') for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(game, f.holder); view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
|
const action = botActions(view).find(row => row.type === 'semutaCommit' || row.type === 'semutaPass');
|
|
assert.ok(action); assert.doesNotThrow(() => act(game, f.holder, action));
|
|
}
|
|
const event = pending.batch.event;
|
|
if (claim) {
|
|
game = act(game, f.holder, {type:'semutaCommit', event});
|
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
|
game = act(game, f.holder, {type:'semutaSelect', event, card:f.poisonId});
|
|
} else for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event});
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(game.battle!.preLeader!.ready, ready === 'owner' ? [] : ['atreides']);
|
|
assert.deepEqual(game.battle!.plans, before.battle!.plans);
|
|
assert.equal(player(game, 'atreides').leaders.find(leader => leader.id === newlyDead[0].id)?.dead, true);
|
|
assert.equal(player(game, f.holder).hand.some(card => card.id === f.poisonId), claim);
|
|
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
|
|
assert.equal(game.discard.some(card => card.id === f.poisonId), !claim);
|
|
assert.deepEqual(inventory(game), f.original);
|
|
assert.throws(() => act(game, f.holder, {type:'semutaCommit', event}));
|
|
});
|
|
}
|