Connect printed high Ecaz Homeworld alliance victory
This commit is contained in:
1 parent
cfa0fe3952
commit
17bc4537c3
14 files changed
+878
-8
No files matched your search
@@ -0,0 +1,120 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, RuleError, type Game } from '../game/engine';
|
||||
import { quoteVictory, VictoryQuoteError } from '../game/victory-quote';
|
||||
import { strongholdProgress } from '../game/victory-progress';
|
||||
import { ecazHomeworldFixture, ecazSeat, observeEcazPosition } from './fixture-ecaz-homeworld-victory';
|
||||
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
function originalCollection(game: Game): Game {
|
||||
game = normalizeAutomaticGame(game);
|
||||
for (let step = 0; !(game.phase === 7 && !game.phaseOpening && !game.response && !game.decision) && step < 300; step++) {
|
||||
if (game.phaseOpening) {
|
||||
const player = game.players.find(player => !game.phaseOpening!.passed.includes(player.id))!;
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
} else if (game.response) {
|
||||
const player = game.players.find(player => !game.response!.passed.includes(player.id))!;
|
||||
game = applyAction(game, player.id, { type: 'passResponse' });
|
||||
} else if (game.decision?.kind === 'homeworldOccupiedIncome') {
|
||||
const player = game.decision.player, offer = viewGame(game, player).homeworldOccupiedIncome!;
|
||||
game = applyAction(game, player, { type: 'decision', event: offer.event, world: offer.world, ownAmount: offer.amount });
|
||||
} else if (game.decision?.kind === 'guildTiming')
|
||||
game = applyAction(game, game.decision.player, { type: 'decision', take: true });
|
||||
else if (game.phase === 5 && game.active && !game.decision)
|
||||
game = applyAction(game, game.active, { type: 'endMovement' });
|
||||
else if (game.phase === 6 && !game.decision) {
|
||||
const player = game.players.find(player => !game.ready.includes(player.id))!;
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
} else throw new Error('The original Collection fixture requires another supported owned choice.');
|
||||
}
|
||||
assert.equal(game.phase, 7);
|
||||
return game;
|
||||
}
|
||||
function originalMentat(game: Game) {
|
||||
for (const player of game.players) if (!game.ready.includes(player.id))
|
||||
game = applyAction(JSON.parse(JSON.stringify(game)), player.id, { type: 'ready' });
|
||||
assert.equal(game.phase, 8);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('actual original Mentat awards the high Ecaz two-native-world alliance win with one joint stronghold and no ordinary point inflation', () => {
|
||||
const fixture = ecazHomeworldFixture(true);
|
||||
let game = originalCollection(fixture.game);
|
||||
const ordinary = strongholdProgress(game).progress.find(row => row.player === 'ec')!;
|
||||
assert.equal(ordinary.strongholds.length, 1); assert.equal(ordinary.jointlyOccupied.length, 1); assert.equal(ordinary.qualifies, false);
|
||||
assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []);
|
||||
const row = viewGame(game, 'ec').ecazHomeworldVictory!;
|
||||
assert.equal(row.population, 7); assert.equal(row.qualifies, true);
|
||||
assert.deepEqual(new Set(row.distinctNativeFactions), new Set(['atreides', 'emperor']));
|
||||
const cards = biddingPhysicalIds(game), forces = game.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves, tanks: player.tanks }));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let result: Game = JSON.parse(JSON.stringify(game));
|
||||
for (const player of result.players) {
|
||||
const view = viewGame(result, player.id); view.players.find(seat => seat.id === player.id)!.bot = difficulty;
|
||||
const ready = botActions(view).find(action => action.type === 'ready');
|
||||
assert.ok(ready, `${difficulty} has its original legal Collection readiness`);
|
||||
result = applyAction(result, player.id, ready);
|
||||
}
|
||||
assert.equal(result.status, 'finished');
|
||||
assert.deepEqual(new Set(result.winner), new Set(['ec', 'al']));
|
||||
assert.deepEqual(biddingPhysicalIds(result), cards);
|
||||
}
|
||||
game = originalMentat(game);
|
||||
assert.equal(game.status, 'finished'); assert.deepEqual(new Set(game.winner), new Set(['ec', 'al']));
|
||||
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||
assert.deepEqual(game.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves, tanks: player.tanks })), forces);
|
||||
});
|
||||
|
||||
void test('native population6 does not award this win despite the same actual joint site and two occupied native factions', () => {
|
||||
const fixture = ecazHomeworldFixture(true);
|
||||
ecazSeat(fixture.game, 'ec').reserves--; ecazSeat(fixture.game, 'ec').tanks++;
|
||||
observeEcazPosition(fixture.game, 'controlled-native-six-before-original-check');
|
||||
let game = originalCollection(fixture.game);
|
||||
assert.equal(viewGame(game, 'ec').ecazHomeworldVictory!.high, false);
|
||||
game = originalMentat(game);
|
||||
assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []);
|
||||
});
|
||||
|
||||
for (const predicted of ['ecaz', 'guild'] as const) void test(`actual BG prediction of ${predicted} replaces the printed Ecaz alliance win without changing public progress`, () => {
|
||||
let game = originalCollection(ecazHomeworldFixture(true).game);
|
||||
const progress = viewGame(game, 'ec').ecazHomeworldVictory!;
|
||||
ecazSeat(game, 'observer').prediction = { faction: predicted, turn: game.turn };
|
||||
assert.equal(viewGame(game, 'al').ecazHomeworldVictory!.qualifies, progress.qualifies);
|
||||
game = originalMentat(game);
|
||||
assert.deepEqual(game.winner, ['observer']); assert.equal(game.status, 'finished');
|
||||
});
|
||||
|
||||
void test('the original winner quote unions a simultaneous ordinary side before applying private prediction', () => {
|
||||
const fixture = ecazHomeworldFixture(true);
|
||||
const game = originalCollection(fixture.game);
|
||||
// Labelled surviving native counters: a different faction holds three ordinary sites.
|
||||
const sites = TERRITORIES.filter(site => site.type === 'stronghold' &&
|
||||
site.id !== fixture.jointStronghold && !site.sectors.includes(game.storm)).slice(0, 3)
|
||||
.map(site => `${site.id}:${site.sectors[0]}`);
|
||||
assert.equal(sites.length, 3);
|
||||
const other = ecazSeat(game, 'observer');
|
||||
assert.equal(other.faction, 'beneGesserit');
|
||||
delete other.advisors; other.forces = Object.fromEntries(sites.map(site => [site, 1])); other.reserves -= 3;
|
||||
observeEcazPosition(game, 'controlled-separate-ordinary-winner');
|
||||
const quote = quoteVictory(game, game);
|
||||
assert.deepEqual(new Set(quote.winner), new Set(['ec', 'al', 'observer']));
|
||||
assert.equal(quote.ecazHomeworld?.qualifies, true);
|
||||
});
|
||||
|
||||
void test('a wrong detached public Ecaz source cannot authorize an otherwise ordinary original victory quote', () => {
|
||||
const game = originalCollection(ecazHomeworldFixture(true).game), changed = structuredClone(game);
|
||||
changed.turn++;
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => quoteVictory(game, changed), VictoryQuoteError);
|
||||
assert.deepEqual(game, before);
|
||||
const markerless = structuredClone(game); delete markerless.homeworldEcazVictoryPreview;
|
||||
assert.deepEqual(quoteVictory(markerless).winner, []);
|
||||
assert.equal(viewGame(markerless, 'ec').ecazHomeworldVictory, null);
|
||||
const malformed = structuredClone(game); delete malformed.homeworldOccupationHistory;
|
||||
const frozen = structuredClone(malformed);
|
||||
assert.throws(() => applyAction(malformed, 'ec', { type: 'ready' }), RuleError);
|
||||
assert.deepEqual(malformed, frozen);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ecazHomeworldVictoryProgress, type EcazHomeworldVictoryProgress } from '../game/ecaz-homeworld-victory';
|
||||
import { VictoryProgressError, strongholdProgress } from '../game/victory-progress';
|
||||
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { conserveEcazPosition, ecazHomeworldFixture, ecazSeat, observeEcazPosition } from './fixture-ecaz-homeworld-victory';
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} original audit setup and conserved split holdings satisfy the printed High Ecaz condition`, () => {
|
||||
const { game, setup, movement, jointStronghold } = ecazHomeworldFixture(advanced);
|
||||
assert.equal(setup.homeworldEcazVictoryPreview, true);
|
||||
assert.equal(setup.homeworldOccupationPreview, true);
|
||||
assert.equal(movement.phase, 5);
|
||||
assert.equal(movement.response, null);
|
||||
assert.equal(movement.decision, null);
|
||||
assert.ok(!game.nexusCards);
|
||||
assert.ok(!game.techTokens && !game.strongholdCards && !game.discoveryEnabled);
|
||||
const before = JSON.stringify(game);
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.deepEqual(row, {
|
||||
owner: 'ec', members: game.order.filter(id => id === 'ec' || id === 'al'),
|
||||
population: 7, minimum: 7, high: true, jointStrongholds: [jointStronghold],
|
||||
foreignHomeworlds: [
|
||||
{ world: 'homeworld:atreides', card: 'caladan', native: 'at', faction: 'atreides', holder: 'ec' },
|
||||
{ world: 'homeworld:emperor', card: 'kaitain', native: 'em', faction: 'emperor', holder: 'al' },
|
||||
],
|
||||
distinctNativeFactions: ['atreides', 'emperor'], qualifies: true, blocked: null,
|
||||
});
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
assert.deepEqual(ecazHomeworldVictoryProgress(JSON.parse(before)), row);
|
||||
assert.equal(strongholdProgress(game).progress.find(row => row.player === 'ec')!.qualifies, false);
|
||||
homeworldGameIntegrity(game);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} six native Ecaz counters are Low; seven restores the independent printed win`, () => {
|
||||
const { game } = ecazHomeworldFixture(advanced);
|
||||
ecazSeat(game, 'ec').reserves = 6;
|
||||
observeEcazPosition(game, 'controlled-native-ecaz-six');
|
||||
const low = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(low.population, 6);
|
||||
assert.equal(low.high, false);
|
||||
assert.equal(low.qualifies, false);
|
||||
assert.equal(low.foreignHomeworlds.length, 2);
|
||||
ecazSeat(game, 'ec').reserves = 7;
|
||||
observeEcazPosition(game, 'controlled-native-ecaz-seven');
|
||||
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
|
||||
});
|
||||
|
||||
for (const holders of [['ec', 'ec'], ['al', 'al']] as const)
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} both distinct foreign worlds can be physically held by ${holders[0]}`, () => {
|
||||
const { game } = ecazHomeworldFixture(advanced, 'guild', holders);
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.deepEqual(row.foreignHomeworlds.map(world => world.holder), [...holders]);
|
||||
assert.equal(row.qualifies, true);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} two worlds without one actual joint stronghold never qualify`, () => {
|
||||
const { game } = ecazHomeworldFixture(advanced);
|
||||
ecazSeat(game, 'al').forces = {};
|
||||
conserveEcazPosition(game);
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.deepEqual(row.jointStrongholds, []);
|
||||
assert.equal(row.foreignHomeworlds.length, 2);
|
||||
assert.equal(row.qualifies, false);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} a departed physical garrison is not a retained victory holding`, () => {
|
||||
const { game } = ecazHomeworldFixture(advanced);
|
||||
delete game.homeworlds!.custody!.visitors['homeworld:atreides'];
|
||||
observeEcazPosition(game, 'controlled-caladan-departure');
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.deepEqual(row.distinctNativeFactions, ['emperor']);
|
||||
assert.equal(row.qualifies, false);
|
||||
assert.equal(row.blocked, null);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} established source authority distinguishes native repopulation from physical foreign presence`, () => {
|
||||
const { game } = ecazHomeworldFixture(advanced);
|
||||
ecazSeat(game, 'at').reserves = 20;
|
||||
observeEcazPosition(game, 'controlled-native-caladan-repopulation');
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(row.qualifies, advanced);
|
||||
assert.equal(row.foreignHomeworlds.length, advanced ? 2 : 1);
|
||||
assert.equal(row.blocked === null, advanced);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} third Homeworld visitors preserve Advanced retained authority but block unresolved Basic occupation`, () => {
|
||||
const { game } = ecazHomeworldFixture(advanced);
|
||||
game.homeworlds!.custody!.visitors['homeworld:atreides'].observer = { normal: 1, elite: 0 };
|
||||
ecazSeat(game, 'observer').reserves--;
|
||||
observeEcazPosition(game, 'controlled-third-homeworld-visitor');
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(row.qualifies, advanced);
|
||||
assert.equal(row.blocked === null, advanced);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Emperor Kaitain and Salusa are two physical worlds of only one other native faction', () => {
|
||||
const { game } = ecazHomeworldFixture();
|
||||
delete game.homeworlds!.custody!.visitors['homeworld:atreides'];
|
||||
game.homeworlds!.custody!.visitors['homeworld:emperor:salusa'] = { ec: { normal: 1, elite: 0 } };
|
||||
observeEcazPosition(game, 'controlled-two-emperor-worlds');
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.deepEqual(row.foreignHomeworlds.map(world => world.card), ['kaitain', 'salusa_secundus']);
|
||||
assert.deepEqual(row.distinctNativeFactions, ['emperor']);
|
||||
assert.equal(row.qualifies, false);
|
||||
assert.equal(row.blocked, null);
|
||||
});
|
||||
|
||||
void test('own Ecaz and ally-native worlds are excluded rather than counted as other native factions', () => {
|
||||
const { game } = ecazHomeworldFixture();
|
||||
delete game.homeworlds!.custody!.visitors['homeworld:atreides'];
|
||||
// Deliberately hypothetical public custody, not a legal alliance action:
|
||||
// the real engine separately prohibits allies occupying each other's homes.
|
||||
game.homeworlds!.custody!.visitors['homeworld:ecaz'] = { al: { normal: 1, elite: 0 } };
|
||||
game.homeworlds!.custody!.visitors['homeworld:guild'] = { ec: { normal: 1, elite: 0 } };
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.deepEqual(row.foreignHomeworlds.map(world => world.world), ['homeworld:emperor']);
|
||||
assert.deepEqual(row.distinctNativeFactions, ['emperor']);
|
||||
assert.equal(row.qualifies, false);
|
||||
assert.equal(row.blocked, null);
|
||||
});
|
||||
|
||||
void test('joint stronghold under storm still counts; third fighters contest it and unrelated advisors do not', () => {
|
||||
const { game, jointStronghold, jointLocation } = ecazHomeworldFixture();
|
||||
game.storm = splitLocation(jointLocation).sector; // Hypothetical public-board storm, not a clock transition.
|
||||
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
|
||||
ecazSeat(game, 'observer').forces = { [jointLocation]: 1 };
|
||||
ecazSeat(game, 'observer').reserves--;
|
||||
conserveEcazPosition(game);
|
||||
assert.deepEqual(ecazHomeworldVictoryProgress(game)!.jointStrongholds, []);
|
||||
ecazSeat(game, 'observer').advisors = { [jointStronghold]: {} };
|
||||
conserveEcazPosition(game);
|
||||
assert.deepEqual(ecazHomeworldVictoryProgress(game)!.jointStrongholds, [jointStronghold]);
|
||||
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
|
||||
});
|
||||
|
||||
void test('allied BG advisors are not a second joint fighter, and mandatory release cannot invent one', () => {
|
||||
const { game, jointStronghold, jointLocation } = ecazHomeworldFixture(true, 'beneGesserit');
|
||||
ecazSeat(game, 'al').advisors = { [jointStronghold]: {} };
|
||||
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, false);
|
||||
ecazSeat(game, 'ec').forces = {};
|
||||
conserveEcazPosition(game);
|
||||
assert.equal(strongholdProgress(game).released.length, 1);
|
||||
assert.deepEqual(ecazHomeworldVictoryProgress(game)!.jointStrongholds, []);
|
||||
delete ecazSeat(game, 'al').advisors;
|
||||
ecazSeat(game, 'ec').forces = { [jointLocation]: 1 };
|
||||
conserveEcazPosition(game);
|
||||
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
|
||||
});
|
||||
|
||||
void test('a placed HMS is an actual joint stronghold, not an ordinary board pointer', () => {
|
||||
const { game } = ecazHomeworldFixture(true, 'ixians');
|
||||
game.mobileStronghold = { location: 'red_chasm:7' };
|
||||
for (const id of ['ec', 'al']) ecazSeat(game, id).forces = { 'hidden_mobile_stronghold:0': 1 };
|
||||
conserveEcazPosition(game);
|
||||
assert.deepEqual(ecazHomeworldVictoryProgress(game)!.jointStrongholds, ['hidden_mobile_stronghold']);
|
||||
assert.equal(ecazHomeworldVictoryProgress(game)!.qualifies, true);
|
||||
ecazSeat(game, 'al').forces = { 'red_chasm:7': 1 };
|
||||
conserveEcazPosition(game);
|
||||
assert.deepEqual(ecazHomeworldVictoryProgress(game)!.jointStrongholds, []);
|
||||
});
|
||||
|
||||
void test('public No-Field presence gives the same joint site for every concealed value without inventory reads', () => {
|
||||
const { game, jointLocation } = ecazHomeworldFixture(true, 'richese');
|
||||
ecazSeat(game, 'al').forces = {};
|
||||
conserveEcazPosition(game);
|
||||
let expected: EcazHomeworldVictoryProgress | null | undefined;
|
||||
for (const id of ['field-zero', 'field-three', 'field-five']) {
|
||||
const field = deployRicheseNoField(createRicheseNoField(['field-zero', 'field-three', 'field-five']), {
|
||||
tokenId: id, controller: 'al', location: splitLocation(jointLocation),
|
||||
});
|
||||
ecazSeat(game, 'al').noField = field;
|
||||
Object.defineProperty(field, 'tokens', { get() { throw new Error('Private inventory accessed'); } });
|
||||
const row = ecazHomeworldVictoryProgress(game);
|
||||
assert.equal(row!.qualifies, true);
|
||||
if (expected) assert.deepEqual(row, expected);
|
||||
expected = row;
|
||||
}
|
||||
});
|
||||
|
||||
void test('public projection does not read hands, leaders, spice, predictions or technology income', () => {
|
||||
const { game } = ecazHomeworldFixture();
|
||||
const expected = ecazHomeworldVictoryProgress(game)!;
|
||||
for (const player of game.players)
|
||||
for (const key of ['hand', 'leaders', 'spice', 'prediction'])
|
||||
Object.defineProperty(player, key, { get() { throw new Error('Private player field accessed'); } });
|
||||
Object.defineProperty(game, 'techTokens', { get() { throw new Error('Unneeded technology accessed'); } });
|
||||
assert.deepEqual(ecazHomeworldVictoryProgress(game), expected);
|
||||
});
|
||||
|
||||
void test('old contexts without the fresh marker return null without reserve, elite or Homeworld reads', () => {
|
||||
const { game } = ecazHomeworldFixture();
|
||||
delete game.homeworldEcazVictoryPreview;
|
||||
for (const player of game.players)
|
||||
for (const key of ['reserves', 'elites'])
|
||||
Object.defineProperty(player, key, { get() { throw new Error('Old context reserve accessed'); } });
|
||||
Object.defineProperty(game, 'homeworlds', { get() { throw new Error('Old context Homeworld accessed'); } });
|
||||
assert.equal(ecazHomeworldVictoryProgress(game), null);
|
||||
});
|
||||
|
||||
void test('missing or legacy source history blocks a potential two-native win without creating holdings', () => {
|
||||
for (const legacy of [false, true]) {
|
||||
const { game } = ecazHomeworldFixture();
|
||||
if (legacy) delete game.homeworlds!.historyVersion;
|
||||
else delete game.homeworldOccupationHistory;
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.deepEqual(row.foreignHomeworlds, []);
|
||||
assert.deepEqual(row.distinctNativeFactions, []);
|
||||
assert.equal(row.qualifies, false);
|
||||
assert.notEqual(row.blocked, null);
|
||||
delete game.homeworlds!.custody!.visitors['homeworld:atreides'];
|
||||
assert.equal(ecazHomeworldVictoryProgress(game)!.blocked, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic expiry stays unresolved; Advanced retains occupation while its original holder remains', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const { game } = ecazHomeworldFixture(advanced);
|
||||
game.turn++;
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(row.qualifies, advanced);
|
||||
assert.equal(row.blocked === null, advanced);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a physical unobserved new sole garrison cannot manufacture Advanced source qualification', () => {
|
||||
const { game, movement } = ecazHomeworldFixture();
|
||||
game.homeworldOccupationHistory = movement.homeworldOccupationHistory;
|
||||
const row = ecazHomeworldVictoryProgress(game)!;
|
||||
assert.equal(row.qualifies, false);
|
||||
assert.deepEqual(row.foreignHomeworlds, []);
|
||||
assert.notEqual(row.blocked, null);
|
||||
});
|
||||
|
||||
void test('nonexistent Homeworlds, malformed public data and altered JSON source provenance reject with the victory error class', () => {
|
||||
const { game } = ecazHomeworldFixture();
|
||||
const cases = [
|
||||
(g: typeof game) => { g.homeworlds!.custody!.visitors['homeworld:nonexistent'] = { ec: { normal: 1, elite: 0 } }; },
|
||||
(g: typeof game) => { g.homeworlds!.custody!.visitors['homeworld:ecaz'] = { ec: { normal: 1, elite: 0 } }; },
|
||||
(g: typeof game) => { ecazSeat(g, 'al').ally = null; },
|
||||
(g: typeof game) => { ecazSeat(g, 'ec').reserves = -1; },
|
||||
(g: typeof game) => { g.turn = 0; },
|
||||
(g: typeof game) => { ecazSeat(g, 'ec').faction = 'harkonnen'; },
|
||||
(g: typeof game) => { g.homeworldOccupationHistory!.sources[1].cause = 'setup'; },
|
||||
(g: typeof game) => { g.homeworldOccupationHistory!.qualifications[0].player = 'observer'; },
|
||||
(g: typeof game) => { g.homeworldOccupationHistory!.snapshots[1] = '[]'; },
|
||||
(g: typeof game) => { delete g.homeworldOccupationPreview; },
|
||||
];
|
||||
for (const mutate of cases) {
|
||||
const restored: typeof game = JSON.parse(JSON.stringify(game));
|
||||
mutate(restored);
|
||||
assert.throws(() => ecazHomeworldVictoryProgress(restored), VictoryProgressError);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeHomeworldOccupationGameForAudit, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
|
||||
import { advanceOriginal } from './fixture-homeworld-occupied-producers';
|
||||
|
||||
export const ecazSeat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
|
||||
/** Controlled positions are conserved test placements, NOT naturally achieved
|
||||
* games. Setup and source history are original; no synthetic source facts,
|
||||
* replacement deck, optional cards or fabricated occupation receipt is used. */
|
||||
export function conserveEcazPosition(game: Game): void {
|
||||
const groups = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!);
|
||||
for (const player of game.players) {
|
||||
const abroad = groups.filter(home => home.native !== player.id)
|
||||
.reduce((n, home) => n + (home.forces[player.id]?.normal ?? 0) + (home.forces[player.id]?.elite ?? 0), 0);
|
||||
player.tanks = 20 - player.reserves - abroad - Object.values(player.forces).reduce((a, n) => a + n, 0);
|
||||
if (player.elites) {
|
||||
const total = player.faction === 'fremen' ? 3 : player.faction === 'ixians' ? 7 : 5;
|
||||
const abroadElite = groups.filter(home => home.native !== player.id)
|
||||
.reduce((n, home) => n + (home.forces[player.id]?.elite ?? 0), 0);
|
||||
player.elites.tanks = total - player.elites.reserves - abroadElite - Object.values(player.elites.forces).reduce((a, n) => a + n, 0);
|
||||
}
|
||||
}
|
||||
homeworldGameIntegrity(game);
|
||||
}
|
||||
|
||||
export function observeEcazPosition(game: Game, event: string, cause: 'change' | 'turnStart' | 'turnEnd' = 'change'): void {
|
||||
conserveEcazPosition(game);
|
||||
game.homeworldOccupationHistory = observeHomeworldOccupation(game.homeworldOccupationHistory!,
|
||||
homeworldContext(game), game.homeworlds!.custody!, game.turn, cause, event);
|
||||
}
|
||||
|
||||
export function ecazHomeworldFixture(advanced = true, ally: FactionId = 'guild', holders: readonly ['ec' | 'al', 'ec' | 'al'] = ['ec', 'al']) {
|
||||
const factions = ['ecaz', ally, 'atreides', 'emperor', ...(ally === 'beneGesserit' ? ['guild'] : ['beneGesserit'])] as FactionId[];
|
||||
const expansions = [...new Set(factions.map(faction => FACTIONS.find(row => row.id === faction)!.expansion)
|
||||
.filter(expansion => expansion !== 'base'))];
|
||||
let game = createGame('ECAZHOMEWORLDVICTORY', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, expansions);
|
||||
for (const [index, faction] of factions.slice(1).entries())
|
||||
joinGame(game, newPlayer(['al', 'at', 'em', 'observer'][index], faction, faction));
|
||||
game = applyAction(game, 'ec', { type: 'homeworlds', enabled: true });
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initializeHomeworldOccupationGameForAudit(game);
|
||||
game = advanceOriginal(game, current => current.status === 'playing');
|
||||
const setup = structuredClone(game);
|
||||
game = advanceOriginal(game, current => current.phase === 5 && !current.response && !current.decision);
|
||||
const movement = structuredClone(game);
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
const originalTanks = Object.fromEntries(game.players.map(player => [player.id, {
|
||||
normal: player.tanks, elite: player.elites?.tanks ?? 0,
|
||||
}]));
|
||||
for (const player of game.players) {
|
||||
Object.assign(player, { forces: {}, reserves: 20 - player.tanks, ally: null });
|
||||
delete player.advisors;
|
||||
if (player.elites) Object.assign(player.elites, {
|
||||
reserves: (player.faction === 'ixians' ? 7 : player.faction === 'fremen' ? 3 : 5) - player.elites.tanks,
|
||||
forces: {},
|
||||
});
|
||||
}
|
||||
const joint = TERRITORIES.find(site => site.type === 'stronghold' && !site.sectors.includes(game.storm))!;
|
||||
assert.ok(joint, 'Original Movement must have a non-storm joint stronghold.');
|
||||
const jointStronghold = joint.id;
|
||||
const jointLocation = `${joint.id}:${joint.sectors[0]}`;
|
||||
const custody = game.homeworlds!.custody!;
|
||||
custody.visitors = {
|
||||
'homeworld:atreides': { [holders[0]]: { normal: 1, elite: 0 } },
|
||||
'homeworld:emperor': { [holders[1]]: { normal: 1, elite: 0 } },
|
||||
};
|
||||
if (advanced) custody.salusa = { normal: 0, elite: 0 };
|
||||
ecazSeat(game, 'ec').ally = 'al';
|
||||
ecazSeat(game, 'al').ally = 'ec';
|
||||
ecazSeat(game, 'ec').forces = { [jointLocation]: 1 };
|
||||
ecazSeat(game, 'al').forces = { [jointLocation]: 1 };
|
||||
ecazSeat(game, 'ec').reserves = 7;
|
||||
ecazSeat(game, 'al').reserves = 20 - originalTanks.al.normal - 1 - holders.filter(holder => holder === 'al').length;
|
||||
for (const id of ['at', 'em']) {
|
||||
ecazSeat(game, id).reserves = 0;
|
||||
if (ecazSeat(game, id).elites) ecazSeat(game, id).elites!.reserves = 0;
|
||||
}
|
||||
observeEcazPosition(game, 'controlled-conserved-ecaz-two-native-worlds');
|
||||
for (const player of game.players) {
|
||||
assert.ok(player.tanks >= originalTanks[player.id].normal, 'Controlled position cannot resurrect original Tanks.');
|
||||
assert.ok((player.elites?.tanks ?? 0) >= originalTanks[player.id].elite, 'Controlled position cannot resurrect original special Tanks.');
|
||||
}
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...game.players.flatMap(player => player.hand)].map(card => card.id).sort(), cards);
|
||||
return { game, setup, movement, cards, jointStronghold, jointLocation };
|
||||
}
|
||||
Reference in new issue
Block a user