153 lines
8.6 KiB
TypeScript
153 lines
8.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
|
|
import {
|
|
semutaIxAllowResponses, semutaIxInventory, semutaIxPassDiscard, semutaIxPlanSteps,
|
|
semutaIxPlayer, semutaIxSubstitutionStep, type SemutaIxFixture,
|
|
} from './fixture-semuta-ix-substitution';
|
|
|
|
/** A fresh public Basic setup with the three selected families and the
|
|
* independent Ecaz cards. A later battle position is explicitly staged by
|
|
* moving the original held cards and all original forces between legal pools;
|
|
* battle choice, plans, traitor calls and subsequent effects are actual actions. */
|
|
function independentIxBattle(): SemutaIxFixture {
|
|
let game = createGame('SEMUTAINDEPENDENTIX', newPlayer('r', 'Richese', 'richese'), false,
|
|
['choam', 'ix', 'ecaz']);
|
|
joinGame(game, newPlayer('i', 'Ixians', 'ixians'));
|
|
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
|
game = applyAction(game, 'r', {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
|
for (const player of game.players) player.bot = 'Medium';
|
|
for (const id of game.players.map(player => player.id))
|
|
game = applyAction(game, id, {type:'ready'});
|
|
game = applyAction(game, 'r', {type:'start'});
|
|
assert.equal(game.status, 'setup');
|
|
assert.equal(game.semutaRecruitsPreview, true);
|
|
for (let step = 0; (game.status === 'setup' || game.phase === 1 || !game.mobileStronghold?.location) && step < 160; step++) {
|
|
let next: Game | undefined;
|
|
for (const player of game.players) {
|
|
const action = botActions(viewGame(game, player.id))[0];
|
|
if (action) { next = applyAction(game, player.id, action); break; }
|
|
}
|
|
assert.ok(next, 'Native setup and first Storm need a legal action.');
|
|
game = next;
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
assert.ok(game.mobileStronghold?.location);
|
|
const cards = semutaIxInventory(game);
|
|
assert.equal(new Set(cards).size, cards.length);
|
|
for (const player of game.players) {
|
|
game.deck.push(...player.hand); player.hand = [];
|
|
player.reserves += player.tanks + Object.values(player.forces).reduce((sum,n) => sum + n,0);
|
|
player.forces = {}; player.tanks = 0; player.battleLosses = 0;
|
|
if (player.elites) {
|
|
player.elites.reserves += player.elites.tanks + Object.values(player.elites.forces).reduce((sum,n) => sum + n,0);
|
|
player.elites.forces = {}; player.elites.tanks = 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 new Error(`Missing original physical card ${id}.`);
|
|
};
|
|
const semuta = take(SEMUTA_DRUG_ID);
|
|
const tooth = take(game.deck.find(card => card.kind === 'poisonTooth')!.id);
|
|
const shield = take(game.deck.find(card => card.kind === 'shield')!.id);
|
|
const snooper = take(game.deck.find(card => card.kind === 'snooper')!.id);
|
|
semutaIxPlayer(game,'r').hand.push(semuta, take(game.deck.find(card => card.effect === 'karama')!.id));
|
|
semutaIxPlayer(game,'i').hand.push(tooth,shield);
|
|
semutaIxPlayer(game,'e').hand.push(snooper);
|
|
const location = 'arrakeen:10', ix = semutaIxPlayer(game,'i'), emperor = semutaIxPlayer(game,'e');
|
|
assert.equal(ix.reserves,20); assert.equal(ix.elites!.reserves,7);
|
|
ix.reserves -= 3; ix.forces[location] = 3; ix.elites!.reserves--; ix.elites!.forces[location] = 1;
|
|
emperor.reserves -= 5; emperor.forces[location] = 5;
|
|
Object.assign(game,{turn:2,phase:6,active:'i',order:['i','e','r'],storm:18,
|
|
ready:[],response:null,decision:null,phaseOpening:null,movementRemaining:[]});
|
|
assert.deepEqual(semutaIxInventory(game),cards);
|
|
return {game, holder:'r', winner:'i', loser:'e', territory:'arrakeen', location,
|
|
semuta:semuta.id, tooth:tooth.id, shield:shield.id, snooper:snooper.id, cards};
|
|
}
|
|
|
|
function loserDiscard(): SemutaIxFixture {
|
|
const fixture = independentIxBattle();
|
|
let game = applyAction(fixture.game,fixture.winner,{type:'chooseBattle',territory:fixture.territory,target:fixture.loser});
|
|
for (let step = 0; step < 40; step++) {
|
|
if (game.response) {
|
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
|
game = applyAction(game,actor.id,{type:'passResponse'});
|
|
} else if (game.battle?.preparation)
|
|
game = applyAction(game,game.battle.preparation.owner,{type:'declineBattlePower'});
|
|
else if (game.decision?.kind === 'fullPlanOffer')
|
|
game = applyAction(game,game.decision.player,{type:'decision',decline:true});
|
|
else break;
|
|
}
|
|
for (const actor of [fixture.winner,fixture.loser])
|
|
game = applyAction(game,actor,{type:'battlePreparationReady',event:game.battle!.preLeader!.event});
|
|
for (const step of semutaIxPlanSteps(game,fixture)) game = applyAction(game,step.actor,step.action);
|
|
assert.equal(game.decision?.kind,'poisonTooth');
|
|
game = applyAction(game,fixture.winner,{type:'decision',activate:true});
|
|
for (const actor of [fixture.winner,fixture.loser])
|
|
game = applyAction(game,actor,{type:'traitorCall',call:false});
|
|
game = finishSemutaBattleOutcome(game);
|
|
return {...fixture,game};
|
|
}
|
|
|
|
for (const claim of [false,true]) for (const substitute of [false,true]) {
|
|
void test(`Basic independent cards: Ixian loser discard ${claim ? 'claim' : 'pass'}, then ${substitute ? 'substitute' : 'decline'}`, () => {
|
|
const fixture = loserDiscard(), pending = fixture.game;
|
|
const batch = pending.pendingTreacheryDiscard; assert.ok(batch?.reaction);
|
|
assert.equal(batch.reaction.stage,'offer');
|
|
assert.equal(batch.continuation.kind,'battleResolved');
|
|
assert.deepEqual(batch.batch.entries.map(entry => entry.card.id),[fixture.snooper]);
|
|
assert.deepEqual(pending.pendingWinnerDiscards?.cards,[fixture.tooth]);
|
|
assert.equal(pending.pendingIxSubstitution ?? null,null);
|
|
const event = batch.batch.event;
|
|
let game = claim ? applyAction(pending,fixture.holder,{type:'semutaCommit',event}) : semutaIxPassDiscard(pending);
|
|
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
|
|
game = applyAction(game,fixture.holder,{type:'semutaSelect',event,card:fixture.snooper});
|
|
assert.equal(game.decision?.kind,'ixSubstitution');
|
|
assert.equal(game.decision?.player,fixture.winner);
|
|
assert.deepEqual(game.pendingIxSubstitution!.losses,{[fixture.location]:1});
|
|
assert.equal(semutaIxPlayer(game,fixture.winner).tanks,1);
|
|
assert.equal(semutaIxPlayer(game,fixture.winner).elites!.tanks,1);
|
|
assert.equal(semutaIxPlayer(game,fixture.holder).hand.some(card => card.id === fixture.snooper),claim);
|
|
assert.equal(game.discard.some(card => card.id === fixture.tooth),false);
|
|
const step = semutaIxSubstitutionStep(fixture,substitute);
|
|
game = applyAction(game,step.actor,step.action);
|
|
if (substitute) {
|
|
assert.equal(game.response?.kind,'ixSubstitution');
|
|
game = semutaIxAllowResponses(game);
|
|
}
|
|
assert.equal(game.pendingIxSubstitution,null);
|
|
assert.equal(semutaIxPlayer(game,fixture.winner).elites!.tanks,substitute ? 0 : 1);
|
|
assert.equal(semutaIxPlayer(game,fixture.winner).battleLosses,1);
|
|
assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'winnerMandatoryDiscard');
|
|
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id),[fixture.tooth]);
|
|
game = semutaIxPassDiscard(game);
|
|
assert.equal(game.decision?.kind,'battleCards');
|
|
game = applyAction(game,fixture.winner,{type:'decision',discard:[fixture.shield]});
|
|
if (game.pendingTreacheryDiscard) game = semutaIxPassDiscard(game);
|
|
assert.equal(game.phase,7);
|
|
assert.deepEqual(semutaIxInventory(game),fixture.cards);
|
|
assert.equal(game.discard.filter(card => card.id === fixture.semuta).length,Number(claim));
|
|
for (const player of game.players)
|
|
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum,n) => sum + n,0),20);
|
|
});
|
|
}
|
|
|
|
void test('all four AI profiles can answer the fresh discard before Ixian substitution', () => {
|
|
const fixture = loserDiscard();
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(fixture.game,fixture.holder);
|
|
view.players.find(player => player.id === fixture.holder)!.bot = difficulty;
|
|
const action = botActions(view).find(item => item.type === 'semutaCommit' || item.type === 'semutaPass');
|
|
assert.ok(action);
|
|
assert.doesNotThrow(() => applyAction(fixture.game,fixture.holder,action));
|
|
}
|
|
});
|