258 lines
14 KiB
TypeScript
258 lines
14 KiB
TypeScript
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'} native repopulation expires the Basic holding outright while Advanced retains its original epoch`, () => {
|
|
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, 'decided expiry is not an unresolved ambiguity');
|
|
});
|
|
|
|
void test(`${advanced ? 'Advanced' : 'Basic'} contest removes Basic current-sole control while Advanced retains its original epoch`, () => {
|
|
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, 'contested presence is a decided expiry, not an ambiguity');
|
|
});
|
|
}
|
|
|
|
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 current-sole control persists across turns while Advanced retains its original epoch', () => {
|
|
for (const advanced of [false, true]) {
|
|
const { game } = ecazHomeworldFixture(advanced);
|
|
game.turn++;
|
|
const row = ecazHomeworldVictoryProgress(game)!;
|
|
assert.equal(row.qualifies, true);
|
|
assert.equal(row.blocked, null);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|