Integrate native Ixian CHOAM and Tleilaxu Occupy aftermath

This commit is contained in:
Quintine committed 2026-10-02 14:47:54 +10:00
1 parent 3333432be8
commit 1057eb2719
17 files changed
+934 -89

No files matched your search

+237
View File
@@ -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);
});
+154
View File
@@ -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);
});
}
+211
View File
@@ -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
View File
@@ -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. */