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.
This commit is contained in:
1 parent
fde44baab5
commit
17adffa6a7
20 files changed
+573
-93
No files matched your search
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user