Files
Dune/tests/basic-mixed-independent-arrival.test.ts
Quintine 6719245f40
Verify and publish checkpoint container / container (push) Canceled after 0s
Connect independent-card battle and arrival reactions
2026-10-28 22:34:39 +11:00

167 lines
9.8 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { validateAmbassadors, type AmbassadorEffect } from '../game/ecaz-ambassadors';
import { fremenAmbassadorMovement } from '../game/fremen-ambassador';
import { guildAmbassadorShipments } from '../game/guild-ambassador';
import { finishEcazOccupySetup } from './fixture-ecaz-occupy';
import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
const seat = (g: Game, id: string) => g.players.find(p => p.id === id)!;
const quiet = homeworldEcazClean;
/** Native public setup: Ecaz and Moritani with a selected Ix deck and
* Tleilaxu seat. Only the unopened Spice positions are chosen. */
function fixture(first: 'ambassador' | 'terror', effect: AmbassadorEffect = 'ecaz', entrant = 'guild') {
let game = createGame('MIXEDARRIVAL', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'ix']);
for (const faction of ['moritani', 'guild', 'tleilaxu'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); };
if (first === 'ambassador') {
act('ecaz', {type: 'seatPosition', position: 5});
act('moritani', {type: 'seatPosition', position: 1});
act('ecaz', {type: 'seatPosition', position: 2});
}
act('ecaz', {type: 'moduleOption', module: 'ecazTreachery', enabled: true});
for (const player of game.players) act(player.id, {type: 'ready'});
act('ecaz', {type: 'start'});
assert.equal(game.status, 'setup');
assert.equal(game.ecazTreachery, true);
assert.deepEqual(game.expansions, ['ecaz', 'ix']);
game = finishEcazOccupySetup(game);
const afterSetup = structuredClone(game);
for (let index = 0; index < 3; index++) {
const at = game.spiceDeck.findIndex((card, position) => position >= index && 'territory' in card && !card.discovery);
assert.ok(at >= index);
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(at, 1)[0]);
}
const step = () => {
if (game.phase === 5 && game.active && quiet(game)) act(game.active, {type: 'endMovement'});
else {
const next = nextHomeworldEcazStep(game);
assert.ok(next, `Native continuation ${game.turn}/${game.phase}/${game.decision?.kind}`);
act(next.actor, next.action);
}
};
const reach = (predicate: (state: Game) => boolean) => {
for (let count = 0; count < 900 && !predicate(game); count++) step();
assert.ok(predicate(game), 'The native turn must reach its original decision.');
};
reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'ecazPlacement');
const ambassadors = game.ecazAmbassadors!;
const chosen = ambassadors.tokens.find(token => token.effect === effect); assert.ok(chosen);
// Child cases control only the five-token packet, preserving the original
// physical inventory; both actual board placements are still native actions.
if (chosen.zone === 'pool') {
const replaced = ambassadors.tokens.find(token => token.zone === 'supply' && token.effect !== 'ecaz');
assert.ok(replaced);
const index = ambassadors.cohort.indexOf(replaced.id); assert.ok(index >= 0);
ambassadors.cohort[index] = chosen.id; replaced.zone = 'pool'; chosen.zone = 'supply';
validateAmbassadors(ambassadors);
}
assert.equal(chosen.zone, 'supply');
const ambassador = chosen.id;
act('ecaz', {type: 'decision', token: ambassador, territory: 'arrakeen'});
reach(state => !state.response && state.ecazAmbassadors!.tokens.find(token => token.id === ambassador)!.zone === 'placed');
reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'moritaniPlacement');
const terror = game.moritaniTerror!.tokens.find(token => token.kind === 'robbery' && token.status === 'available')!.id;
act('moritani', {type: 'decision', token: terror, territory: 'arrakeen'});
reach(state => state.turn === 2 && state.phase === 5 && state.active === entrant && quiet(state));
const before = structuredClone(game);
const action: Action = {type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0};
act(entrant, action);
reach(state => !!state.pendingArrivalOverlap);
assert.equal(game.pendingArrivalOverlap!.first, first);
return {game, before, action, ambassador, terror, afterSetup, entrant};
}
for (const first of ['ambassador', 'terror'] as const)
void test(`mixed Basic independent cards preserve ${first}-first native arrival and its one payment`, () => {
const f = fixture(first), seen: string[] = [];
let game = f.game;
assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1);
for (let count = 0; count < 50 && (game.pendingArrivalOverlap || game.pendingAmbassador ||
game.pendingTerrorEntry || !quiet(game)); count++) {
let actor: string, action: Action;
if (game.response) {
const next = nextHomeworldEcazStep(game); assert.ok(next);
actor = next.actor; action = next.action;
} else if (game.decision?.kind === 'ecazAmbassador') {
seen.push('ambassador'); actor = 'ecaz';
action = {type: 'decision', event: game.pendingAmbassador!.event,
trigger: true, choice: 'duke', beneficiary: 'ecaz'};
} else if (game.decision?.kind === 'moritaniTerror') {
const entry = viewGame(game, 'moritani').terrorEntry!; assert.ok('stage' in entry);
seen.push(`terror:${entry.stage}`); actor = 'moritani';
action = entry.stage === 'offer' ? {type: 'decision', reveal: true} : {type: 'decision', choice: 'spice'};
} else throw Error(`Unexpected native overlap continuation: ${game.decision?.kind}`);
game = applyAction(game, actor, action);
}
assert.deepEqual(seen, first === 'ambassador' ? ['ambassador', 'terror:offer', 'terror:robbery'] :
['terror:offer', 'terror:robbery', 'ambassador']);
const robbery = Math.ceil((seat(f.before, 'guild').spice - 1) / 2);
assert.equal(seat(game, 'guild').spice, seat(f.before, 'guild').spice - 1 - robbery);
assert.equal(seat(game, 'moritani').spice, seat(f.before, 'moritani').spice + robbery);
assert.equal(seat(game, 'guild').reserves, seat(f.before, 'guild').reserves - 1);
assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1);
assert.equal(game.dukeVidal!.controller, 'ecaz');
assert.equal(game.moritaniTerror!.tokens.find(token => token.id === f.terror)!.status, 'removed');
assert.equal(game.ecazAmbassadors!.tokens.find(token => token.id === f.ambassador)!.zone, 'supply');
assert.equal(game.active, 'guild');
assert.throws(() => applyAction(game, 'guild', f.action));
game = applyAction(game, 'guild', {type: 'endMovement'});
assert.equal(seat(game, 'guild').forces['arrakeen:10'], 1);
});
void test('all four AI profiles can legally respond to a mixed independent-card arrival', () => {
const f = fixture('terror'), actor = f.game.decision!.player;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, actor);
view.players.find(player => player.id === actor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
assert.doesNotThrow(() => applyAction(f.game, actor, action));
}
});
for (const effect of ['fremen', 'guild'] as const)
void test(`mixed Basic independent cards resume a real ${effect} Ambassador child before original Terror`, () => {
// Native Guild entrant does not trigger the Guild Ambassador; Tleilaxu
// supplies a separate genuine paid entry for that physical token.
const entrant = effect === 'guild' ? 'tleilaxu' : 'guild';
const f = fixture('ambassador', effect, entrant);
let game = f.game;
const entry = game.pendingAmbassador; assert.ok(entry);
const before = {spice: seat(game, 'ecaz').spice, reserves: seat(game, 'ecaz').reserves};
game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event, trigger: true, beneficiary: 'ecaz'});
if (effect === 'fremen') {
assert.equal(game.pendingAmbassador?.stage, 'move');
const options = fremenAmbassadorMovement(game, 'ecaz').sources;
const source = options.find(row => row.territory !== 'arrakeen' && row.sectors.some(s => s.forces > 0) &&
row.destinations.some(d => d.territory === 'arrakeen' && d.sector === 10));
assert.ok(source, 'An original Ecaz force can reach the triggering stronghold.');
const sector = source.sectors.find(row => row.forces > 0)!;
game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event,
forces: {[sector.key]: 1}, territory: 'arrakeen', sector: 10});
} else {
assert.equal(game.pendingAmbassador?.stage, 'ship');
assert.ok(guildAmbassadorShipments(game, 'ecaz').destinations.some(d => d.territory === 'arrakeen' && d.sector === 10));
game = applyAction(game, 'ecaz', {type: 'decision', event: entry.event, amount: 1, elite: 0,
territory: 'arrakeen', sector: 10});
assert.equal(seat(game, 'ecaz').reserves, before.reserves - 1);
}
assert.equal(seat(game, 'ecaz').spice, before.spice);
assert.equal(game.pendingTerrorEntry?.entrant, 'ecaz');
assert.equal(game.pendingTerrorEntry?.cause, 'ambassador');
assert.equal(game.pendingTerrorEntry?.token, f.terror);
game = applyAction(game, 'moritani', {type: 'decision', decline: true});
assert.equal(game.pendingTerrorEntry?.entrant, entrant);
game = applyAction(game, 'moritani', {type: 'decision', decline: true});
assert.equal(game.pendingArrivalOverlap, null);
assert.equal(game.pendingAmbassador, null);
assert.equal(game.pendingTerrorEntry, null);
assert.equal(seat(game, entrant).forces['arrakeen:10'], 1);
assert.equal(seat(game, 'ecaz').forces['arrakeen:10'], 1);
});