Files
Dune/tests/semuta-independent-native-substitution.test.ts
T
Quintine 59579486a4
Verify and publish checkpoint container / container (push) Canceled after 0s
Connect Semuta with native allied retention
2026-10-29 03:17:07 +11:00

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