Files
Dune/tests/paired-e3-skills-arrival.test.ts
Quintine 17adffa6a7 Connect paired E3 Skills without Nexus and retain native arrival independence
Reuse original paired roster validation with plain all14 native setup and optional independent cards. Preserve real coalition, trained death and Mentat paths; extend the existing paid Ambassador/Terror queue to paired Skills and card-only factions. Frozen29-file323/323, types/lint/build, native programmes and390px entry pass. No save conversion or deployment.
2026-10-17 21:12:35 +11:00

102 lines
6.0 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { createEcazIndependentSkillsFixture } from './fixture-ecaz-independent-skills';
import { homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
/** Branch from the genuine original setup snapshot, not its later battle.
* Only unopened land-card order is controlled. Native token declarations,
* both response windows, phase readiness and the paid shipment are actual actions. */
function pairedArrival(advanced: boolean, skills: boolean, independent: boolean) {
const fixture = createEcazIndependentSkillsFixture({ paired: true, advanced, skills,
independent, cards: independent ? ['reinforcements', 'harass'] : [] });
let game = structuredClone(fixture.afterSetup);
const act = (actor: string, action: Action) => { game = applyAction(game, actor, action); };
const step = () => {
if (homeworldEcazClean(game) && game.phase === 5)
act(game.active!, { type: 'endMovement' });
else {
const next = nextHomeworldEcazStep(game);
assert.ok(next, `Native paired continuation at ${game.turn}/${game.phase}/${game.decision?.kind}`);
act(next.actor, next.action);
}
};
const reach = (until: (state: Game) => boolean) => {
for (let n = 0; n < 450 && !until(game); n++) step();
assert.ok(until(game));
};
for (let index = 0; index < (advanced ? 4 : 2); 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]);
}
reach(state => state.turn === 1 && !state.response && state.decision?.kind === 'ecazPlacement');
const ambassador = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz' && token.zone === 'supply')!.id;
act(fixture.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(fixture.ally, { type: 'decision', token: terror, territory: 'arrakeen' });
reach(state => state.turn === 2 && state.phase === 5 && state.active === fixture.opponent && homeworldEcazClean(state));
const before = structuredClone(game);
const shipment: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 };
act(fixture.opponent, shipment);
reach(state => !!state.pendingArrivalOverlap);
return { fixture, game, before, shipment, ambassador, terror };
}
for (const { advanced, skills, independent } of [
{ advanced: false, skills: true, independent: false },
{ advanced: true, skills: true, independent: true },
{ advanced: false, skills: false, independent: true },
])
void test(`${advanced ? 'Advanced' : 'Basic'} paired ${skills ? 'Skills' : 'native factions'} ${independent ? 'with independent cards' : 'without extra cards'} settles both earned reactions without re-paying shipment`, () => {
const f = pairedArrival(advanced, skills, independent), { ecaz, ally: moritani, opponent: guild } = f.fixture;
let game = f.game;
const player = (state: Game, id: string) => state.players.find(seat => seat.id === id)!;
const before = player(f.before, guild), moritaniBefore = player(f.before, moritani).spice;
const first = game.pendingArrivalOverlap!.first;
const seen: string[] = [];
for (let n = 0; n < 40 && (game.pendingArrivalOverlap || game.pendingAmbassador ||
game.pendingTerrorEntry || !homeworldEcazClean(game)); n++) {
let actor: string, action: Action;
if (game.response) {
const next = nextHomeworldEcazStep(game)!;
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 arrival 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((before.spice - 1) / 2);
assert.equal(player(game, guild).spice, before.spice - 1 - robbery);
assert.equal(player(game, moritani).spice, moritaniBefore + robbery);
assert.equal(player(game, guild).reserves, before.reserves - 1);
assert.equal(player(game, guild).forces['arrakeen:10'], 1);
assert.equal(game.dukeVidal!.controller, ecaz);
assert.equal(game.dukeVidal!.leader.strength, 6);
assert.ok(!game.leaderSkills?.assignments.some(assignment => assignment.leader === 'duke-vidal'));
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(viewGame(game, guild).arrivalOverlap, null);
assert.equal(game.active, guild);
const settled = structuredClone(game);
assert.throws(() => applyAction(game, guild, f.shipment));
assert.deepEqual(game, settled);
game = applyAction(game, guild, { type: 'endMovement' });
assert.equal(player(game, guild).reserves, before.reserves - 1);
assert.equal(player(game, guild).forces['arrakeen:10'], 1);
});