Integrate native Ixian CHOAM and Tleilaxu Occupy aftermath
This commit is contained in:
1 parent
3333432be8
commit
1057eb2719
17 files changed
+934
-89
No files matched your search
@@ -0,0 +1,237 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { ecazOccupyTerritoryForces } from './fixture-ecaz-occupy';
|
||||
import {
|
||||
nativeIxOccupyCase, nativeChoamOccupyCase, nativeOccupyPlayer,
|
||||
type NativeIxOccupyCase, type NativeChoamOccupyCase,
|
||||
} from './fixture-ecaz-ix-choam';
|
||||
|
||||
function forces(game: Game, result: NativeIxOccupyCase | NativeChoamOccupyCase, owner: string): number {
|
||||
return ecazOccupyTerritoryForces(game, owner, result.fixture.territory);
|
||||
}
|
||||
function cyborgs(game: Game, result: NativeIxOccupyCase): number {
|
||||
return nativeOccupyPlayer(game, result.fixture.ally).elites?.forces[result.fixture.location] ?? 0;
|
||||
}
|
||||
function conservedIx(game: Game, result: NativeIxOccupyCase): void {
|
||||
const ix = nativeOccupyPlayer(game, result.fixture.ally);
|
||||
assert.equal(ix.reserves + ix.tanks + Object.values(ix.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
assert.ok(ix.elites);
|
||||
assert.equal(ix.elites.reserves + ix.elites.tanks +
|
||||
Object.values(ix.elites.forces).reduce((sum, count) => sum + count, 0), 7);
|
||||
}
|
||||
function sameEvent(result: NativeIxOccupyCase): void {
|
||||
const event = result.before.battle?.ecazOccupy?.event;
|
||||
assert.ok(event);
|
||||
assert.equal(result.afterLosses.lastBattleContext?.event, event,
|
||||
'Typed physical casualties belong to the original Occupy battle.');
|
||||
assert.equal(result.game.lastBattleContext?.event, event,
|
||||
'Ix substitution and selected-lead cleanup must not create a second battle.');
|
||||
}
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
for (const commitment of ['funded', 'half'] as const)
|
||||
void test(`Native mixed Ix ${commitment} commitment with ${lead} lead settles typed losses, Suboids and lead cards`, () => {
|
||||
const result = nativeIxOccupyCase({ lead, commitment });
|
||||
const { fixture, actor, before, afterReveal, afterLosses, afterSubstitution, game } = result;
|
||||
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
||||
const expectedLost = commitment === 'funded' ? 4 : 5;
|
||||
const expectedCyborgsLost = commitment === 'funded' ? 2 : 1;
|
||||
const profile = viewGame(before, actor).battle?.ecazOccupy?.profile;
|
||||
assert.ok(profile);
|
||||
assert.equal(profile.forceOwner, fixture.ally);
|
||||
assert.equal(profile.payer, actor);
|
||||
assert.equal(profile.forces.normalFixedHalf, true, 'Actual Suboids stay half-strength regardless of selected lead.');
|
||||
assert.equal(profile.maxSupport, 2, 'Only two actual Cyborgs can receive paid support.');
|
||||
assert.equal(profile.fixedEcazDial, 3);
|
||||
assert.ok(afterReveal.decision?.kind === 'battleLosses');
|
||||
assert.equal(afterReveal.decision.player, actor);
|
||||
assert.equal(afterReveal.decision.forceOwner, fixture.ally);
|
||||
assert.ok(afterReveal.decision.options.some(loss => loss.normal === (commitment === 'funded' ? 2 : 4) &&
|
||||
loss.elite === expectedCyborgsLost), 'The selected dial has a real physical typed winner allocation.');
|
||||
assert.equal(forces(afterLosses, result, fixture.ally), 8 - expectedLost);
|
||||
assert.equal(cyborgs(afterLosses, result), 2 - expectedCyborgsLost);
|
||||
assert.equal(nativeOccupyPlayer(afterLosses, fixture.ally).elites!.tanks -
|
||||
nativeOccupyPlayer(before, fixture.ally).elites!.tanks, expectedCyborgsLost);
|
||||
assert.ok(afterLosses.decision?.kind === 'ixSubstitution');
|
||||
assert.equal(afterLosses.decision.player, fixture.ally);
|
||||
assert.equal(afterLosses.decision.losses[fixture.location], expectedCyborgsLost);
|
||||
assert.equal(forces(afterSubstitution, result, fixture.ally), 8 - expectedLost,
|
||||
'A surviving Suboid is exchanged, not an additional Cyborg created.');
|
||||
assert.equal(cyborgs(afterSubstitution, result), 2);
|
||||
assert.equal(nativeOccupyPlayer(afterSubstitution, fixture.ally).elites!.tanks,
|
||||
nativeOccupyPlayer(before, fixture.ally).elites!.tanks);
|
||||
assert.equal(nativeOccupyPlayer(afterSubstitution, fixture.ally).tanks -
|
||||
nativeOccupyPlayer(before, fixture.ally).tanks, expectedLost);
|
||||
assert.ok(afterSubstitution.decision?.kind === 'battleCards');
|
||||
assert.equal(afterSubstitution.decision.player, actor,
|
||||
'Native Ix substitution resumes the selected leader/card owner, not the force owner.');
|
||||
assert.equal(game.lastBattleContext?.winner, actor);
|
||||
assert.equal(forces(game, result, fixture.ecaz), 2);
|
||||
assert.equal(forces(game, result, fixture.opponent), 0);
|
||||
assert.equal(nativeOccupyPlayer(before, actor).spice - nativeOccupyPlayer(game, actor).spice,
|
||||
commitment === 'funded' ? 1 : 0, 'Suboids and fixed Ecaz contribute no paid support.');
|
||||
assert.equal(nativeOccupyPlayer(before, nonLead).spice, nativeOccupyPlayer(game, nonLead).spice);
|
||||
assert.deepEqual(nativeOccupyPlayer(game, nonLead).hand, nativeOccupyPlayer(before, nonLead).hand);
|
||||
assert.ok(game.discard.some(card => card.id === result.shield));
|
||||
assert.ok(!nativeOccupyPlayer(game, actor).hand.some(card => card.id === result.shield));
|
||||
sameEvent(result);
|
||||
for (const checkpoint of [before, afterLosses, afterSubstitution, game]) conservedIx(checkpoint, result);
|
||||
assert.ok(game.phase >= 7);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Native Ix zero variable commitment with ${lead} lead still pays the mandatory free Ecaz losses`, () => {
|
||||
const result = nativeIxOccupyCase({ lead, commitment: 'zero' });
|
||||
const { fixture, actor, before, game } = result;
|
||||
assert.equal(game.lastBattleContext?.winner, actor);
|
||||
assert.equal(forces(game, result, fixture.ecaz), 2);
|
||||
assert.equal(forces(game, result, fixture.ally), 8);
|
||||
assert.equal(cyborgs(game, result), 2);
|
||||
assert.equal(nativeOccupyPlayer(game, fixture.ally).tanks, nativeOccupyPlayer(before, fixture.ally).tanks);
|
||||
assert.equal(nativeOccupyPlayer(game, actor).spice, nativeOccupyPlayer(before, actor).spice);
|
||||
assert.notEqual(result.afterLosses.decision?.kind, 'ixSubstitution');
|
||||
conservedIx(game, result);
|
||||
});
|
||||
|
||||
void test('Native Karama cancellation with Ecaz lead uses only Ecaz own army and leaves mixed Ix untouched on a win', () => {
|
||||
const result = nativeIxOccupyCase({ lead: 'ecaz', canceled: true });
|
||||
const { fixture, before, game } = result;
|
||||
const profile = viewGame(before, fixture.ecaz).battle?.ecazOccupy?.profile;
|
||||
assert.ok(profile?.canceled);
|
||||
assert.equal(profile.forceOwner, fixture.ecaz);
|
||||
assert.equal(profile.fixedEcazDial, 0);
|
||||
assert.equal(profile.forces.normalFixedHalf, false);
|
||||
assert.equal(game.lastBattleContext?.winner, fixture.ecaz);
|
||||
assert.equal(forces(game, result, fixture.ecaz), 3);
|
||||
assert.equal(forces(game, result, fixture.ally), 8);
|
||||
assert.equal(cyborgs(game, result), 2);
|
||||
assert.equal(nativeOccupyPlayer(before, fixture.ecaz).spice - nativeOccupyPlayer(game, fixture.ecaz).spice, 2);
|
||||
assert.equal(nativeOccupyPlayer(game, fixture.ally).tanks, nativeOccupyPlayer(before, fixture.ally).tanks);
|
||||
assert.equal(nativeOccupyPlayer(game, fixture.ally).elites!.tanks, nativeOccupyPlayer(before, fixture.ally).elites!.tanks);
|
||||
assert.deepEqual(nativeOccupyPlayer(game, fixture.ally).hand, nativeOccupyPlayer(before, fixture.ally).hand);
|
||||
assert.notEqual(result.afterLosses.decision?.kind, 'ixSubstitution');
|
||||
sameEvent(result);
|
||||
conservedIx(game, result);
|
||||
});
|
||||
|
||||
void test('Native Karama cancellation with Ix lead uses Ix own typed army then returns to Ix cards without consuming Ecaz', () => {
|
||||
const result = nativeIxOccupyCase({ lead: 'ally', canceled: true });
|
||||
const { fixture, before, afterLosses, afterSubstitution, game } = result;
|
||||
const profile = viewGame(before, fixture.ally).battle?.ecazOccupy?.profile;
|
||||
assert.ok(profile?.canceled);
|
||||
assert.equal(profile.forceOwner, fixture.ally);
|
||||
assert.equal(profile.fixedEcazDial, 0);
|
||||
assert.equal(game.lastBattleContext?.winner, fixture.ally);
|
||||
assert.equal(forces(game, result, fixture.ecaz), 5);
|
||||
assert.equal(forces(game, result, fixture.ally), 4);
|
||||
assert.ok(afterLosses.decision?.kind === 'ixSubstitution');
|
||||
assert.equal(afterLosses.decision.player, fixture.ally);
|
||||
assert.equal(cyborgs(afterLosses, result), 0);
|
||||
assert.equal(cyborgs(afterSubstitution, result), 2);
|
||||
assert.ok(afterSubstitution.decision?.kind === 'battleCards');
|
||||
assert.equal(afterSubstitution.decision.player, fixture.ally);
|
||||
assert.equal(nativeOccupyPlayer(before, fixture.ally).spice - nativeOccupyPlayer(game, fixture.ally).spice, 1);
|
||||
assert.deepEqual(nativeOccupyPlayer(game, fixture.ecaz).hand, nativeOccupyPlayer(before, fixture.ecaz).hand);
|
||||
assert.equal(nativeOccupyPlayer(game, fixture.ecaz).tanks, nativeOccupyPlayer(before, fixture.ecaz).tanks);
|
||||
sameEvent(result);
|
||||
conservedIx(game, result);
|
||||
});
|
||||
|
||||
void test('All four native minimal policies can continue after Ecaz-led Ix substitution and selected-card cleanup', () => {
|
||||
const { actor, game } = nativeIxOccupyCase({ lead: 'ecaz' });
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(game, actor);
|
||||
const player = view.players.find(p => p.id === actor);
|
||||
assert.ok(player);
|
||||
player.bot = difficulty;
|
||||
const action = botActions(view).find(candidate => candidate.type === 'ready');
|
||||
assert.ok(action, `${difficulty} must be able to continue the actual post-Ix phase.`);
|
||||
let next = applyAction(game, actor, action);
|
||||
while (next.phaseOpening) {
|
||||
const pending = next.players.find(p => !next.phaseOpening!.passed.includes(p.id));
|
||||
assert.ok(pending);
|
||||
next = applyAction(next, pending.id, { type: 'ready' });
|
||||
}
|
||||
assert.equal(next.phase, 7, 'The policy and native opening acknowledgments reach actual Collection.');
|
||||
assert.equal(next.phaseOpening, null);
|
||||
}
|
||||
});
|
||||
|
||||
for (const relationship of ['coside', 'opposing'] as const)
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Native ${relationship} CHOAM with ${lead} lead receives only floor halves of actual other-payer support`, () => {
|
||||
const result = nativeChoamOccupyCase({ relationship, lead });
|
||||
const { fixture, actor, choam, before, revealed, game } = result;
|
||||
const plan = revealed.battle?.plans[actor];
|
||||
assert.ok(plan);
|
||||
assert.equal(plan.support, 3);
|
||||
assert.equal(plan.allyPayment ?? 0, 0);
|
||||
assert.equal(plan.dial, 6, 'Three mandatory free Ecaz strength is not three additional support payments.');
|
||||
assert.equal(game.lastBattleContext?.winner, actor);
|
||||
assert.equal(game.lastBattleContext?.result, 'normal');
|
||||
const actorIsChoam = actor === choam;
|
||||
const opponentIsChoam = fixture.opponent === choam;
|
||||
const income = actorIsChoam || opponentIsChoam ? 1 : 2;
|
||||
assert.equal(nativeOccupyPlayer(game, choam).spice - nativeOccupyPlayer(before, choam).spice,
|
||||
income - (actorIsChoam || opponentIsChoam ? 3 : 0),
|
||||
'Each actual other payer contributes floor(3/2); CHOAM own support goes wholly to the Bank.');
|
||||
if (!actorIsChoam)
|
||||
assert.equal(nativeOccupyPlayer(before, actor).spice - nativeOccupyPlayer(game, actor).spice, 3);
|
||||
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
||||
if (nonLead !== choam)
|
||||
assert.equal(nativeOccupyPlayer(before, nonLead).spice, nativeOccupyPlayer(game, nonLead).spice);
|
||||
assert.equal(forces(game, result, fixture.ecaz), 2);
|
||||
assert.equal(forces(game, result, fixture.ally), 5);
|
||||
assert.equal(forces(game, result, fixture.opponent), 0);
|
||||
assert.ok(game.phase >= 7);
|
||||
});
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Native coside CHOAM fixed-only ${lead} lead has no bank payment or minted battle income`, () => {
|
||||
const result = nativeChoamOccupyCase({ lead, support: 0, opponentSupport: 0 });
|
||||
const { fixture, actor, choam, before, game } = result;
|
||||
assert.equal(game.lastBattleContext?.winner, actor);
|
||||
assert.equal(nativeOccupyPlayer(game, choam).spice, nativeOccupyPlayer(before, choam).spice);
|
||||
assert.equal(nativeOccupyPlayer(game, fixture.ecaz).spice, nativeOccupyPlayer(before, fixture.ecaz).spice);
|
||||
assert.equal(game.pendingChoamBattleIncome ?? null, null);
|
||||
assert.equal(forces(game, result, fixture.ecaz), 2);
|
||||
assert.equal(forces(game, result, fixture.ally), 8);
|
||||
});
|
||||
|
||||
void test('Native CHOAM funding separates Ecaz own spice from actual prepaid donor spice and excludes donor share from income', () => {
|
||||
const result = nativeChoamOccupyCase({ lead: 'ecaz', donorPayment: 2 });
|
||||
const { fixture, actor, choam, beforeFunding, before, revealed, game } = result;
|
||||
assert.equal(before.aid[choam]?.amount, 2, 'The native CHOAM offer must actually escrow the donor share.');
|
||||
assert.equal(nativeOccupyPlayer(beforeFunding, choam).spice - nativeOccupyPlayer(before, choam).spice, 2);
|
||||
assert.equal(revealed.battle?.plans[actor]?.allyPayment, 2);
|
||||
assert.equal(nativeOccupyPlayer(before, actor).spice - nativeOccupyPlayer(game, actor).spice, 1);
|
||||
assert.equal(nativeOccupyPlayer(game, choam).spice - nativeOccupyPlayer(beforeFunding, choam).spice, -1,
|
||||
'Two prepaid donor spice yield no CHOAM share; only floor(3/2) enemy income returns.');
|
||||
assert.equal(game.lastBattleContext?.winner, fixture.ecaz);
|
||||
assert.equal(forces(game, result, fixture.ecaz), 2);
|
||||
assert.equal(forces(game, result, fixture.ally), 5);
|
||||
});
|
||||
|
||||
for (const relationship of ['coside', 'opposing'] as const)
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
void test(`Native ${relationship} CHOAM ${lead}-led traitor victory excludes all battle income and waives winner support`, () => {
|
||||
const result = nativeChoamOccupyCase({ relationship, lead, traitor: true });
|
||||
const { fixture, actor, choam, before, revealed, game } = result;
|
||||
const opposingLeader = nativeOccupyPlayer(before, fixture.opponent).leaders.find(leader =>
|
||||
leader.id === revealed.battle?.plans[fixture.opponent]?.leader);
|
||||
assert.ok(opposingLeader);
|
||||
const bounty = opposingLeader.strength;
|
||||
assert.equal(game.lastBattleContext?.result, 'traitor');
|
||||
assert.equal(game.lastBattleContext?.winner, actor);
|
||||
assert.equal(nativeOccupyPlayer(game, actor).spice - nativeOccupyPlayer(before, actor).spice, bounty,
|
||||
'The successful caller pays no winning support and retains the original leader bounty.');
|
||||
assert.equal(nativeOccupyPlayer(game, choam).spice - nativeOccupyPlayer(before, choam).spice,
|
||||
choam === fixture.opponent ? -3 : choam === actor ? bounty : 0,
|
||||
'No actual support payment generates CHOAM income during traitor combat; original winner bounty is separate.');
|
||||
assert.equal(game.pendingChoamBattleIncome ?? null, null);
|
||||
assert.equal(forces(game, result, fixture.ecaz), 5);
|
||||
assert.equal(forces(game, result, fixture.ally), 8);
|
||||
assert.equal(forces(game, result, fixture.opponent), 0);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game, type Player } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { ecazOccupyFixture, createEcazOccupySetup, chooseEcazOccupyLead,
|
||||
openEcazOccupyPlans, stageEcazOccupyCard, ecazOccupyTerritoryForces,
|
||||
type EcazOccupyFixture } from './fixture-ecaz-occupy';
|
||||
|
||||
const seat = (game: Game, id: string): Player => {
|
||||
const player = game.players.find(p => p.id === id); assert.ok(player); return player;
|
||||
};
|
||||
const army = (game: Game, fixture: EcazOccupyFixture, id: string) =>
|
||||
ecazOccupyTerritoryForces(game, id, fixture.territory);
|
||||
|
||||
function aftermath(state: Game): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 100; n++) {
|
||||
if (game.response) game = applyAction(game,
|
||||
game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
|
||||
else if (game.decision?.kind === 'battleLosses')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', choice: 0 });
|
||||
else if (game.pendingTreacheryDiscard)
|
||||
game = applyAction(game, game.players[0].id, { type: 'advanceBots' });
|
||||
else return game;
|
||||
}
|
||||
throw Error('Native Occupy aftermath did not finish.');
|
||||
}
|
||||
|
||||
function originalWin(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally') {
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const tleilaxu = fixture.game.players.find(p => p.faction === 'tleilaxu')!.id;
|
||||
const held = seat(fixture.game, tleilaxu).faceDancers!;
|
||||
const candidates = seat(fixture.game, actor).leaders.filter(l => !l.dead && !l.usedAt);
|
||||
const winner = candidates.find(l => held.some(c => !c.revealed && c.leader === l.id));
|
||||
assert.ok(winner, 'Use an actual first-draw Face Dancer and the selected lead\'s real leader.');
|
||||
const loser = seat(fixture.game, fixture.opponent).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength)[0];
|
||||
const weapon = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'projectile');
|
||||
const shield = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'shield');
|
||||
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
|
||||
const before = structuredClone(game);
|
||||
const fixed = viewGame(game, actor).battle!.ecazOccupy!.profile!.fixedEcazDial;
|
||||
game = applyAction(game, actor, { type: 'battlePlan', dial: fixed + 2,
|
||||
support: 2, leader: winner.id, weapon, defense: shield });
|
||||
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0,
|
||||
support: 0, leader: loser.id });
|
||||
for (let n = 0; n < 10 && game.battle; n++) {
|
||||
const view = viewGame(game, actor).battle!;
|
||||
const voter = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
|
||||
if (!voter) break;
|
||||
game = applyAction(game, voter, { type: 'traitorCall', call: false });
|
||||
}
|
||||
game = aftermath(game);
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
return { game, before, actor, tleilaxu, winner: winner.id, loser: loser.id,
|
||||
bounty: loser.strength, weapon, shield };
|
||||
}
|
||||
|
||||
function continueNativeBattlePhase(state: Game): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 30 && game.phase === 6; n++) {
|
||||
const actor = game.players.find(p => !game.ready.includes(p.id)); assert.ok(actor);
|
||||
const view = viewGame(game, actor.id);
|
||||
view.players.find(p => p.id === actor.id)!.bot = 'Easy';
|
||||
const action = botActions(view).find(candidate => candidate.type === 'ready'); assert.ok(action);
|
||||
game = applyAction(game, actor.id, action);
|
||||
}
|
||||
assert.equal(game.phase, 7, 'The original battle must continue naturally to Spice Collection.');
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const) {
|
||||
void test(`Tleilaxu winning Occupy coside cannot Face Dance its selected ${lead} leader`, () => {
|
||||
const fixture = ecazOccupyFixture({ allyFaction: 'tleilaxu',
|
||||
faceDancerFaction: lead === 'ecaz' ? 'ecaz' : 'tleilaxu' });
|
||||
const win = originalWin(fixture, lead);
|
||||
const voters = viewGame(win.before, win.actor).battle!.traitorVoters;
|
||||
assert.ok(voters.includes(fixture.ecaz) && voters.includes(fixture.ally),
|
||||
'Both actual same-faction participants receive their native traitor declaration.');
|
||||
let game = applyAction(win.game, win.actor, { type: 'decision', discard: [win.shield] });
|
||||
game = aftermath(game);
|
||||
assert.equal(game.battle, null);
|
||||
assert.equal(game.decision, null, 'No winning-coside Face Dance opportunity may be offered.');
|
||||
assert.equal(army(game, fixture, fixture.ecaz), 1);
|
||||
assert.equal(army(game, fixture, fixture.ally), 2);
|
||||
assert.equal(seat(game, win.actor).leaders.find(l => l.id === win.winner)!.dead, false);
|
||||
assert.ok(seat(game, win.tleilaxu).faceDancers!.some(c => c.leader === win.winner && !c.revealed));
|
||||
assert.equal(seat(game, win.actor).spice, seat(win.before, win.actor).spice - 2 + win.bounty);
|
||||
assert.ok(seat(game, win.actor).hand.some(c => c.id === win.weapon));
|
||||
assert.ok(game.discard.some(c => c.id === win.shield));
|
||||
continueNativeBattlePhase(game);
|
||||
});
|
||||
}
|
||||
|
||||
for (const lead of ['ecaz', 'ally'] as const)
|
||||
for (const origin of ['reserves', 'board'] as const)
|
||||
for (const count of [0, 1, 3]) {
|
||||
void test(`External Tleilaxu replaces both ${lead}-led Occupy armies: ${origin} ${count}/3`, () => {
|
||||
const options = { allyFaction: 'guild', opponentFaction: 'emperor',
|
||||
roster: ['ecaz', 'guild', 'emperor', 'tleilaxu'], expansions: ['ecaz', 'ix'],
|
||||
faceDancerFaction: lead === 'ecaz' ? 'ecaz' : 'guild' } as const;
|
||||
const initial = createEcazOccupySetup({ ...options, roster: [...options.roster], expansions: [...options.expansions] });
|
||||
const fixture = ecazOccupyFixture({ ...options, initial,
|
||||
roster: [...options.roster], expansions: [...options.expansions] });
|
||||
const tleilaxu = fixture.game.players.find(p => p.faction === 'tleilaxu')!.id;
|
||||
// Labelled conserved physical source placement outside the battle:
|
||||
// five original Tleilaxu reserve counters become Arrakeen board sources.
|
||||
const source = 'arrakeen:10';
|
||||
seat(fixture.game, tleilaxu).reserves -= 5;
|
||||
seat(fixture.game, tleilaxu).forces[source] = 5;
|
||||
const win = originalWin(fixture, lead);
|
||||
let game = applyAction(win.game, win.actor, { type: 'decision', discard: [win.shield] });
|
||||
game = aftermath(game);
|
||||
const decision = game.decision;
|
||||
assert.ok(decision?.kind === 'faceDance');
|
||||
assert.equal(decision.winner, win.actor);
|
||||
assert.equal(decision.leader, win.winner);
|
||||
assert.equal(army(game, fixture, fixture.ecaz), 1);
|
||||
assert.equal(army(game, fixture, fixture.ally), 2);
|
||||
assert.equal(seat(game, win.actor).spice, seat(win.before, win.actor).spice - 2 + win.bounty);
|
||||
const beforeDance = structuredClone(game);
|
||||
const nonLead = win.actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
|
||||
const nonLeadHand = structuredClone(seat(game, nonLead).hand);
|
||||
const sector = splitLocation(fixture.location).sector;
|
||||
assert.throws(() => applyAction(game, tleilaxu,
|
||||
{ type: 'decision', reveal: true, sources: { reserves: 4 }, sector }), /more forces/,
|
||||
'Replacement cannot exceed the whole surviving coalition.');
|
||||
const sources = origin === 'reserves' ? { reserves: count } : { [source]: count };
|
||||
game = applyAction(game, tleilaxu, { type: 'decision', reveal: true, sources, sector });
|
||||
game = aftermath(game);
|
||||
assert.equal(army(game, fixture, fixture.ecaz), 0);
|
||||
assert.equal(army(game, fixture, fixture.ally), 0);
|
||||
assert.equal(army(game, fixture, tleilaxu), count);
|
||||
assert.equal(seat(game, fixture.ecaz).reserves, seat(beforeDance, fixture.ecaz).reserves + 1);
|
||||
assert.equal(seat(game, fixture.ally).reserves, seat(beforeDance, fixture.ally).reserves + 2);
|
||||
assert.equal(seat(game, tleilaxu).reserves,
|
||||
seat(beforeDance, tleilaxu).reserves - (origin === 'reserves' ? count : 0));
|
||||
assert.equal(seat(game, tleilaxu).forces[source], 5 - (origin === 'board' ? count : 0));
|
||||
assert.equal(seat(game, win.actor).leaders.find(l => l.id === win.winner)!.dead, true);
|
||||
assert.equal(seat(game, fixture.opponent).leaders.find(l => l.id === win.loser)!.dead, true);
|
||||
assert.equal(seat(game, tleilaxu).spice, seat(beforeDance, tleilaxu).spice,
|
||||
'Face Dance leader death is not a second bounty.');
|
||||
assert.equal(seat(game, win.actor).spice, seat(beforeDance, win.actor).spice,
|
||||
'Original selected winner retains its bounty after replacement.');
|
||||
assert.ok(seat(game, win.actor).hand.some(c => c.id === win.weapon));
|
||||
assert.ok(game.discard.some(c => c.id === win.shield));
|
||||
assert.deepEqual(seat(game, nonLead).hand, nonLeadHand);
|
||||
assert.ok(seat(game, tleilaxu).faceDancers!.some(c => c.leader === win.winner && c.revealed));
|
||||
assert.equal(game.battle, null);
|
||||
assert.equal(game.decision, null);
|
||||
continueNativeBattlePhase(game);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game, type Player } from '../game/engine';
|
||||
import {
|
||||
ecazOccupyFixture, chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans,
|
||||
allowEcazOccupyResponses, stageEcazOccupyCard, type EcazOccupyFixture,
|
||||
} from './fixture-ecaz-occupy';
|
||||
|
||||
export type NativeOccupyLead = 'ecaz' | 'ally';
|
||||
export const nativeOccupyPlayer = (game: Game, id: string): Player => {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player, 'The original native roster must contain this player.');
|
||||
return player;
|
||||
};
|
||||
|
||||
function availableLeader(game: Game, id: string, strongest: boolean): string {
|
||||
const available = nativeOccupyPlayer(game, id).leaders.filter(l => !l.dead && !l.usedAt);
|
||||
available.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
|
||||
assert.ok(available[0], 'The native roster must supply an available physical leader.');
|
||||
return available[0].id;
|
||||
}
|
||||
|
||||
function prepare(fixture: EcazOccupyFixture, lead: NativeOccupyLead,
|
||||
canceled: boolean, funding = 0): Game {
|
||||
let game = canceled ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead);
|
||||
game = allowEcazOccupyResponses(game);
|
||||
if (game.decision?.kind === 'choamBattleFunding')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', amount: funding });
|
||||
else assert.equal(funding, 0, 'Donor spice requires the original native CHOAM funding offer.');
|
||||
return openEcazOccupyPlans(game);
|
||||
}
|
||||
|
||||
function reveal(state: Game, actor: string, caller?: string): Game {
|
||||
let game = state;
|
||||
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
|
||||
const battle = viewGame(game, actor).battle;
|
||||
assert.ok(battle);
|
||||
const voter = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
|
||||
if (!voter) break;
|
||||
game = applyAction(game, voter, { type: 'traitorCall', call: voter === caller });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Only consume real aftermath windows. Never install a decision, plan or phase. */
|
||||
export function finishNativeOccupyAftermath(state: Game, discardCard?: string): Game {
|
||||
let game = state;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
game = applyAction(game, game.players[0].id, { type: 'advanceBots' });
|
||||
} else if (game.response) {
|
||||
game = allowEcazOccupyResponses(game);
|
||||
} else if (game.decision?.kind === 'battleCards') {
|
||||
const discard = discardCard && game.decision.cards.includes(discardCard) ? [discardCard] : [];
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', discard });
|
||||
} else if (game.decision?.kind === 'choamMarket') {
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', done: true });
|
||||
} else {
|
||||
assert.equal(game.battle, null, 'Native plans and traitor voting must have resolved.');
|
||||
assert.equal(game.decision, null, 'Native typed losses/substitution must precede continuation.');
|
||||
assert.ok(game.phase >= 7, 'The actual coalition battle must reach native Collection.');
|
||||
return game;
|
||||
}
|
||||
}
|
||||
throw new Error('The native Occupy battle aftermath did not finish.');
|
||||
}
|
||||
|
||||
export type NativeIxOccupyOptions = {
|
||||
initial?: Game;
|
||||
lead?: NativeOccupyLead;
|
||||
canceled?: boolean;
|
||||
commitment?: 'funded' | 'half' | 'zero';
|
||||
};
|
||||
export type NativeIxOccupyCase = {
|
||||
fixture: EcazOccupyFixture;
|
||||
actor: string;
|
||||
shield: string;
|
||||
variableDial: number;
|
||||
support: number;
|
||||
before: Game;
|
||||
afterReveal: Game;
|
||||
afterLosses: Game;
|
||||
afterSubstitution: Game;
|
||||
game: Game;
|
||||
};
|
||||
|
||||
/** Original Ix setup, real Nexus and mixed shipment (six Suboids, two Cyborgs).
|
||||
* Only conserved original hand-card staging is added to the common fixture. */
|
||||
export function nativeIxOccupyCase(options: NativeIxOccupyOptions = {}): NativeIxOccupyCase {
|
||||
const lead = options.lead ?? 'ecaz';
|
||||
const canceled = options.canceled ?? false;
|
||||
const commitment = options.commitment ?? 'funded';
|
||||
const fixture = ecazOccupyFixture({ initial: options.initial, allyFaction: 'ixians',
|
||||
expansions: options.initial?.expansions ?? ['ecaz', 'ix'], ecazForces: 5, allyForces: 8, allyElite: 2 });
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const shield = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'shield');
|
||||
let game = prepare(fixture, lead, canceled);
|
||||
const before = structuredClone(game);
|
||||
const profile = viewGame(game, actor).battle?.ecazOccupy?.profile;
|
||||
assert.ok(profile);
|
||||
const ecazOwn = canceled && lead === 'ecaz';
|
||||
const variableDial = ecazOwn ? 2 : commitment === 'funded' ? 4 : commitment === 'half' ? 3 : 0;
|
||||
const support = ecazOwn ? 2 : commitment === 'funded' ? 1 : 0;
|
||||
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
|
||||
support, leader: availableLeader(game, actor, true), defense: shield });
|
||||
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: availableLeader(game, fixture.opponent, false) });
|
||||
game = reveal(game, actor);
|
||||
const afterReveal = structuredClone(game);
|
||||
const normal = ecazOwn ? 2 : commitment === 'funded' ? 2 : commitment === 'half' ? 4 : 0;
|
||||
const elite = ecazOwn || commitment === 'zero' ? 0 : commitment === 'funded' ? 2 : 1;
|
||||
if (game.decision?.kind === 'battleLosses') {
|
||||
const choice = game.decision.options.findIndex(loss => loss.normal === normal && loss.elite === elite);
|
||||
assert.ok(choice >= 0, 'The actual Ix dial must offer the requested native typed allocation.');
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', choice });
|
||||
}
|
||||
const afterLosses = structuredClone(game);
|
||||
if (elite > 0) {
|
||||
assert.ok(game.decision?.kind === 'ixSubstitution');
|
||||
assert.equal(game.decision.player, fixture.ally, 'Only the actual Ixian chooses surviving Suboids.');
|
||||
game = applyAction(game, fixture.ally, { type: 'decision',
|
||||
sources: { [fixture.location]: elite }, recover: { [fixture.location]: elite } });
|
||||
game = allowEcazOccupyResponses(game);
|
||||
} else assert.notEqual(game.decision?.kind, 'ixSubstitution');
|
||||
const afterSubstitution = structuredClone(game);
|
||||
game = finishNativeOccupyAftermath(game, shield);
|
||||
return { fixture, actor, shield, variableDial, support, before, afterReveal,
|
||||
afterLosses, afterSubstitution, game };
|
||||
}
|
||||
|
||||
export type NativeChoamOccupyOptions = {
|
||||
initial?: Game;
|
||||
lead?: NativeOccupyLead;
|
||||
relationship?: 'coside' | 'opposing';
|
||||
support?: number;
|
||||
opponentSupport?: number;
|
||||
donorPayment?: number;
|
||||
traitor?: boolean;
|
||||
};
|
||||
export type NativeChoamOccupyCase = {
|
||||
fixture: EcazOccupyFixture;
|
||||
actor: string;
|
||||
choam: string;
|
||||
support: number;
|
||||
opponentSupport: number;
|
||||
donorPayment: number;
|
||||
beforeFunding: Game;
|
||||
before: Game;
|
||||
revealed: Game;
|
||||
afterReveal: Game;
|
||||
game: Game;
|
||||
};
|
||||
|
||||
/** Labelled physical Traitor Card exchange after original setup. Preserve the
|
||||
* original deal and reserve; never synthesize a traitor identity or dead leader. */
|
||||
function holdPhysicalTraitor(game: Game, owner: string, identity: string): void {
|
||||
const player = nativeOccupyPlayer(game, owner);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
assert.ok(player.traitors[0], 'An original held Traitor Card must fund this physical exchange.');
|
||||
const reserve = game.traitorReserve;
|
||||
const index = reserve?.indexOf(identity) ?? -1;
|
||||
if (reserve && index >= 0) {
|
||||
reserve[index] = player.traitors[0];
|
||||
player.traitors[0] = identity;
|
||||
} else {
|
||||
const donor = game.players.find(p => p.traitors.includes(identity));
|
||||
assert.ok(donor, 'The original source must contain the requested physical Traitor Card.');
|
||||
const donorIndex = donor.traitors.indexOf(identity);
|
||||
donor.traitors[donorIndex] = player.traitors[0];
|
||||
player.traitors[0] = identity;
|
||||
}
|
||||
}
|
||||
|
||||
/** Native CHOAM original family setup and actual opposing/coside armies.
|
||||
* Any donor payment is offered and escrowed by the existing CHOAM decision. */
|
||||
export function nativeChoamOccupyCase(options: NativeChoamOccupyOptions = {}): NativeChoamOccupyCase {
|
||||
const lead = options.lead ?? 'ecaz';
|
||||
const relationship = options.relationship ?? 'coside';
|
||||
const support = options.support ?? 3;
|
||||
const opponentSupport = options.opponentSupport ?? 3;
|
||||
const donorPayment = options.donorPayment ?? 0;
|
||||
const fixture = ecazOccupyFixture({ initial: options.initial,
|
||||
allyFaction: relationship === 'coside' ? 'choam' : 'guild',
|
||||
opponentFaction: relationship === 'coside' ? 'guild' : 'choam',
|
||||
expansions: options.initial?.expansions ?? ['ecaz', 'choam'], ecazForces: 5, allyForces: 8 });
|
||||
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
||||
const choam = relationship === 'coside' ? fixture.ally : fixture.opponent;
|
||||
const beforeFunding = structuredClone(fixture.game);
|
||||
let game = prepare(fixture, lead, false, donorPayment);
|
||||
let opponentLeader = availableLeader(game, fixture.opponent, false);
|
||||
if (options.traitor) {
|
||||
const physical = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)];
|
||||
const leader = nativeOccupyPlayer(game, fixture.opponent).leaders.find(l =>
|
||||
!l.dead && !l.usedAt && physical.includes(l.id));
|
||||
assert.ok(leader, 'The actual opposing roster must supply a physical non-loyal Traitor Card.');
|
||||
opponentLeader = leader.id;
|
||||
holdPhysicalTraitor(game, actor, opponentLeader);
|
||||
}
|
||||
const before = structuredClone(game);
|
||||
const profile = viewGame(game, actor).battle?.ecazOccupy?.profile;
|
||||
assert.ok(profile);
|
||||
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + support,
|
||||
support, allyPayment: donorPayment, leader: availableLeader(game, actor, true) });
|
||||
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: opponentSupport,
|
||||
support: opponentSupport, leader: opponentLeader });
|
||||
const revealed = structuredClone(game);
|
||||
game = reveal(game, actor, options.traitor ? actor : undefined);
|
||||
const afterReveal = structuredClone(game);
|
||||
game = finishNativeOccupyAftermath(game);
|
||||
return { fixture, actor, choam, support, opponentSupport, donorPayment,
|
||||
beforeFunding, before, revealed, afterReveal, game };
|
||||
}
|
||||
+108
-26
@@ -3,20 +3,31 @@ import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, ne
|
||||
viewGame, type Action, type Game, type Player } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { TERRITORIES, location as locationKey, splitLocation } from '../game/board';
|
||||
import { createAdvancedSourceFixture, finishToMovement, advanceToNextStorm } from './fixture-advanced-source';
|
||||
import { finishToMovement, advanceToNextStorm } from './fixture-advanced-source';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { withoutEcazLoyalty } from '../game/ecaz-loyalty';
|
||||
|
||||
export type EcazOccupyFixtureFaction = Exclude<FactionId, 'richese'>;
|
||||
export type EcazOccupyFixtureOptions = {
|
||||
initial?: Game;
|
||||
ecazForces?: number;
|
||||
allyFaction?: 'fremen' | 'emperor' | 'beneGesserit' | 'guild';
|
||||
allyFaction?: EcazOccupyFixtureFaction;
|
||||
order?: 'ecaz-first' | 'ally-first' | 'opponent-first';
|
||||
opponentFaction?: 'guild' | 'emperor' | 'beneGesserit' | 'fremen';
|
||||
opponentFaction?: EcazOccupyFixtureFaction;
|
||||
expansions?: Game['expansions'];
|
||||
roster?: EcazOccupyFixtureFaction[];
|
||||
allyForces?: number;
|
||||
allyElite?: number;
|
||||
opponentForces?: number;
|
||||
opponentElite?: number;
|
||||
/** Seed only a still-pending original Face Dancer shuffle, never a later draw. */
|
||||
faceDancerFaction?: EcazOccupyFixtureFaction;
|
||||
advisorAlly?: boolean;
|
||||
};
|
||||
export type EcazOccupyFixture = {
|
||||
game: Game; ecaz: string; ally: string; opponent: string;
|
||||
game: Game; initial: Game; afterSetup: Game; ecaz: string; ally: string; opponent: string;
|
||||
territory: string; location: string; ecazForces: number;
|
||||
};
|
||||
|
||||
@@ -43,6 +54,54 @@ function step(game: Game): Game {
|
||||
throw new Error(`No native fixture action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
}
|
||||
|
||||
/** Complete existing native offers. Entropy is scoped to the final first-draw
|
||||
* action and forwards the Ix starting-card remainder shuffle unchanged. */
|
||||
export function finishEcazOccupySetup(state: Game, faceDancerFaction?: EcazOccupyFixtureFaction): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let attempt = 0; attempt < 200 && game.status === 'setup'; attempt++) {
|
||||
const tleilaxu = game.players.find(p => p.faction === 'tleilaxu');
|
||||
const pending = viewGame(game, game.players[0].id).setupPending;
|
||||
const finalForces = game.setupStage === 'forces' && !game.players.some(p => p.faction === 'ixians') &&
|
||||
pending.length === 1 && (game.players.find(p => p.id === pending[0])!.faction === 'beneGesserit' ||
|
||||
game.players.find(p => p.id === pending[0])!.faction === 'ecaz' &&
|
||||
!game.players.some(p => p.faction === 'beneGesserit'));
|
||||
if (!faceDancerFaction || !tleilaxu || tleilaxu.faceDancers ||
|
||||
!(game.decision?.kind === 'ixSetup' || finalForces)) {
|
||||
game = step(game);
|
||||
continue;
|
||||
}
|
||||
const held = new Set(game.players.flatMap(p => p.traitors));
|
||||
const source = withoutEcazLoyalty(traitorDeck(game.players, game.expansions.includes('ix')),
|
||||
game.ecazLoyalty).filter(id => !held.has(id));
|
||||
const owner = game.players.find(p => p.faction === faceDancerFaction);
|
||||
assert.ok(owner);
|
||||
const target = owner.leaders.filter(l => source.includes(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(target, 'A real leader of the selected faction remains in the first native Face Dancer source.');
|
||||
const targetIndex = source.indexOf(target.id);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const originalRandom = crypto.getRandomValues.bind(crypto);
|
||||
let prefixCalls = game.decision?.kind === 'ixSetup' ? Math.max(0, game.ixSetupCards!.length - 2) : 0;
|
||||
let index = source.length - 1;
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array instanceof Uint32Array && array.length === 1);
|
||||
if (prefixCalls > 0) { prefixCalls--; originalRandom(array); return array; }
|
||||
if (index <= 0) { originalRandom(array); return array; }
|
||||
array[0] = index === targetIndex ? 0 : 0xffffffff;
|
||||
index--;
|
||||
return array;
|
||||
};
|
||||
try { game = step(game); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
assert.equal(game.status, 'playing', 'Seed only an actually pending first Face Dancer draw.');
|
||||
assert.ok(game.players.find(p => p.id === tleilaxu.id)!.faceDancers!.some(c => c.leader === target.id));
|
||||
}
|
||||
assert.equal(game.status, 'playing', 'Original native setup must finish without a redeal.');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function settleEcazOccupyArrival(state: Game, accompany = false): Game {
|
||||
let game = state;
|
||||
for (let attempt = 0; attempt < 50 && (game.response || game.decision); attempt++) {
|
||||
@@ -106,40 +165,46 @@ export function stageEcazOccupyCard(game: Game, owner: string,
|
||||
/** Genuine fresh E3 faction setup, a natural worm Nexus, reciprocal alliance,
|
||||
* accepted shared shipment and actual chooseBattle. Returns BEFORE lead choice.
|
||||
* A supplied fresh setup retains its original deal and printed seat positions. */
|
||||
export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazOccupyFixture {
|
||||
export function createEcazOccupySetup(options: EcazOccupyFixtureOptions = {}): Game {
|
||||
const allyFaction = options.allyFaction ?? 'fremen';
|
||||
const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild');
|
||||
assert.notEqual(allyFaction, opponentFaction);
|
||||
const ecazForces = options.ecazForces ?? 3;
|
||||
assert.ok(Number.isInteger(ecazForces) && ecazForces >= 1 && ecazForces <= 5);
|
||||
let setup: Game;
|
||||
if (options.initial) {
|
||||
setup = structuredClone(options.initial);
|
||||
assert.equal(setup.status, 'setup', 'Captured input must be its original admitted fresh setup.');
|
||||
assert.equal(setup.ecazOccupyPreview, true);
|
||||
assert.equal(setup.advanced, true);
|
||||
assert.deepEqual(setup.expansions, ['ecaz']);
|
||||
if (options.expansions) assert.deepEqual(setup.expansions, options.expansions);
|
||||
assert.ok(setup.expansions.includes('ecaz') && new Set(setup.expansions).size === setup.expansions.length);
|
||||
assert.equal(setup.turn, 1, 'Captured Occupy input is never a started-game retrofit.');
|
||||
assert.ok(!setup.ecazTreachery && !setup.homeworlds && !setup.nexusCards &&
|
||||
!setup.leaderSkills && !setup.strongholdCards && !setup.techTokens &&
|
||||
!setup.discoveryEnabled && !setup.discoveries && !setup.discoveryStash &&
|
||||
!setup.greatMaker && !setup.moritaniAssassinatePreview,
|
||||
'Captured Occupy inputs admit the explicit ordinary Advanced profile only.');
|
||||
assert.deepEqual([...setup.deck, ...setup.discard, ...setup.players.flatMap(p => p.hand)]
|
||||
.map(card => card.id).sort(), baseDeck().map(card => card.id).sort(),
|
||||
'The original captured deal must retain the ordinary 33-card source.');
|
||||
assert.deepEqual([...setup.deck, ...setup.discard, ...(setup.ixSetupCards ?? []),
|
||||
...setup.players.flatMap(p => p.hand)].map(card => card.id).sort(),
|
||||
treacheryDeck(setup.expansions).map(card => card.id).sort(),
|
||||
'The original captured deal must retain every selected physical card exactly once.');
|
||||
} else {
|
||||
const roster: FactionId[] = ['ecaz', allyFaction, opponentFaction];
|
||||
setup = createGame('ECAZOCCUPY', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
for (const faction of roster.slice(1)) joinGame(setup, newPlayer(faction, faction, faction));
|
||||
const desired = options.order === 'ally-first' ? [allyFaction, opponentFaction, 'ecaz'] :
|
||||
const roster: EcazOccupyFixtureFaction[] = options.roster ?? ['ecaz', allyFaction, opponentFaction];
|
||||
const expansions = options.expansions ?? ['ecaz',
|
||||
...(roster.some(f => f === 'ixians' || f === 'tleilaxu') ? ['ix'] as const : []),
|
||||
...(roster.includes('choam') ? ['choam'] as const : [])];
|
||||
const desired: EcazOccupyFixtureFaction[] = options.order === 'ally-first' ? [allyFaction, opponentFaction, 'ecaz'] :
|
||||
options.order === 'opponent-first' || opponentFaction === 'beneGesserit' ?
|
||||
[opponentFaction, 'ecaz', allyFaction] : ['ecaz', allyFaction, opponentFaction];
|
||||
// Circles 3..5 remain in this order for every genuine second-turn Storm
|
||||
// distance (1..6); the occupied initial circles are vacated via real actions.
|
||||
desired.push(...roster.filter(f => !desired.includes(f)));
|
||||
const joinOrder = roster.length > 4 ? desired : roster;
|
||||
setup = createGame('ECAZOCCUPY', newPlayer(joinOrder[0], joinOrder[0], joinOrder[0]), true, expansions);
|
||||
for (const faction of joinOrder.slice(1)) joinGame(setup, newPlayer(faction, faction, faction));
|
||||
const firstCircle = Math.min(3, 7 - desired.length);
|
||||
// Printed circles preserve the source order; all initial forces are
|
||||
// subsequently returned through their actual physical reserves.
|
||||
for (let i = desired.length - 1; i >= 0; i--)
|
||||
if (viewGame(setup, desired[i]).playerPositions[desired[i]] !== i + 3)
|
||||
setup = applyAction(setup, desired[i], { type: 'seatPosition', position: i + 3 });
|
||||
if (viewGame(setup, desired[i]).playerPositions[desired[i]] !== i + firstCircle)
|
||||
setup = applyAction(setup, desired[i], { type: 'seatPosition', position: i + firstCircle });
|
||||
for (const player of setup.players) setup = applyAction(setup, player.id, { type: 'ready' });
|
||||
setup = initializeEcazOccupyGameForAudit(setup);
|
||||
}
|
||||
@@ -147,16 +212,27 @@ export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazO
|
||||
setup.players.some(p => p.faction === allyFaction) &&
|
||||
setup.players.some(p => p.faction === opponentFaction),
|
||||
'The original source roster must contain the three actual factions for this rule case.');
|
||||
return setup;
|
||||
}
|
||||
|
||||
export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazOccupyFixture {
|
||||
const allyFaction = options.allyFaction ?? 'fremen';
|
||||
const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild');
|
||||
const ecazForces = options.ecazForces ?? 3;
|
||||
assert.ok(Number.isInteger(ecazForces) && ecazForces >= 1 && ecazForces <= 5);
|
||||
const setup = createEcazOccupySetup(options);
|
||||
const initial = structuredClone(setup);
|
||||
const ecaz = setup.players.find(p => p.faction === 'ecaz')!.id;
|
||||
const ally = setup.players.find(p => p.faction === allyFaction)!.id;
|
||||
const opponent = setup.players.find(p => p.faction === opponentFaction)!.id;
|
||||
let game = createAdvancedSourceFixture({ initial: setup }).game;
|
||||
assert.equal(game.status, 'playing');
|
||||
let game = finishEcazOccupySetup(setup, options.faceDancerFaction);
|
||||
const afterSetup = structuredClone(game);
|
||||
returnBoardToReserves(game);
|
||||
orderBlow(game, false, 0);
|
||||
orderBlow(game, false, 1);
|
||||
game = finishToMovement(game);
|
||||
while (game.phase === 5) game = applyAction(game, game.active!, { type: 'endMovement' });
|
||||
while (game.phase === 5) game = game.response || game.phaseOpening || game.decision
|
||||
? step(game) : applyAction(game, game.active!, { type: 'endMovement' });
|
||||
game = advanceToNextStorm(game);
|
||||
orderBlow(game, true, 0);
|
||||
orderBlow(game, false, 1);
|
||||
@@ -180,14 +256,20 @@ export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazO
|
||||
const location = locationKey(territory, sector);
|
||||
stageEcazOccupyCard(game, opponent, card => card.effect === 'karama');
|
||||
while (game.phase === 5) {
|
||||
if (game.response || game.phaseOpening || game.decision) {
|
||||
game = step(game);
|
||||
continue;
|
||||
}
|
||||
const actor = game.active!;
|
||||
if (![ecaz, ally, opponent].includes(actor)) {
|
||||
game = applyAction(game, actor, { type: 'endMovement' });
|
||||
continue;
|
||||
}
|
||||
const player = game.players.find(p => p.id === actor)!;
|
||||
const amount = actor === ecaz ? ecazForces : actor === ally ? 4 : 8;
|
||||
const elite = actor === ally && player.elites ? 2 : 0;
|
||||
const amount = actor === ecaz ? ecazForces :
|
||||
actor === ally ? options.allyForces ?? 4 : options.opponentForces ?? 8;
|
||||
const elite = actor === ally ? options.allyElite ?? (player.elites ? 2 : 0) :
|
||||
actor === opponent ? options.opponentElite ?? 0 : 0;
|
||||
const action: Action = { type: 'ship', territory, sector, amount, ...(elite ? { elite } : {}) };
|
||||
game = settleEcazOccupyArrival(applyAction(game, actor, action), !!options.advisorAlly && actor === ecaz);
|
||||
if (actor === ally && options.advisorAlly) {
|
||||
@@ -205,7 +287,7 @@ export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazO
|
||||
game = applyAction(game, chooser, { type: 'chooseBattle', territory,
|
||||
target: choice.attacker === chooser ? choice.defender : choice.attacker });
|
||||
if (!options.advisorAlly) assert.equal(game.decision?.kind, 'ecazBattleLead');
|
||||
return { game, ecaz, ally, opponent, territory, location, ecazForces };
|
||||
return { game, initial, afterSetup, ecaz, ally, opponent, territory, location, ecazForces };
|
||||
}
|
||||
|
||||
/** Resolve only existing native counter windows, never create one. */
|
||||
|
||||
Reference in new issue
Block a user