Compose source-clear Basic even-force Ecaz Occupy

This commit is contained in:
Quintine committed 2026-10-03 20:34:22 +10:00
1 parent 63e57bd894
commit 5f082696a6
18 files changed
+861 -52

No files matched your search

+306
View File
@@ -0,0 +1,306 @@
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 { TERRITORIES, location as locationKey } from '../game/board';
import { ecazOccupyDialChoices, ecazOccupyPlanControl } from '../game/ecaz-occupy-options';
import { advanceToNextStorm, finishToMovement } from './fixture-advanced-source';
import { chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans,
ecazOccupyTerritoryForces, stageEcazOccupyCard, allowEcazOccupyResponses,
type EcazOccupyFixture } from './fixture-ecaz-occupy';
import { basicEcazOccupyFixture, basicEcazOccupyBattleAction } from './fixture-basic-ecaz-occupy';
const seat = (game: Game, id: string): Player => game.players.find(p => p.id === id)!;
const forces = (game: Game, fixture: EcazOccupyFixture, id: string) =>
ecazOccupyTerritoryForces(game, id, fixture.territory);
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function leader(game: Game, id: string, strongest: boolean): string {
return seat(game, id).leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0].id;
}
function reject(game: Game, actor: string, action: Parameters<typeof applyAction>[2]): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'A rejected native action cannot commit any part of the battle.');
}
/** Exchange an existing physical Traitor Card, preserving the native deck. */
function holdTraitor(game: Game, owner: string, identity: string): void {
const receiver = seat(game, owner);
if (receiver.traitors.includes(identity)) return;
const reserve = game.traitorReserve ?? [];
const index = reserve.indexOf(identity);
if (index >= 0) {
reserve[index] = receiver.traitors[0];
receiver.traitors[0] = identity;
} else {
const donor = game.players.find(p => p.traitors.includes(identity));
assert.ok(donor, 'The chosen leader must have a real physical Traitor Card.');
const donorIndex = donor.traitors.indexOf(identity);
donor.traitors[donorIndex] = receiver.traitors[0];
receiver.traitors[0] = identity;
}
}
function aftermath(state: Game): Game {
let game = state;
for (let attempt = 0; attempt < 100; attempt++) {
if (game.response) game = allowEcazOccupyResponses(game);
else if (game.decision?.kind === 'battleLosses')
game = applyAction(game, game.decision.player, { type: 'decision', choice: 0 });
else if (game.decision?.kind === 'battleCards')
game = applyAction(game, game.decision.player, { type: 'decision', discard: [] });
else if (game.pendingTreacheryDiscard)
game = applyAction(game, game.players[0].id, { type: 'advanceBots' });
else return game;
}
throw new Error('The original Basic battle aftermath did not finish.');
}
function resolve(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally', options: {
outcome?: 'win' | 'loss' | 'traitor' | 'mutualTraitors' | 'explosion'; canceled?: boolean;
} = {}): { game: Game; before: Game; actor: string } {
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const outcome = options.outcome ?? 'win';
let game = options.canceled ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead);
game = openEcazOccupyPlans(game);
const before = structuredClone(game);
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
const voter = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
let ownLeader = leader(game, actor, outcome !== 'loss');
const opponentLeader = leader(game, fixture.opponent, outcome === 'loss');
if (outcome === 'traitor' || outcome === 'mutualTraitors') {
holdTraitor(game, voter, opponentLeader);
if (outcome === 'mutualTraitors') {
const physical = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)];
ownLeader = seat(game, actor).leaders.find(l => !l.dead && !l.usedAt && physical.includes(l.id))!.id;
holdTraitor(game, fixture.opponent, ownLeader);
}
}
const weapon = outcome === 'explosion' ? stageEcazOccupyCard(game, actor, c => c.kind === 'lasgun') : undefined;
const defense = outcome === 'explosion' ? stageEcazOccupyCard(game, fixture.opponent, c => c.kind === 'shield') : undefined;
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + (outcome === 'loss' ? 0 : 2),
support: 0, leader: ownLeader, ...(weapon ? { weapon } : {}) });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: outcome === 'loss' ? 8 : 0,
support: 0, leader: opponentLeader, ...(defense ? { defense } : {}) });
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
const battle = viewGame(game, actor).battle!;
const next = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
if (!next) break;
const call = next === voter && ['traitor', 'mutualTraitors'].includes(outcome) ||
next === fixture.opponent && outcome === 'mutualTraitors';
game = applyAction(game, next, { type: 'traitorCall', call });
}
game = aftermath(game);
assert.equal(game.battle, null, 'One original combined battle must retire, not duplicate a second fight.');
return { game, before, actor };
}
for (const ecazForces of [2, 4]) for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic E${ecazForces} ${lead} lead: free full-strength commitment and equal Ecaz loss/survival`, () => {
const fixture = basicEcazOccupyFixture({ ecazForces });
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const pending = reload(fixture.game); // Only an actual pending lead decision is resumed.
const prepared = openEcazOccupyPlans(chooseEcazOccupyLead({ ...fixture, game: pending }, lead));
const view = viewGame(prepared, actor);
const profile = view.battle!.ecazOccupy!.profile!;
assert.equal(profile.fixedEcazDial, ecazForces / 2);
assert.equal(profile.forceOwner, fixture.ally);
assert.equal(profile.maxDial, ecazForces / 2 + 4);
assert.equal(profile.maxSupport, 0);
assert.equal(ecazOccupyPlanControl(view, ecazForces / 2 + 2, 0)!.blocked, null);
assert.notEqual(ecazOccupyPlanControl(view, ecazForces / 2 + 2, 1)!.blocked, null);
const { game, before } = resolve(fixture, lead);
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(forces(game, fixture, fixture.ecaz), ecazForces / 2);
assert.equal(seat(game, fixture.ecaz).tanks - seat(before, fixture.ecaz).tanks, ecazForces / 2);
assert.equal(forces(game, fixture, fixture.ally), 2);
assert.equal(seat(game, fixture.ally).tanks - seat(before, fixture.ally).tanks, 2);
assert.equal(forces(game, fixture, fixture.opponent), 0);
for (const id of [fixture.ecaz, fixture.ally]) assert.equal(seat(game, id).spice, seat(before, id).spice);
assert.equal(game.phase, 7, 'Surviving allies coexist into original Collection without resettlement.');
});
for (const allyFaction of ['emperor', 'fremen'] as const) for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic ${allyFaction} ${lead} lead uses native ordinary inventory without Advanced elite strength`, () => {
const fixture = basicEcazOccupyFixture({ allyFaction, ecazForces: 4 });
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const prepared = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
const profile = viewGame(prepared, actor).battle!.ecazOccupy!.profile!;
assert.equal(profile.maxDial, 6, 'Four real Basic ordinary forces provide four variable strength.');
const { game, before } = resolve(fixture, lead);
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ally), 2);
assert.equal(seat(game, actor).spice, seat(before, actor).spice);
});
for (const outcome of ['loss', 'traitor', 'mutualTraitors', 'explosion'] as const)
for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic ${outcome} with ${lead} lead settles both physical armies through original handlers`, () => {
const fixture = basicEcazOccupyFixture({ ecazForces: 4 });
const { game, before, actor } = resolve(fixture, lead, { outcome });
assert.equal(game.lastBattleContext!.result, outcome === 'loss' ? 'normal' : outcome);
assert.equal(game.lastBattleContext!.winner, outcome === 'traitor' ? actor : outcome === 'loss' ? fixture.opponent : null);
for (const [id, count] of [[fixture.ecaz, 4], [fixture.ally, 4]] as const) {
assert.equal(forces(game, fixture, id), outcome === 'traitor' ? count : 0);
assert.equal(seat(game, id).tanks - seat(before, id).tanks, outcome === 'traitor' ? 0 : count);
const bounty = outcome === 'traitor' && id === actor
? seat(before, fixture.opponent).leaders.find(l => l.id === leader(before, fixture.opponent, false))!.strength : 0;
assert.equal(seat(game, id).spice - seat(before, id).spice, bounty,
'Basic support is free while the original defeated-leader bounty still belongs to the lead.');
}
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic printed Karama after ${lead} lead permits only that lead's own free ordinary dial`, () => {
const fixture = basicEcazOccupyFixture({ ecazForces: 4 });
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const canceled = cancelEcazOccupy(fixture, lead);
const prepared = openEcazOccupyPlans(canceled);
const profile = viewGame(prepared, actor).battle!.ecazOccupy!.profile!;
assert.equal(profile.canceled, true);
assert.equal(profile.fixedEcazDial, 0);
assert.equal(profile.forceOwner, actor);
assert.equal(profile.maxDial, 4);
const { game, before } = resolve(fixture, lead, { canceled: true });
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(forces(game, fixture, fixture.ecaz), lead === 'ecaz' ? 2 : 4);
assert.equal(forces(game, fixture, fixture.ally), lead === 'ally' ? 2 : 4);
assert.equal(seat(game, actor).spice, seat(before, actor).spice);
assert.equal(seat(game, fixture.ecaz).ally, fixture.ally);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic ${lead} lead owns printed plan cards and its original aftermath discard`, () => {
const fixture = basicEcazOccupyFixture();
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
const shield = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'shield');
let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead));
const nonLeadHand = structuredClone(seat(game, nonLead).hand);
reject(game, nonLead, { type: 'battlePlan', dial: 4, support: 0,
leader: leader(game, nonLead, true), defense: shield });
game = applyAction(game, actor, { type: 'battlePlan', dial: 4, support: 0,
leader: leader(game, actor, true), defense: shield });
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0,
leader: leader(game, fixture.opponent, false) });
for (const voter of viewGame(game, actor).battle!.traitorVoters)
game = applyAction(game, voter, { type: 'traitorCall', call: false });
assert.equal(game.decision?.kind, 'battleCards');
assert.equal(game.decision!.player, actor);
const pending = reload(game);
game = applyAction(pending, actor, { type: 'decision', discard: [shield] });
game = aftermath(game);
assert.ok(game.discard.some(card => card.id === shield));
assert.ok(!seat(game, actor).hand.some(card => card.id === shield));
assert.deepEqual(seat(game, nonLead).hand, nonLeadHand);
assert.equal(game.phase, 7);
});
void test('Basic odd uncanceled native choice rejects before creating a lead frame or committing forces', () => {
const fixture = basicEcazOccupyFixture({ ecazForces: 3, chooseBattle: false });
const { actor, action } = basicEcazOccupyBattleAction(fixture);
reject(fixture.game, actor, action);
assert.equal(fixture.game.battle, null);
assert.equal(fixture.game.decision, null);
assert.equal(forces(fixture.game, fixture, fixture.ecaz), 3);
assert.equal(forces(fixture.game, fixture, fixture.ally), 4);
});
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const)
for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic ${difficulty} policy selects ${lead} and seals a legal actual-owner plan`, () => {
const fixture = basicEcazOccupyFixture();
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const leadView = viewGame(fixture.game, fixture.ecaz);
leadView.players.find(p => p.id === fixture.ecaz)!.bot = difficulty;
const choose = botActions(leadView).find(a => a.type === 'decision' && a.lead === actor);
assert.ok(choose, 'Every policy must retain both legal native lead choices.');
let game = openEcazOccupyPlans(applyAction(fixture.game, fixture.ecaz, choose));
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const plan = botActions(view).find(a => a.type === 'battlePlan');
assert.ok(plan, 'The actual lead must receive a sealable native candidate.');
assert.ok(ecazOccupyDialChoices(view).some(c => c.dial === plan.dial && c.support === (plan.support ?? 0)));
assert.equal(ecazOccupyPlanControl(view, Number(plan.dial), Number(plan.support ?? 0))!.blocked, null);
game = applyAction(game, actor, plan);
assert.ok(game.battle!.plans[actor], 'The selected lead, not the force owner, owns the sealed plan.');
const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz;
assert.equal(game.battle!.plans[nonLead], undefined);
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0,
leader: leader(game, fixture.opponent, false) });
for (const voter of viewGame(game, actor).battle!.traitorVoters)
game = applyAction(game, voter, { type: 'traitorCall', call: false });
game = aftermath(game);
assert.equal(game.battle, null);
assert.equal(game.phase, 7, 'The policy-generated real battle must continue into native Collection.');
});
void test('Basic city survivors get no city income; shared Collection resumes once at its actual decision', () => {
const city = basicEcazOccupyFixture({ territory: 'arrakeen' });
const cityResult = resolve(city, 'ecaz');
assert.equal(cityResult.game.phase, 7);
for (const id of [city.ecaz, city.ally])
assert.equal(seat(cityResult.game, id).spice, seat(cityResult.before, id).spice,
'Basic Arrakeen grants no Advanced two-spice city income.');
assert.equal(forces(cityResult.game, city, city.ecaz), 2);
assert.equal(forces(cityResult.game, city, city.ally), 2);
const desert = basicEcazOccupyFixture();
// Controlled bank-funded physical spice after native setup; not a collection receipt.
desert.game.spice[desert.location] = 5;
const resolution = resolve(desert, 'ally');
const before = resolution.before;
let game = resolution.game;
assert.equal(game.decision?.kind, 'ecazSpice');
assert.equal(game.spice[desert.location], 0);
const pending = reload(game); // Resume only the actual allocation decision.
game = applyAction(pending, pending.decision!.player,
{ type: 'decision', event: pending.ecazCollection!.event, allocation: { kind: 'equal' } });
assert.equal(game.decision, null);
assert.equal(game.ecazCollection!.stage, 'complete');
assert.equal(seat(game, desert.ecaz).spice - seat(before, desert.ecaz).spice, 2);
assert.equal(seat(game, desert.ally).spice - seat(before, desert.ally).spice, 3);
assert.equal(game.ecazCollection!.settled.length, 1);
reject(game, pending.decision!.player,
{ type: 'decision', event: pending.ecazCollection!.event, allocation: { kind: 'equal' } });
});
void test('Basic reciprocal coalition coexists in three shared strongholds and wins through original Mentat', () => {
const fixture = basicEcazOccupyFixture();
let game = resolve(fixture, 'ecaz').game;
game = finishToMovement(advanceToNextStorm(game));
// Conserved controlled board after the next genuine Storm; no alliance,
// victory decision, phase or reserve identity is manufactured.
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
}
const cities = TERRITORIES.filter(t => t.type === 'stronghold' &&
t.sectors.some(sector => sector !== game.storm)).slice(0, 3).map(t => t.id);
assert.equal(cities.length, 3, 'Three original strongholds remain outside the actual Storm.');
for (const city of cities) {
const sector = TERRITORIES.find(t => t.id === city)!.sectors.find(s => s !== game.storm)!;
assert.ok(sector !== undefined);
for (const id of [fixture.ecaz, fixture.ally]) {
seat(game, id).reserves--;
seat(game, id).forces[locationKey(city, sector)] = 1;
}
}
const progress = viewGame(game, fixture.ecaz).victoryProgress.find(p => p.player === fixture.ecaz)!;
assert.deepEqual([...progress.jointlyOccupied].sort(), [...cities].sort());
assert.equal(progress.qualifies, true);
while (game.phase === 5) {
game = allowEcazOccupyResponses(game);
game = applyAction(game, game.active!, { type: 'endMovement' });
}
assert.equal(game.phase, 7, 'Shared allied cities must not produce spurious coalition battles.');
for (let attempt = 0; attempt < 50 && game.status === 'playing'; attempt++) {
game = allowEcazOccupyResponses(game);
const actor = game.players.find(p => !game.ready.includes(p.id))!.id;
game = applyAction(game, actor, { type: 'ready' });
}
assert.equal(game.status, 'finished');
assert.deepEqual([...game.winner].sort(), [fixture.ecaz, fixture.ally].sort());
});
+170 -17
View File
@@ -10,30 +10,183 @@ import {
type EcazOccupyBattleInput,
} from '../game/ecaz-occupy-battle';
const ordinary = (normal: number): CombatForces => ({
normal, elite: 0, eliteStrength: 2, freeSupport: false,
const ordinary = (normal: number, advanced: boolean): CombatForces => ({
normal, elite: 0, eliteStrength: advanced ? 2 : 1, freeSupport: !advanced,
});
function battle(
advanced: boolean,
ecazCount: number,
lead = 'ecaz-seat',
canceled = false,
allyForces = ordinary(4),
allyForces = ordinary(4, advanced),
): EcazOccupyBattleInput {
return {
advanced,
battleOrderActor: 'enemy-seat',
ecaz: { id: 'ecaz-seat', faction: 'ecaz', ally: 'ally-seat', forces: ordinary(ecazCount) },
ecaz: { id: 'ecaz-seat', faction: 'ecaz', ally: 'ally-seat', forces: ordinary(ecazCount, advanced) },
ally: { id: 'ally-seat', faction: 'atreides', ally: 'ecaz-seat', forces: allyForces },
lead,
canceled,
};
}
void test('Basic E2/E4 normal wins split Ecaz equally and settle the real ally pool with either lead', () => {
for (const ecazCount of [2, 4]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
const profile = quoteEcazOccupyBattle(battle(false, ecazCount, lead));
const fixed = ecazCount / 2;
for (const variableDial of [0, 2]) {
const dial = quoteEcazOccupyDial(profile, fixed + variableDial, 0);
assert.equal(dial.payer, lead);
assert.equal(dial.forceOwner, 'ally-seat');
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: fixed + variableDial, support: 0,
});
assert.deepEqual(win.fixedLosses, [{ owner: 'ecaz-seat', normal: fixed, elite: 0 }]);
assert.equal(ecazCount - win.fixedLosses[0].normal, fixed);
assert.equal(win.casualties?.owner, 'ally-seat');
assert.deepEqual(win.casualties?.options, [
{ normal: variableDial, elite: 0, paidNormal: 0, paidElite: 0 },
]);
assert.equal(4 - win.casualties!.options[0].normal, 4 - variableDial);
assert.deepEqual(win.destroyedArmies, []);
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
result: 'normal', won: false, dial: fixed + variableDial, support: 0,
}), {
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
});
}
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
{ dial: fixed, support: 0, variableDial: 0 },
{ dial: fixed + 1, support: 0, variableDial: 1 },
{ dial: fixed + 2, support: 0, variableDial: 2 },
{ dial: fixed + 3, support: 0, variableDial: 3 },
{ dial: fixed + 4, support: 0, variableDial: 4 },
]);
assert.throws(() => quoteEcazOccupyDial(profile, fixed - 1, 0), EcazOccupyBattleError);
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 0.5, 0), EcazOccupyBattleError);
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 1, 1), EcazOccupyBattleError);
}
}
});
void test('Basic Fedaykin and Sardaukar stay typed, full strength one, free, and physically allied under either lead', () => {
const forces: CombatForces = { normal: 2, elite: 2, eliteStrength: 1, freeSupport: true };
for (const faction of ['fremen', 'emperor'] as const) {
for (const ecazCount of [2, 4]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
const input = battle(false, ecazCount, lead, false, forces);
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction } });
const fixed = ecazCount / 2;
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: fixed + 2, support: 0,
});
assert.deepEqual(win.fixedLosses, [{ owner: 'ecaz-seat', normal: fixed, elite: 0 }]);
assert.equal(win.casualties?.owner, 'ally-seat');
assert.deepEqual(win.casualties?.options, [
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
{ normal: 1, elite: 1, paidNormal: 0, paidElite: 0 },
{ normal: 0, elite: 2, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(quoteEcazOccupyDial(profile, fixed + 4, 0).options, [
{ normal: 2, elite: 2, paidNormal: 0, paidElite: 0 },
]);
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 5, 0), EcazOccupyBattleError);
assert.throws(() => quoteEcazOccupyDial(profile, fixed + 2, 1), EcazOccupyBattleError);
}
}
}
});
void test('Basic cancellation uses the selected own pool even for odd Ecaz and preserves the other army on a normal win', () => {
const forces: CombatForces = { normal: 0, elite: 2, eliteStrength: 1, freeSupport: true };
for (const ecazCount of [2, 4, 5]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
const input = battle(false, ecazCount, lead, true, forces);
const profile = quoteEcazOccupyBattle({
...input, ally: { ...input.ally, faction: 'emperor' },
});
const dial = quoteEcazOccupyDial(profile, 1, 0);
assert.equal(dial.forceOwner, lead);
assert.equal(dial.payer, lead);
assert.equal(dial.fixedEcazDial, 0);
assert.deepEqual(dial.options, [{
normal: lead === 'ecaz-seat' ? 1 : 0,
elite: lead === 'ally-seat' ? 1 : 0,
paidNormal: 0, paidElite: 0,
}]);
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: 1, support: 0,
});
assert.equal(win.casualties?.owner, lead);
assert.deepEqual(win.fixedLosses, []);
assert.deepEqual(win.destroyedArmies, []);
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
result: 'normal', won: false, dial: 1, support: 0,
}), {
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
});
}
}
const input = battle(false, 5, 'ecaz-seat', true);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, forces: ordinary(0, false) },
}), EcazOccupyBattleError);
assert.throws(() => quoteEcazOccupyBattle({
...input, ecaz: { ...input.ecaz, forces: ordinary(0, false) },
}), EcazOccupyBattleError);
});
void test('Basic sole traitor victory spares both armies; defeat, mutual traitors and explosion spare neither', () => {
for (const ecazCount of [2, 4]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
for (const canceled of [false, true]) {
const profile = quoteEcazOccupyBattle(battle(false, ecazCount, lead, canceled));
const dial = canceled ? 1 : ecazCount / 2 + 1;
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
result: 'traitor', won: true, dial, support: 0,
}), { destroyedArmies: [], fixedLosses: [], casualties: null });
for (const result of ['traitor', 'mutualTraitors', 'explosion'] as const)
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
result, won: false, dial, support: 0,
}), {
destroyedArmies: ['ecaz-seat', 'ally-seat'], fixedLosses: [], casualties: null,
});
for (const result of ['mutualTraitors', 'explosion'] as const)
assert.throws(() => quoteEcazOccupyOutcome(profile, {
result, won: true, dial, support: 0,
}), EcazOccupyBattleError);
}
}
}
});
void test('uncanceled Basic odd Ecaz is source-blocked before commitment, while Advanced keeps ceil losses and floor survivors', () => {
for (const ecazCount of [1, 3, 5, 19]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
assert.throws(() => quoteEcazOccupyBattle(battle(false, ecazCount, lead)), EcazOccupyBattleError);
const profile = quoteEcazOccupyBattle(battle(true, ecazCount, lead));
const win = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: Math.ceil(ecazCount / 2), support: 0,
});
assert.equal(ecazCount - win.fixedLosses[0].normal, Math.floor(ecazCount / 2));
}
}
});
void test('the Occupy rules band is required rather than defaulting malformed or missing input to Advanced', () => {
for (const advanced of [undefined, null, 0, 1, 'false', 'true']) {
const input = { ...battle(true, 4), advanced } as unknown as EcazOccupyBattleInput;
assert.throws(() => quoteEcazOccupyBattle(input), EcazOccupyBattleError);
}
});
void test('Advanced E1–5 ordinary wins lose ceil Ecaz separately and retain floor, with either lead', () => {
for (const [ecazCount, fixed, survivors] of [
[1, 1, 0], [2, 1, 1], [3, 2, 1], [4, 2, 2], [5, 3, 2],
]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
const profile = quoteEcazOccupyBattle(battle(ecazCount, lead));
const profile = quoteEcazOccupyBattle(battle(true, ecazCount, lead));
const dial = quoteEcazOccupyDial(profile, fixed + 1.5, 1);
assert.equal(dial.variableDial, 1.5);
assert.equal(dial.payer, lead);
@@ -56,7 +209,7 @@ void test('Advanced E1–5 ordinary wins lose ceil Ecaz separately and retain fl
void test('Advanced E1–5 normal defeat destroys both real armies regardless of the selected lead', () => {
for (const ecazCount of [1, 2, 3, 4, 5]) {
for (const lead of ['ecaz-seat', 'ally-seat']) {
const profile = quoteEcazOccupyBattle(battle(ecazCount, lead));
const profile = quoteEcazOccupyBattle(battle(true, ecazCount, lead));
const loss = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: false, dial: Math.ceil(ecazCount / 2), support: 0,
});
@@ -68,7 +221,7 @@ void test('Advanced E1–5 normal defeat destroys both real armies regardless of
});
void test('Ecaz cannot be dialed below its mandatory increment or charged extra support for it', () => {
const profile = quoteEcazOccupyBattle(battle(5));
const profile = quoteEcazOccupyBattle(battle(true, 5));
assert.throws(() => quoteEcazOccupyDial(profile, 2.5, 0), /include 3 fixed Ecaz/);
assert.throws(() => quoteEcazOccupyDial(profile, 3, 1), /legal physical commitment/);
assert.deepEqual(quoteEcazOccupyDial(profile, 3, 0).options, [
@@ -80,7 +233,7 @@ void test('Ecaz cannot be dialed below its mandatory increment or charged extra
});
void test('Fremen and Fedaykin remain free and physically Fremen even when Ecaz supplies the plan', () => {
const input = battle(3, 'ecaz-seat', false, {
const input = battle(true, 3, 'ecaz-seat', false, {
normal: 2, elite: 2, eliteStrength: 2, freeSupport: true,
});
const profile = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'fremen' } });
@@ -106,7 +259,7 @@ void test('Fremen and Fedaykin remain free and physically Fremen even when Ecaz
});
void test('Sardaukar keep typed supported casualties and their native opponent-dependent strength', () => {
const input = battle(3, 'ecaz-seat', false, {
const input = battle(true, 3, 'ecaz-seat', false, {
normal: 3, elite: 2, eliteStrength: 2, freeSupport: false,
});
const emperor = { ...input.ally, faction: 'emperor' as const };
@@ -141,7 +294,7 @@ void test('Sardaukar keep typed supported casualties and their native opponent-d
void test('cancellation retains either selected lead, recomputes its own dial, and leaves the other army undialed on a win', () => {
const fremen: CombatForces = { normal: 2, elite: 2, eliteStrength: 2, freeSupport: true };
for (const lead of ['ecaz-seat', 'ally-seat']) {
const input = battle(5, lead, false, fremen);
const input = battle(true, 5, lead, false, fremen);
const active = quoteEcazOccupyBattle({ ...input, ally: { ...input.ally, faction: 'fremen' } });
assert.equal(quoteEcazOccupyDial(active, 4, 0).forceOwner, 'ally-seat');
const canceled = quoteEcazOccupyBattle({ ...input, canceled: true, ally: { ...input.ally, faction: 'fremen' } });
@@ -172,7 +325,7 @@ void test('cancellation retains either selected lead, recomputes its own dial, a
void test('sole traitor victory waives both pools losses; sole defeat, mutual traitors and explosion destroy the actual side', () => {
for (const lead of ['ecaz-seat', 'ally-seat']) {
for (const canceled of [false, true]) {
const profile = quoteEcazOccupyBattle(battle(5, lead, canceled));
const profile = quoteEcazOccupyBattle(battle(true, 5, lead, canceled));
const dial = canceled ? 0 : 3;
assert.deepEqual(quoteEcazOccupyOutcome(profile, {
result: 'traitor', won: true, dial, support: 0,
@@ -190,7 +343,7 @@ void test('sole traitor victory waives both pools losses; sole defeat, mutual tr
});
void test('funding-limited legal options retain unsupported half dials without spending the fixed contribution', () => {
const profile = quoteEcazOccupyBattle(battle(1, 'ecaz-seat', false, ordinary(2)));
const profile = quoteEcazOccupyBattle(battle(true, 1, 'ecaz-seat', false, ordinary(2, true)));
assert.deepEqual(ecazOccupyDialOptions(profile, 0), [
{ dial: 1, support: 0, variableDial: 0 },
{ dial: 1.5, support: 0, variableDial: 0.5 },
@@ -207,25 +360,25 @@ void test('funding-limited legal options retain unsupported half dials without s
});
void test('nonreciprocal allies, advisors-only or disconnected pools, and foreign lead actors cannot create a combined army', () => {
const input = battle(3);
const input = battle(true, 3);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, ally: null },
}), /reciprocal ally/);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, faction: 'beneGesserit', forces: ordinary(0) },
...input, ally: { ...input.ally, faction: 'beneGesserit', forces: ordinary(0, true) },
}), /storm-connected fighters/);
assert.throws(() => quoteEcazOccupyBattle({
...input, ecaz: { ...input.ecaz, forces: ordinary(0) },
...input, ecaz: { ...input.ecaz, forces: ordinary(0, true) },
}), /storm-connected fighters/);
assert.throws(() => quoteEcazOccupyBattle({ ...input, lead: 'enemy-seat' }), /lead must be Ecaz/);
assert.throws(() => quoteEcazOccupyBattle({ ...input, battleOrderActor: '' }), /battle-order actor/);
assert.throws(() => quoteEcazOccupyBattle({
...input, ally: { ...input.ally, forces: ordinary(21) },
...input, ally: { ...input.ally, forces: ordinary(21, true) },
}), EcazOccupyBattleError);
});
void test('two twenty-counter armies are legal separate pools, not an over-cap merged CombatForces', () => {
const profile = quoteEcazOccupyBattle(battle(20, 'ecaz-seat', false, ordinary(20)));
const profile = quoteEcazOccupyBattle(battle(true, 20, 'ecaz-seat', false, ordinary(20, true)));
const outcome = quoteEcazOccupyOutcome(profile, {
result: 'normal', won: true, dial: 30, support: 20,
});
+1
View File
@@ -28,6 +28,7 @@ function profile(
allyForces = native(4),
): EcazOccupyBattleProfile {
return quoteEcazOccupyBattle({
advanced: true,
battleOrderActor: 'ecaz-user',
ecaz: {
id: 'ecaz-user', faction: 'ecaz', ally: 'ally-user',
+1 -1
View File
@@ -132,7 +132,7 @@ for (const role of ['ally', 'opponent'] as const) for (const marker of [0, 3, 5]
void test('Original concealed Richese presence permits a zero prospective pool, never an ordinary empty ally', () => {
const forces = { normal: 0, elite: 0, eliteStrength: 2 as const, freeSupport: false };
const input: EcazOccupyBattleInput = { battleOrderActor: 'ecaz', lead: 'ecaz', canceled: false,
const input: EcazOccupyBattleInput = { advanced: true, battleOrderActor: 'ecaz', lead: 'ecaz', canceled: false,
ecaz: { id: 'ecaz', faction: 'ecaz', ally: 'richese', forces: { ...forces, normal: 5 } },
ally: { id: 'richese', faction: 'richese', ally: 'ecaz', forces } };
const saved = structuredClone(input);
+1
View File
@@ -16,6 +16,7 @@ const ix: CombatForces = {
};
function profile(ecazNormal = 5, lead = 'ecaz-user', canceled = false, allyForces = ix): EcazOccupyBattleProfile {
return quoteEcazOccupyBattle({
advanced: true,
battleOrderActor: 'ecaz-user',
ecaz: { id: 'ecaz-user', faction: 'ecaz', ally: 'ally-user', forces: ordinary(ecazNormal) },
ally: { id: 'ally-user', faction: 'ixians', ally: 'ecaz-user', forces: allyForces },
+166
View File
@@ -0,0 +1,166 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { TERRITORIES, location as locationKey } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { advanceToNextStorm } from './fixture-advanced-source';
import { finishEcazOccupySetup, settleEcazOccupyArrival, stageEcazOccupyCard,
type EcazOccupyFixture } from './fixture-ecaz-occupy';
export type BasicEcazOccupyOptions = {
/** Existing authenticated ready lobby; never recreate seats or ready it again. */
initial?: Game;
ecazForces?: number;
allyFaction?: 'guild' | 'emperor' | 'fremen';
opponentFaction?: 'guild' | 'emperor' | 'fremen';
allyForces?: number;
territory?: string;
/** Odd rule probes stop before the native chooseBattle commitment. */
chooseBattle?: boolean;
};
function step(game: Game): Game {
if (game.response) return applyAction(game,
game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
if (game.phaseOpening) return applyAction(game,
game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id, { type: 'ready' });
if (game.decision?.kind === 'ecazPlacement')
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
if (game.phase === 0 && !game.decision && game.stormPending === null) {
const dialer = game.stormDialers.find(id => game.stormDials[id] === undefined)!;
return applyAction(game, dialer, { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 });
}
if (game.phase === 3 && game.auction && !game.decision)
return applyAction(game, game.auction.active, { type: 'passBid' });
if (game.decision?.kind === 'wormRide')
return applyAction(game, game.decision.player, { type: 'decision', accept: false });
if (game.decision?.kind === 'guildShipment')
return applyAction(game, game.decision.player, { type: 'decision', allow: true });
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const candidates = botActions(view);
const action = candidates.find(a => a.type === 'ready') ?? candidates[0];
if (action) return applyAction(game, player.id, action);
}
throw new Error(`No native Basic action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
}
function orderBlow(game: Game, worm: boolean, position: number): void {
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
(worm ? 'worm' in card && !card.greatMaker && !card.suppressed :
'territory' in card && !['the_great_flat', 'arrakeen'].includes(card.territory)));
assert.ok(index >= position, 'Reorder only original unplayed Spice Cards.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
export function basicEcazOccupyBattleAction(fixture: EcazOccupyFixture): { actor: string; action: Action } {
const game = fixture.game;
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === fixture.territory);
assert.ok(choice, 'The physical coalition and rival must produce one native battle choice.');
return { actor: choice.chooser, action: { type: 'chooseBattle', territory: fixture.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
}
/** Genuine Basic native deal, original Storm/Blow/Nexus/alliance and shipments.
* Only physical board forces and unplayed card order are controlled after setup.
* Native wallets, phases, reciprocal alliance, plans and receipts remain original. */
export function basicEcazOccupyFixture(options: BasicEcazOccupyOptions = {}): EcazOccupyFixture {
const allyFaction = options.allyFaction ?? 'guild';
const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild');
assert.notEqual(allyFaction, opponentFaction);
let setup: Game;
if (options.initial) {
setup = structuredClone(options.initial);
} else {
const roster = ['ecaz', allyFaction, opponentFaction] as const;
setup = createGame('BASICECAZOCCUPY', newPlayer('ecaz', 'ecaz', 'ecaz'), false, ['ecaz']);
for (const faction of roster.slice(1)) joinGame(setup, newPlayer(faction, faction, faction));
for (let i = roster.length - 1; i >= 0; i--)
if (viewGame(setup, roster[i]).playerPositions[roster[i]] !== i + 3)
setup = applyAction(setup, roster[i], { type: 'seatPosition', position: i + 3 });
for (const player of setup.players) setup = applyAction(setup, player.id, { type: 'ready' });
}
setup = initializeEcazOccupyGameForAudit(setup);
assert.equal(setup.advanced, false);
assert.deepEqual(setup.expansions, ['ecaz']);
assert.ok(!setup.ecazTreachery && !setup.leaderSkills && !setup.homeworlds && !setup.nexusCards &&
!setup.strongholdCards && !setup.techTokens && !setup.discoveryEnabled);
const ecazSeat = setup.players.find(player => player.faction === 'ecaz');
const allySeat = setup.players.find(player => player.faction === allyFaction);
const opponentSeat = setup.players.find(player => player.faction === opponentFaction);
assert.ok(ecazSeat && allySeat && opponentSeat, 'The actual lobby must contain the requested native factions.');
const ecaz = ecazSeat.id, ally = allySeat.id, opponent = opponentSeat.id;
const initial = structuredClone(setup);
let game = finishEcazOccupySetup(setup);
const afterSetup = structuredClone(game);
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)]
.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort(),
'The original Basic deal retains the exact Ecaz family deck.');
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
}
orderBlow(game, false, 0);
orderBlow(game, false, 1);
for (let attempt = 0; attempt < 800 && !(game.phase === 5 &&
!game.phaseOpening && !game.response && !game.decision); attempt++) game = step(game);
assert.equal(game.phase, 5, 'The first original Basic movement must open.');
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);
orderBlow(game, false, 2);
let allied = false;
for (let attempt = 0; attempt < 500; attempt++) {
if (!allied && game.phase === 1 && game.nexus && !game.spiceWindow &&
!game.spiceResolution && !game.phaseOpening && !game.response && !game.decision) {
game = applyAction(game, ecaz, { type: 'alliance', target: ally });
game = applyAction(game, ally, { type: 'alliance', target: ecaz });
allied = true;
}
if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break;
game = step(game);
}
assert.ok(allied && game.phase === 5, 'A genuine Basic worm Nexus must form the reciprocal alliance.');
assert.equal(game.players.find(p => p.id === ecaz)!.ally, ally);
assert.equal(game.players.find(p => p.id === ally)!.ally, ecaz);
const territory = options.territory ?? 'the_great_flat';
const sector = TERRITORIES.find(t => t.id === territory)!.sectors.find(s => s !== game.storm)!;
assert.ok(sector !== undefined);
const location = locationKey(territory, sector);
const ecazForces = options.ecazForces ?? 4;
// Controlled rival position after the genuine Nexus. Transfer eight original
// reserve counters without claiming an unaffordable played shipment history.
const rival = game.players.find(player => player.id === opponent)!;
assert.ok(rival.reserves >= 8);
rival.reserves -= 8;
rival.forces[location] = 8;
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 (actor === ecaz || actor === ally) {
const amount = actor === ecaz ? ecazForces : options.allyForces ?? 4;
game = settleEcazOccupyArrival(applyAction(game, actor,
{ type: 'ship', territory, sector, amount }));
}
game = applyAction(game, actor, { type: 'endMovement' });
}
while (game.phaseOpening || game.response || game.decision) game = step(game);
assert.equal(game.phase, 6);
const fixture: EcazOccupyFixture = { game, initial, afterSetup, ecaz, ally,
opponent, territory, location, ecazForces };
if (options.chooseBattle !== false) {
const { actor, action } = basicEcazOccupyBattleAction(fixture);
fixture.game = applyAction(game, actor, action);
assert.equal(fixture.game.decision?.kind, 'ecazBattleLead');
}
return fixture;
}