Connect native Homeworld Ecaz Arrakis coalition battles
This commit is contained in:
1 parent
210aa04811
commit
7ef3377218
10 files changed
+310
-22
No files matched your search
@@ -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' });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user