Connect native Homeworld Ecaz Arrakis coalition battles

This commit is contained in:
Quintine committed 2026-10-17 18:24:11 +11:00
1 parent 210aa04811
commit 7ef3377218
10 files changed
+310 -22

No files matched your search

-13
View File
@@ -132,19 +132,6 @@ void test('incompatible selected modules reject the whole start instead of silen
}
});
void test('an Ecaz Homeworld Skills selection cannot start without supported combined Occupy battles', () => {
let game = createGame('ECAZLOBBYMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']);
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
game = act(game, game.host, { type: 'homeworlds', enabled: true });
game = act(game, game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
game = ready(game);
reject(game, game.host, preview);
assert.equal(game.status, 'lobby');
assert.equal(game.homeworlds!.custody, null);
assert.equal(game.leaderSkills, undefined);
});
void test('Advanced starts require the exact host opt-in and cannot dispatch audit initializers or hidden bypass options', () => {
const game = ready(lobby(BASE, true));
reject(game, game.host, { type: 'start' });
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import { finishEcazOccupySetup, type EcazOccupyFixture } from './fixture-ecaz-occupy';
export type HomeworldEcazStep = { actor: string; action: Action };
export type HomeworldEcazOccupyFixture = EcazOccupyFixture & { actions: HomeworldEcazStep[] };
export const homeworldEcazClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Original choices only: leave optional Ambassadors unplaced and pass auctions. */
export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null {
if (game.response) return { actor: game.players.find(p => !game.response!.passed.includes(p.id))!.id,
action: { type: 'passResponse' } };
if (game.decision?.kind === 'ecazPlacement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (!game.phaseOpening && !game.decision && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } };
}
if (!game.phaseOpening && !game.decision && game.phase === 3 && game.auction)
return { actor: game.auction.active, action: { type: 'passBid' } };
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const actions = botActions(view);
const action = actions.find(a => a.type === 'ready') ?? actions[0];
if (action) return { actor: player.id, action };
}
return null;
}
/** Fresh native World setup, a real worm alliance and paid Arrakis shipments.
* Only the unopened Spice order is staged; no armies, wallets, phases, alliances
* or World custody are assigned. The selected skill offers remain the real deal. */
export function createHomeworldEcazOccupyFixture(options: { advanced?: boolean; skills?: boolean } = {}): HomeworldEcazOccupyFixture {
const advanced = options.advanced ?? true;
let game = createGame('WORLDECAZOCCUPY', newPlayer('ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
const actions: HomeworldEcazStep[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action: structuredClone(action) });
game = applyAction(game, actor, action);
};
act('ecaz', { type: 'homeworlds', enabled: true });
if (options.skills) act('ecaz', { type: 'moduleOption', module: 'leaderSkills', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
act('ecaz', advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const initial = structuredClone(game);
game = finishEcazOccupySetup(game);
const afterSetup = structuredClone(game);
const step = () => {
const next = nextHomeworldEcazStep(game);
assert.ok(next, `A native World action must exist at ${game.turn}/${game.phase}/${game.decision?.kind}`);
act(next.actor, next.action);
};
const reach = (until: (state: Game) => boolean) => {
for (let n = 0; n < 800 && !until(game); n++) step();
assert.ok(until(game), 'The original World programme must reach its next native boundary.');
};
const orderBlow = (worm: boolean, position: number) => {
const at = game.spiceDeck.findIndex((card, i) => i >= position &&
(worm ? 'worm' in card && !card.greatMaker && !card.suppressed :
'territory' in card && !['arrakeen', 'sietch_tabr', 'habbanya_ridge_sietch'].includes(card.territory)));
assert.ok(at >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
};
orderBlow(false, 0);
if (advanced) orderBlow(false, 1);
reach(g => g.phase === 5 && homeworldEcazClean(g));
while (game.phase === 5) {
if (homeworldEcazClean(game)) act(game.active!, { type: 'endMovement' }); else step();
}
reach(g => g.turn === 2 && g.phase === 0 && !g.phaseOpening);
orderBlow(true, 0);
orderBlow(false, 1);
if (advanced) orderBlow(false, 2);
reach(g => g.phase === 1 && g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldEcazClean(g));
act('ecaz', { type: 'alliance', target: 'emperor' });
act('emperor', { type: 'alliance', target: 'ecaz' });
reach(g => g.phase === 5 && homeworldEcazClean(g));
const battleTerritory = ['arrakeen', 'sietch_tabr', 'habbanya_ridge_sietch']
.find(id => territory(id).sectors[0] !== game.storm)!;
const sector = territory(battleTerritory).sectors[0];
const location = `${battleTerritory}:${sector}`;
const shipped = new Set<string>();
while (game.phase === 5) {
if (!homeworldEcazClean(game)) { step(); continue; }
const actor = game.active!;
if (!shipped.has(actor)) {
act(actor, { type: 'ship', territory: battleTerritory, sector,
amount: actor === 'guild' ? 2 : 4, elite: actor === 'emperor' ? 2 : 0, allyPayment: 0 });
shipped.add(actor);
reach(homeworldEcazClean);
}
act(actor, { type: 'endMovement' });
}
reach(homeworldEcazClean);
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === battleTerritory);
assert.ok(choice, 'The actually shipped coalition must have a mandatory Arrakis battle.');
act(choice.chooser, { type: 'chooseBattle', territory: battleTerritory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
if (!options.skills) assert.equal(game.decision?.kind, 'ecazBattleLead');
return { game, initial, afterSetup, actions, ecaz: 'ecaz', ally: 'emperor', opponent: 'guild',
territory: battleTerritory, location, ecazForces: 4 };
}
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { chooseEcazOccupyLead, openEcazOccupyPlans, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { createHomeworldEcazOccupyFixture, homeworldEcazClean, nextHomeworldEcazStep } from './fixture-homeworld-ecaz-occupy';
const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
const leader = (game: Game, id: string, strongest: boolean) => player(game, id).leaders
.filter(l => !l.dead && !l.usedAt).sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0].id;
function revealAndFinish(state: Game, mixedLoss = false): Game {
let game = state;
while (game.battle) {
const view = viewGame(game, game.battle.attacker).battle!;
const voter = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
assert.ok(voter);
game = applyAction(game, voter, { type: 'traitorCall', call: false });
}
if (mixedLoss) {
const decision = game.decision;
assert.ok(decision?.kind === 'battleLosses');
const choice = decision.options.findIndex(loss => loss.normal === 1 && loss.elite === 1);
assert.ok(choice >= 0);
game = applyAction(game, decision.player, { type: 'decision', choice });
}
for (let n = 0; n < 100 && (game.phase === 6 || game.phase === 7 && !homeworldEcazClean(game)); n++) {
const next = nextHomeworldEcazStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.phase, 7);
homeworldGameIntegrity(game);
return game;
}
for (const advanced of [false, true]) for (const lead of ['ecaz', 'ally'] as const)
void test(`${advanced ? 'Advanced' : 'Basic'} native Worlds ${lead} lead keeps separate typed Arrakis Occupy losses and original World custody`, () => {
const f = createHomeworldEcazOccupyFixture({ advanced });
const actor = lead === 'ecaz' ? f.ecaz : f.ally;
let game = openEcazOccupyPlans(chooseEcazOccupyLead(f, lead));
const before = structuredClone(game);
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
assert.equal(profile.forceOwner, f.ally);
assert.equal(profile.fixedEcazDial, 2);
// Advanced Salusa retains full-strength unpaid Sardaukar. One paid ordinary
// plus one unpaid starred counter makes variable strength 3, not three pieces.
game = applyAction(game, actor, { type: 'battlePlan', dial: advanced ? 5 : 4,
support: advanced ? 1 : 0, leader: leader(game, actor, true) });
game = applyAction(game, f.opponent, { type: 'battlePlan', dial: 0, leader: leader(game, f.opponent, false) });
game = revealAndFinish(game, !advanced);
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(player(game, f.ecaz).forces[f.location], 2);
assert.equal(player(game, f.ecaz).tanks - player(before, f.ecaz).tanks, 2);
assert.equal(player(game, f.ally).forces[f.location], 2);
assert.equal(player(game, f.ally).elites!.forces[f.location], 1);
assert.equal(player(game, f.ally).tanks - player(before, f.ally).tanks, 2);
assert.equal(player(game, f.ally).elites!.tanks - player(before, f.ally).elites!.tanks, 1);
assert.equal(player(game, f.opponent).forces[f.location], undefined);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`native World Karama prevention keeps ${lead} as lead and spares the excluded allied army`, () => {
const f = createHomeworldEcazOccupyFixture();
const actor = lead === 'ecaz' ? f.ecaz : f.ally;
const excluded = actor === f.ecaz ? f.ally : f.ecaz;
const karama = stageEcazOccupyCard(f.game, f.opponent, card => card.effect === 'karama');
let game = chooseEcazOccupyLead(f, lead);
assert.equal(game.response?.kind, 'ecazOccupy');
game = applyAction(game, f.opponent, { type: 'card', card: karama, mode: 'cancel' });
game = openEcazOccupyPlans(game);
const before = structuredClone(game);
const profile = viewGame(game, actor).battle!.ecazOccupy!.profile!;
assert.equal(profile.forceOwner, actor);
assert.equal(profile.fixedEcazDial, 0);
game = applyAction(game, actor, { type: 'battlePlan', dial: actor === f.ecaz ? 2 : 3,
support: actor === f.ecaz ? 2 : 1, leader: leader(game, actor, true) });
game = applyAction(game, f.opponent, { type: 'battlePlan', dial: 0, leader: leader(game, f.opponent, false) });
game = revealAndFinish(game);
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(player(game, actor).forces[f.location], 2);
assert.equal(player(game, actor).tanks - player(before, actor).tanks, 2);
assert.deepEqual(player(game, excluded).forces, player(before, excluded).forces);
assert.equal(player(game, excluded).tanks, player(before, excluded).tanks);
assert.equal(game.discard.filter(card => card.id === karama).length, 1);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
});
void test('all four legal profiles complete the native World Skills coalition and retain physical source integrity', () => {
for (const difficulty of DIFFICULTIES) {
const f = createHomeworldEcazOccupyFixture({ skills: true });
let game = f.game;
for (let n = 0; n < 150 && game.phase === 6; n++) {
let next;
for (const p of game.players) {
const view = viewGame(game, p.id);
view.players.find(seat => seat.id === p.id)!.bot = difficulty;
const action = botActions(view)[0];
if (action) { next = { actor: p.id, action }; break; }
}
assert.ok(next, `${difficulty} must retain a legal coalition actor`);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.phase, 7, difficulty);
assert.ok(game.lastBattleContext!.combatants.includes(f.opponent));
assert.equal(game.battle, null);
homeworldGameIntegrity(game);
}
});