370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { type FactionId } from '../game/catalog';
|
|
import { baseDeck } from '../game/cards';
|
|
import { territory } from '../game/board';
|
|
import { quoteVictory } from '../game/victory-quote';
|
|
import { strongholdProgress } from '../game/victory-progress';
|
|
import { createTechTokens } from '../game/tech-tokens';
|
|
import { createStrongholdCards } from '../game/stronghold-cards';
|
|
import { createTerrorState } from '../game/moritani-terror';
|
|
import { botActions } from '../game/bots';
|
|
import { createAmbassadors } from '../game/ecaz-ambassadors';
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
|
|
const THREE = ['arrakeen', 'carthag', 'sietch_tabr'];
|
|
const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
const json = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
|
|
// Constructed, conserved development board; transitions into Mentat are real
|
|
// ready actions, not a production Ecaz-start or combined-combat claim.
|
|
function fixture(
|
|
ally: FactionId = 'emperor',
|
|
extra: FactionId[] = ['beneGesserit'],
|
|
advanced = true,
|
|
) {
|
|
const g = createGame(
|
|
'ECAZVICTORY',
|
|
newPlayer('ec', 'Ecaz', 'ecaz'),
|
|
advanced,
|
|
['ecaz'],
|
|
);
|
|
g.players.push(newPlayer('al', 'Ally', ally));
|
|
extra.forEach((f, i) => g.players.push(newPlayer(`x${i}`, f, f)));
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 7,
|
|
turn: 2,
|
|
storm: 18,
|
|
order: g.players.map((p) => p.id),
|
|
active: null,
|
|
ready: [],
|
|
phaseOpening: null,
|
|
deck: baseDeck(),
|
|
});
|
|
for (const p of g.players)
|
|
Object.assign(p, {
|
|
hand: [],
|
|
forces: {},
|
|
reserves: 20,
|
|
spice: 20,
|
|
traitors: [],
|
|
traitorChoices: [],
|
|
});
|
|
seat(g, 'ec').ally = 'al';
|
|
seat(g, 'al').ally = 'ec';
|
|
seat(g, 'ec').allySinceTurn = 1;
|
|
seat(g, 'al').allySinceTurn = 1;
|
|
return g;
|
|
}
|
|
function occupy(g: Game, id: string, holds: string[]) {
|
|
const p = seat(g, id);
|
|
p.forces = Object.fromEntries(
|
|
holds.map((t) => [`${t}:${territory(t).sectors[0]}`, 1]),
|
|
);
|
|
p.reserves = 20 - holds.length;
|
|
}
|
|
function joint(g: Game, holds = THREE) {
|
|
occupy(g, 'ec', holds);
|
|
occupy(g, 'al', holds);
|
|
return g;
|
|
}
|
|
function mentat(state: Game) {
|
|
let g = json(state);
|
|
for (const p of state.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.phase, 8);
|
|
return g;
|
|
}
|
|
function verdict(state: Game, winners: string[]) {
|
|
const original = json(state);
|
|
const input = json(state);
|
|
input.phase = 8;
|
|
const quote = quoteVictory(input);
|
|
assert.deepEqual(quote.winner, winners);
|
|
assert.deepEqual(state, original);
|
|
const done = mentat(state);
|
|
assert.deepEqual(done.winner, winners);
|
|
assert.equal(done.status, winners.length ? 'finished' : 'playing');
|
|
for (const p of state.players) {
|
|
assert.deepEqual(seat(done, p.id).forces, p.forces);
|
|
assert.equal(seat(done, p.id).reserves, p.reserves);
|
|
}
|
|
return done;
|
|
}
|
|
|
|
for (const advanced of [false, true])
|
|
void test(`actual ${advanced ? 'Advanced' : 'Basic'} Mentat awards three jointly held strongholds to both Ecaz allies`, () => {
|
|
const g = joint(fixture('emperor', ['beneGesserit'], advanced));
|
|
const row = strongholdProgress(g).progress.find((p) => p.player === 'ec')!;
|
|
assert.equal(row.target, 4);
|
|
assert.equal(row.occupyTarget, 3);
|
|
assert.deepEqual(new Set(row.jointlyOccupied), new Set(THREE));
|
|
assert.equal(row.qualifies, true);
|
|
assert.deepEqual(
|
|
mentat(g).players.map((p) => p.hand.length),
|
|
[0, 0, 0],
|
|
);
|
|
verdict(g, ['ec', 'al']);
|
|
});
|
|
|
|
void test('a two-and-one distribution cannot win at three; ordinary four still wins without three joint locations', () => {
|
|
const g = fixture();
|
|
occupy(g, 'ec', ['arrakeen', 'carthag']);
|
|
occupy(g, 'al', ['sietch_tabr']);
|
|
verdict(g, []);
|
|
occupy(g, 'al', ['sietch_tabr', 'tueks_sietch']);
|
|
const row = strongholdProgress(g).progress.find((p) => p.player === 'al')!;
|
|
assert.deepEqual(row.jointlyOccupied, []);
|
|
assert.equal(row.strongholds.length, 4);
|
|
verdict(g, ['ec', 'al']);
|
|
});
|
|
|
|
void test('two jointly held locations plus all Tech Tokens is not a third physical joint location', () => {
|
|
const g = joint(fixture(), THREE.slice(0, 2));
|
|
g.techTokens = createTechTokens();
|
|
for (const token of Object.values(g.techTokens)) token.owner = 'al';
|
|
const row = strongholdProgress(g).progress.find((p) => p.player === 'ec')!;
|
|
assert.equal(row.techStronghold, true);
|
|
assert.equal(row.jointlyOccupied.length, 2);
|
|
assert.equal(row.qualifies, false);
|
|
verdict(g, []);
|
|
occupy(g, 'ec', THREE);
|
|
verdict(g, ['ec', 'al']); // Three ordinary locations plus one owned bonus.
|
|
g.techTokens.production.owner = 'ec';
|
|
assert.equal(
|
|
strongholdProgress(g).progress.find((p) => p.player === 'ec')!
|
|
.techStronghold,
|
|
false,
|
|
);
|
|
verdict(g, []);
|
|
});
|
|
|
|
void test('storm order changes presentation order, not which Ecaz alliance qualifies', () => {
|
|
const g = joint(fixture());
|
|
g.order = ['al', 'x0', 'ec'];
|
|
verdict(g, ['al', 'ec']);
|
|
});
|
|
|
|
void test('correct BG prediction overrides either allied faction of an Ecaz joint-three winner only on its chosen turn', () => {
|
|
for (const faction of ['ecaz', 'emperor'] as const) {
|
|
const g = joint(fixture());
|
|
seat(g, 'x0').prediction = { faction, turn: 2 };
|
|
const publicBefore = strongholdProgress(g);
|
|
verdict(g, ['x0']);
|
|
seat(g, 'x0').prediction!.turn = 3;
|
|
assert.deepEqual(strongholdProgress(g), publicBefore);
|
|
verdict(g, ['ec', 'al']);
|
|
}
|
|
});
|
|
|
|
void test('joint stronghold occupied solely by the pair counts under storm; opposing fighters remove it', () => {
|
|
const g = joint(fixture('emperor', ['harkonnen']));
|
|
g.storm = 10;
|
|
verdict(g, ['ec', 'al']);
|
|
occupy(g, 'x0', ['arrakeen']);
|
|
assert.equal(
|
|
strongholdProgress(g).progress.find((p) => p.player === 'ec')!
|
|
.jointlyOccupied.length,
|
|
2,
|
|
);
|
|
verdict(g, []);
|
|
});
|
|
|
|
void test('an allied BG advisor is not the second fighter, and unrelated BG advisors do not contest a joint win', () => {
|
|
const g = joint(fixture('beneGesserit', ['emperor']));
|
|
seat(g, 'al').advisors = { sietch_tabr: {} };
|
|
assert.equal(strongholdProgress(g).released.length, 0);
|
|
verdict(g, []);
|
|
delete seat(g, 'al').advisors;
|
|
verdict(g, ['ec', 'al']);
|
|
const other = joint(fixture());
|
|
occupy(other, 'x0', THREE);
|
|
seat(other, 'x0').advisors = {
|
|
arrakeen: {},
|
|
carthag: {},
|
|
sietch_tabr: {},
|
|
};
|
|
verdict(other, ['ec', 'al']);
|
|
});
|
|
|
|
void test('lone BG advisor release feeds ordinary progress but cannot manufacture a second joint occupier', () => {
|
|
const g = fixture('beneGesserit', ['emperor']);
|
|
occupy(g, 'ec', ['arrakeen', 'carthag']);
|
|
occupy(g, 'al', ['sietch_tabr', 'tueks_sietch']);
|
|
seat(g, 'al').advisors = { sietch_tabr: {}, tueks_sietch: {} };
|
|
const progress = strongholdProgress(g);
|
|
assert.equal(progress.released.length, 2);
|
|
assert.deepEqual(
|
|
progress.progress.find((p) => p.player === 'ec')!.jointlyOccupied,
|
|
[],
|
|
);
|
|
const done = verdict(g, ['ec', 'al']);
|
|
assert.equal(seat(done, 'al').advisors?.sietch_tabr, undefined);
|
|
});
|
|
|
|
void test('all three concealed No-Field values provide identical public joint occupation without revelation', () => {
|
|
let publicResult: ReturnType<typeof strongholdProgress> | undefined;
|
|
for (let index = 0; index < 3; index++) {
|
|
const g = joint(fixture('richese'));
|
|
occupy(g, 'al', THREE.slice(0, 2));
|
|
const ids = ['field-zero', 'field-three', 'field-five'];
|
|
seat(g, 'al').noField = deployRicheseNoField(createRicheseNoField(ids), {
|
|
tokenId: ids[index],
|
|
controller: 'al',
|
|
location: { territory: 'sietch_tabr', sector: 14 },
|
|
});
|
|
const progress = strongholdProgress(g);
|
|
if (publicResult) assert.deepEqual(progress, publicResult);
|
|
publicResult = progress;
|
|
const done = verdict(g, ['ec', 'al']);
|
|
assert.deepEqual(seat(done, 'al').noField, seat(g, 'al').noField);
|
|
const other = viewGame(done, 'ec').players.find((p) => p.id === 'al')!;
|
|
assert.equal(JSON.stringify(other.noField).includes(ids[index]), false);
|
|
}
|
|
});
|
|
|
|
void test('a placed HMS can be the third actual jointly held location and final cards remain controlled by Ecaz', () => {
|
|
const g = joint(fixture('ixians'), THREE.slice(0, 2));
|
|
g.mobileStronghold = { location: 'red_chasm:7' };
|
|
for (const id of ['ec', 'al']) {
|
|
seat(g, id).forces['hidden_mobile_stronghold:0'] = 1;
|
|
seat(g, id).reserves--;
|
|
}
|
|
g.strongholdCards = createStrongholdCards();
|
|
const done = verdict(g, ['ec', 'al']);
|
|
assert.equal(done.strongholdCards!.owners.arrakeen, 'ec');
|
|
assert.equal(done.strongholdCards!.owners.hidden_mobile_stronghold, 'ec');
|
|
assert.equal(done.strongholdCards!.claimedTurn, 2);
|
|
});
|
|
|
|
void test('public progress never reads private prediction, cards, balances or No-Field token inventory', () => {
|
|
const g = joint(fixture('richese'));
|
|
const expected = strongholdProgress(g);
|
|
for (const p of g.players)
|
|
for (const key of ['hand', 'spice', 'prediction'])
|
|
Object.defineProperty(p, key, {
|
|
get() {
|
|
throw Error(`Private ${key} read`);
|
|
},
|
|
});
|
|
assert.deepEqual(strongholdProgress(g), expected);
|
|
const tokens = createRicheseNoField(['n0', 'n3', 'n5']);
|
|
seat(g, 'al').noField = tokens;
|
|
Object.defineProperty(tokens, 'tokens', {
|
|
get() {
|
|
throw Error('Private No-Field inventory read');
|
|
},
|
|
});
|
|
assert.deepEqual(strongholdProgress(g), expected);
|
|
});
|
|
|
|
for (const converted of [false, true])
|
|
void test(`${converted ? 'BG-converted' : 'printed'} cancellation of genuine Moritani placement preserves the Ecaz joint victory`, () => {
|
|
let g = joint(fixture('emperor', ['moritani', 'beneGesserit']));
|
|
g.moritaniTerror = createTerrorState(() => 0.4);
|
|
const actor = converted ? 'x1' : 'al';
|
|
const index = g.deck.findIndex(
|
|
(c) => c.name === (converted ? 'Baliset' : 'Karama'),
|
|
);
|
|
assert.ok(index >= 0);
|
|
const card = g.deck.splice(index, 1)[0];
|
|
seat(g, actor).hand.push(card);
|
|
if (converted) {
|
|
// A real remaining blocker keeps the conversion open for JSON recovery.
|
|
const blocker = g.deck.findIndex((c) => c.name === 'Karama');
|
|
assert.ok(blocker >= 0);
|
|
seat(g, 'ec').hand.push(g.deck.splice(blocker, 1)[0]);
|
|
}
|
|
g = mentat(g);
|
|
assert.equal(g.status, 'playing');
|
|
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
|
const token = g.moritaniTerror!.tokens[0].id;
|
|
g = applyAction(g, 'x0', {
|
|
type: 'decision',
|
|
token,
|
|
territory: 'tueks_sietch',
|
|
});
|
|
assert.equal(g.response?.kind, 'moritaniPlacement');
|
|
const source = json(g);
|
|
g = applyAction(g, actor, { type: 'card', card: card.id, mode: 'cancel' });
|
|
if (converted) assert.equal(g.response?.kind, 'worthlessKarama');
|
|
for (let n = 0; g.response && n < 20; n++) {
|
|
const responder = g.players.find(
|
|
(p) => !g.response!.passed.includes(p.id),
|
|
)!;
|
|
g = applyAction(json(g), responder.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(g.response, null);
|
|
assert.deepEqual(g.winner, ['ec', 'al']);
|
|
assert.equal(g.status, 'finished');
|
|
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
|
|
assert.deepEqual(g.moritaniTerror!.tokens, source.moritaniTerror!.tokens);
|
|
assert.equal(g.moritaniTerror!.placementTurn, 2);
|
|
for (const id of ['ec', 'al'])
|
|
assert.deepEqual(seat(g, id).forces, seat(source, id).forces);
|
|
});
|
|
|
|
for (const level of ['Hard', 'Brutal'] as const)
|
|
void test(`${level} prioritizes a legal shipment completing the third joint stronghold`, () => {
|
|
const g = joint(
|
|
fixture('emperor', ['harkonnen'], false),
|
|
THREE.slice(0, 2),
|
|
);
|
|
occupy(g, 'al', THREE);
|
|
Object.assign(g, {
|
|
phase: 5,
|
|
active: 'ec',
|
|
movementRemaining: [...g.order],
|
|
});
|
|
const view = viewGame(g, 'ec');
|
|
view.players.find((p) => p.id === 'ec')!.bot = level;
|
|
const before = structuredClone(view);
|
|
const actions = botActions(view);
|
|
assert.deepEqual(view, before);
|
|
const action = actions.find((a) => a.type === 'ship');
|
|
assert.ok(action);
|
|
assert.equal(action.territory, 'sietch_tabr');
|
|
const moved = applyAction(g, 'ec', action);
|
|
assert.equal(moved.phase, 5);
|
|
assert.equal(moved.status, 'playing');
|
|
assert.equal(
|
|
strongholdProgress(moved).progress.find((p) => p.player === 'ec')!
|
|
.jointlyOccupied.length,
|
|
3,
|
|
);
|
|
});
|
|
|
|
void test('Brutal Guild takes its real timing opportunity against a joint-two threat, and waits when that public threat is absent', () => {
|
|
for (const threatened of [false, true]) {
|
|
let g = joint(
|
|
fixture('emperor', ['guild']),
|
|
threatened ? THREE.slice(0, 2) : THREE.slice(0, 1),
|
|
);
|
|
g.phase = 4;
|
|
g.ecazAmbassadors = createAmbassadors(() => 0.4);
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.decision?.kind, 'ecazPlacement');
|
|
g = applyAction(g, 'ec', { type: 'decision', decline: true });
|
|
assert.equal(g.phase, 5);
|
|
assert.equal(g.decision?.kind, 'guildTiming');
|
|
const view = viewGame(g, 'x0');
|
|
view.players.find((p) => p.id === 'x0')!.bot = 'Brutal';
|
|
const action = botActions(view)[0];
|
|
assert.equal(action.type, 'decision');
|
|
assert.equal(action.take, threatened);
|
|
const done = applyAction(g, 'x0', action);
|
|
assert.equal(done.active, threatened ? 'x0' : 'ec');
|
|
assert.deepEqual(done.order, g.order);
|
|
}
|
|
});
|