427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { quoteAdvisorBattleOffer } from '../game/movement-phase-quote';
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
|
|
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
function army(g: Game, id: string, forces: Record<string, number>) {
|
|
const p = player(g, id);
|
|
p.forces = forces;
|
|
p.reserves = 20 - p.tanks - Object.values(forces).reduce((n, x) => n + x, 0);
|
|
}
|
|
function fixture(actor = 'ec') {
|
|
const g = createGame('OCCUPYMOVE', newPlayer('ec', 'Ecaz', 'ecaz'), false, [
|
|
'ecaz',
|
|
]);
|
|
g.players.push(
|
|
newPlayer('al', 'Ally', 'emperor'),
|
|
newPlayer('en', 'Enemy', 'harkonnen'),
|
|
newPlayer('x', 'Later', 'ixians'),
|
|
);
|
|
const partner = actor === 'ec' ? 'al' : 'ec';
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: actor,
|
|
order: [partner, actor, 'en', 'x'],
|
|
movementRemaining: [actor, 'en', 'x'],
|
|
deck: baseDeck(),
|
|
});
|
|
for (const p of g.players)
|
|
Object.assign(p, {
|
|
hand: [],
|
|
forces: {},
|
|
elites: undefined,
|
|
reserves: 20,
|
|
tanks: 0,
|
|
spice: 10,
|
|
traitors: [],
|
|
traitorChoices: [],
|
|
shipped: false,
|
|
moved: 0,
|
|
});
|
|
player(g, 'ec').ally = 'al';
|
|
player(g, 'al').ally = 'ec';
|
|
player(g, 'ec').allySinceTurn = player(g, 'al').allySinceTurn = 1;
|
|
return g;
|
|
}
|
|
function conserved(g: Game) {
|
|
for (const p of g.players)
|
|
assert.equal(
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((n, x) => n + x, 0),
|
|
20,
|
|
);
|
|
const cards = [
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((p) => p.hand),
|
|
].map((c) => c.id);
|
|
assert.equal(new Set(cards).size, cards.length);
|
|
}
|
|
function hold(g: Game, id: string, name: string) {
|
|
const at = g.deck.findIndex((c) => c.name === name);
|
|
assert.ok(at >= 0, name);
|
|
const c = g.deck.splice(at, 1)[0];
|
|
player(g, id).hand.push(c);
|
|
return c.id;
|
|
}
|
|
const destination = { territory: 'arrakeen', sector: 10 };
|
|
void test('BG can prepare established advisors beside Ecaz before movement, while ordinary allied and current-turn locked groups remain excluded', () => {
|
|
const g = fixture('al');
|
|
g.advanced = true;
|
|
player(g, 'al').faction = 'beneGesserit';
|
|
for (const id of ['ec', 'al', 'en']) army(g, id, { 'arrakeen:10': 1 });
|
|
player(g, 'al').advisors = { arrakeen: {} };
|
|
const before = structuredClone(g);
|
|
const offer = quoteAdvisorBattleOffer(g);
|
|
assert.deepEqual(g, before);
|
|
assert.deepEqual(offer.territories, ['arrakeen']);
|
|
g.active = null;
|
|
g.decision = {
|
|
kind: 'advisorBattle',
|
|
player: 'al',
|
|
territories: offer.territories,
|
|
};
|
|
const done = applyAction(g, 'al', {
|
|
type: 'decision',
|
|
accept: true,
|
|
territory: 'arrakeen',
|
|
});
|
|
assert.equal(player(done, 'al').advisors?.arrakeen, undefined);
|
|
assert.equal(player(done, 'al').forces['arrakeen:10'], 1);
|
|
assert.equal(done.phase, 5);
|
|
conserved(done);
|
|
const locked = structuredClone(before);
|
|
player(locked, 'al').advisors!.arrakeen.lockedTurn = locked.turn;
|
|
assert.deepEqual(quoteAdvisorBattleOffer(locked).territories, []);
|
|
const ordinary = structuredClone(before);
|
|
player(ordinary, 'ec').faction = 'atreides';
|
|
assert.deepEqual(quoteAdvisorBattleOffer(ordinary).territories, []);
|
|
});
|
|
for (const actor of ['ec', 'al']) {
|
|
const partner = actor === 'ec' ? 'al' : 'ec';
|
|
void test(`${actor} ships and moves into its partner plus an enemy as two occupying factions`, () => {
|
|
const initial = fixture(actor);
|
|
army(initial, partner, { 'arrakeen:10': 2 });
|
|
army(initial, 'en', { 'arrakeen:10': 1 });
|
|
army(initial, actor, { 'imperial_basin:10': 2 });
|
|
const before = structuredClone(initial);
|
|
let g = applyAction(initial, actor, {
|
|
type: 'ship',
|
|
amount: 2,
|
|
...destination,
|
|
});
|
|
assert.deepEqual(initial, before);
|
|
assert.equal(player(g, actor).spice, 8);
|
|
assert.equal(player(g, actor).reserves, 16);
|
|
g = applyAction(JSON.parse(JSON.stringify(g)) as Game, actor, {
|
|
type: 'move',
|
|
forces: { 'imperial_basin:10': 2 },
|
|
...destination,
|
|
});
|
|
assert.equal(player(g, actor).forces['arrakeen:10'], 4);
|
|
assert.equal(player(g, partner).forces['arrakeen:10'], 2);
|
|
assert.equal(player(g, 'en').forces['arrakeen:10'], 1);
|
|
assert.equal(player(g, actor).moved, 1);
|
|
assert.equal(g.phase, 5);
|
|
assert.equal(g.battle, null);
|
|
conserved(g);
|
|
});
|
|
void test(`${actor} finishes its turn without destroying an older Ecaz coalition or starting combat`, () => {
|
|
const g = fixture(actor);
|
|
army(g, actor, { 'arrakeen:10': 2 });
|
|
army(g, partner, { 'arrakeen:10': 3 });
|
|
const done = applyAction(g, actor, { type: 'endMovement' });
|
|
assert.deepEqual(player(done, actor).forces, player(g, actor).forces);
|
|
assert.deepEqual(player(done, partner).forces, player(g, partner).forces);
|
|
assert.equal(player(done, actor).tanks, 0);
|
|
assert.equal(done.active, 'en');
|
|
assert.equal(done.phase, 5);
|
|
assert.deepEqual(done.movementRemaining, ['en', 'x']);
|
|
conserved(done);
|
|
});
|
|
}
|
|
void test('ordinary non-Ecaz allies permit reserve arrivals with Basic visitor and Advanced whole-group departure obligations', () => {
|
|
for (const advanced of [false, true])
|
|
for (const baseline of [0, 2]) {
|
|
const g = fixture();
|
|
g.advanced = advanced;
|
|
player(g, 'ec').faction = 'atreides';
|
|
army(g, 'al', { 'arrakeen:10': 1 });
|
|
if (baseline) {
|
|
army(g, 'ec', { 'arrakeen:10': baseline });
|
|
// Keep the ally's turn pending to distinguish Basic visitors from the
|
|
// separate legacy loss of an older group after both allies have ended.
|
|
g.movementRemaining = ['ec', 'al', 'en', 'x'];
|
|
}
|
|
const before = structuredClone(g);
|
|
const arrived = applyAction(g, 'ec', {
|
|
type: 'ship',
|
|
amount: 1,
|
|
...destination,
|
|
});
|
|
assert.deepEqual(g, before);
|
|
assert.equal(player(arrived, 'ec').forces['arrakeen:10'], baseline + 1);
|
|
assert.equal(player(arrived, 'ec').reserves, 19 - baseline);
|
|
assert.equal(player(arrived, 'ec').spice, 9);
|
|
assert.equal(player(arrived, 'ec').shipped, true);
|
|
assert.equal(player(arrived, 'ec').moved, 0);
|
|
assert.equal(player(arrived, 'ec').tanks, 0);
|
|
assert.deepEqual(player(arrived, 'al'), player(g, 'al'));
|
|
assert.deepEqual(arrived.deck, g.deck);
|
|
assert.deepEqual(arrived.discard, g.discard);
|
|
assert.deepEqual(arrived.players.map((p) => p.hand), g.players.map((p) => p.hand));
|
|
const view = viewGame(arrived, 'ec');
|
|
assert.deepEqual(view.basicAlliedShipment, advanced ? null : {
|
|
territory: 'Arrakeen', normal: 1, elite: 0,
|
|
});
|
|
assert.deepEqual(view.advancedAllySeparation, advanced ? {
|
|
territories: ['Arrakeen'],
|
|
} : null);
|
|
conserved(arrived);
|
|
const restored = JSON.parse(JSON.stringify(arrived)) as Game;
|
|
const done = applyAction(restored, 'ec', { type: 'endMovement' });
|
|
assert.deepEqual(player(done, 'ec').forces,
|
|
!advanced && baseline ? { 'arrakeen:10': baseline } : {});
|
|
assert.equal(player(done, 'ec').tanks, advanced ? baseline + 1 : 1);
|
|
assert.equal(player(done, 'ec').reserves, 19 - baseline);
|
|
assert.equal(player(done, 'ec').spice, 9);
|
|
assert.equal(player(done, 'ec').shipped, true);
|
|
assert.equal(player(done, 'ec').moved, 0);
|
|
assert.deepEqual(player(done, 'al'), player(restored, 'al'));
|
|
assert.deepEqual(done.deck, arrived.deck);
|
|
assert.deepEqual(done.discard, arrived.discard);
|
|
assert.deepEqual(done.players.map((p) => p.hand), arrived.players.map((p) => p.hand));
|
|
if (!advanced) {
|
|
assert.equal(done.basicAlliedShipment, null);
|
|
assert.ok(done.log.some((entry) =>
|
|
/1 new allied-shipment visitors.*provisional Basic no-departure policy, not a publisher ruling/.test(entry.text)));
|
|
}
|
|
assert.equal(done.active, baseline ? 'al' : 'en');
|
|
assert.deepEqual(done.movementRemaining, baseline ? ['al', 'en', 'x'] : ['en', 'x']);
|
|
assert.equal(done.phase, 5);
|
|
assert.equal(done.battle, null);
|
|
conserved(done);
|
|
}
|
|
});
|
|
void test('a third unrelated side is rejected for shipment and movement before any payment or force change', () => {
|
|
const g = fixture();
|
|
for (const id of ['ec', 'al', 'en']) army(g, id, { 'arrakeen:10': 1 });
|
|
army(g, 'x', { 'imperial_basin:10': 1 });
|
|
g.active = 'x';
|
|
g.order = ['ec', 'al', 'x', 'en'];
|
|
g.movementRemaining = ['x', 'en'];
|
|
const before = structuredClone(g);
|
|
for (const action of [
|
|
{ type: 'ship', amount: 1, ...destination },
|
|
{ type: 'move', forces: { 'imperial_basin:10': 1 }, ...destination },
|
|
])
|
|
assert.throws(
|
|
() => applyAction(g, 'x', action),
|
|
/stronghold|occupying factions/,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('an unrelated player can challenge the Ecaz pair as the second side through a formerly blocked stronghold path', () => {
|
|
const g = fixture();
|
|
army(g, 'ec', { 'arrakeen:10': 1 });
|
|
army(g, 'al', { 'arrakeen:10': 1 });
|
|
army(g, 'en', { 'imperial_basin:10': 2 });
|
|
g.active = 'en';
|
|
g.movementRemaining = ['en', 'x'];
|
|
const done = applyAction(g, 'en', {
|
|
type: 'move',
|
|
forces: { 'imperial_basin:10': 2 },
|
|
...destination,
|
|
});
|
|
assert.equal(player(done, 'en').forces['arrakeen:10'], 2);
|
|
assert.equal(done.phase, 5);
|
|
conserved(done);
|
|
});
|
|
void test('advisors enter without creating another occupying side while both fighter sides remain', () => {
|
|
const g = fixture();
|
|
g.advanced = true;
|
|
player(g, 'x').faction = 'beneGesserit';
|
|
for (const id of ['ec', 'al']) army(g, id, { 'arrakeen:10': 1 });
|
|
army(g, 'en', { 'arrakeen:10': 1, 'imperial_basin:10': 1 });
|
|
army(g, 'x', { 'imperial_basin:10': 1, 'arrakeen:10': 1 });
|
|
player(g, 'x').advisors = { imperial_basin: {}, arrakeen: {} };
|
|
g.active = 'x';
|
|
g.order = ['ec', 'al', 'x', 'en'];
|
|
g.movementRemaining = ['x', 'en'];
|
|
const done = applyAction(g, 'x', {
|
|
type: 'move',
|
|
forces: { 'imperial_basin:10': 1 },
|
|
...destination,
|
|
});
|
|
assert.equal(player(done, 'x').forces['arrakeen:10'], 2);
|
|
assert.ok(player(done, 'x').advisors?.arrakeen);
|
|
assert.equal(player(done, 'x').advisors?.arrakeen.lockedTurn, undefined);
|
|
assert.equal(done.phase, 5);
|
|
conserved(done);
|
|
});
|
|
for (const value of [0, 3, 5] as const)
|
|
void test(`a concealed ${value} No-Field counts as Ecaz's allied presence without revealing or spending its reserve forces`, () => {
|
|
const g = fixture();
|
|
player(g, 'al').faction = 'richese';
|
|
const state = createRicheseNoField([
|
|
'marker-zero',
|
|
'marker-three',
|
|
'marker-five',
|
|
]);
|
|
const token = state.tokens.find((t) => t.value === value)!;
|
|
player(g, 'al').noField = deployRicheseNoField(state, {
|
|
tokenId: token.id,
|
|
controller: 'al',
|
|
location: destination,
|
|
});
|
|
player(g, 'al').noFieldEvent = 'private-marker-source';
|
|
// This is the movement after Ecaz's ordinary shipment; no new allied
|
|
// No-Field offer is due before that already-spent shipment.
|
|
player(g, 'ec').shipped = true;
|
|
army(g, 'en', { 'arrakeen:10': 1 });
|
|
army(g, 'ec', { 'imperial_basin:10': 1 });
|
|
const before = structuredClone(player(g, 'al').noField);
|
|
const done = applyAction(g, 'ec', {
|
|
type: 'move',
|
|
forces: { 'imperial_basin:10': 1 },
|
|
...destination,
|
|
});
|
|
assert.deepEqual(player(done, 'al').noField, before);
|
|
assert.equal(player(done, 'al').reserves, 20);
|
|
assert.deepEqual(player(done, 'al').forces, {});
|
|
assert.equal(player(done, 'ec').forces['arrakeen:10'], 1);
|
|
assert.equal(done.phase, 5);
|
|
conserved(done);
|
|
});
|
|
for (const level of DIFFICULTIES)
|
|
void test(`${level} enumerates legal coalition destinations and retains atomic retries after a rejected third side`, () => {
|
|
const g = fixture();
|
|
army(g, 'al', { 'arrakeen:10': 1 });
|
|
army(g, 'en', { 'arrakeen:10': 1 });
|
|
army(g, 'ec', { 'imperial_basin:10': 3 });
|
|
const v = viewGame(g, 'ec');
|
|
v.players.find((p) => p.id === 'ec')!.bot = level;
|
|
const before = structuredClone(v);
|
|
const actions = botActions(v);
|
|
assert.deepEqual(v, before);
|
|
const move = actions.find(
|
|
(a) => a.type === 'move' && a.territory === 'arrakeen',
|
|
);
|
|
assert.ok(
|
|
move,
|
|
'co-occupied targets survive legality filtering and candidate truncation',
|
|
);
|
|
for (const a of actions.filter((a) => ['move', 'ship'].includes(a.type)))
|
|
assert.doesNotThrow(() => applyAction(g, 'ec', a));
|
|
const blocked = structuredClone(g);
|
|
// A later current position has two independent defenders after the
|
|
// alliance ended. Keep the fortress itself valid; Ecaz would be third.
|
|
player(blocked, 'ec').ally = null;
|
|
player(blocked, 'al').ally = null;
|
|
const snapshot = structuredClone(blocked);
|
|
assert.throws(() => applyAction(blocked, 'ec', move));
|
|
assert.deepEqual(blocked, snapshot);
|
|
const retry = viewGame(blocked, 'ec');
|
|
retry.players.find((p) => p.id === 'ec')!.bot = level;
|
|
const alternatives = botActions(retry).filter((a) =>
|
|
['move', 'ship'].includes(a.type),
|
|
);
|
|
assert.ok(alternatives.length);
|
|
assert.ok(alternatives.every((a) => a.territory !== 'arrakeen'));
|
|
for (const a of alternatives)
|
|
assert.doesNotThrow(() => applyAction(blocked, 'ec', a));
|
|
});
|
|
|
|
void test('BG may request fighters beside its Ecaz ally, but an accompaniment lock still prevents that request', () => {
|
|
const g = fixture('al');
|
|
g.advanced = true;
|
|
player(g, 'al').faction = 'beneGesserit';
|
|
army(g, 'al', { 'imperial_basin:10': 1 });
|
|
army(g, 'ec', { 'imperial_basin:10': 1, 'arrakeen:10': 1 });
|
|
army(g, 'en', { 'arrakeen:10': 1 });
|
|
player(g, 'al').advisors = { imperial_basin: {} };
|
|
const action = {
|
|
type: 'move',
|
|
forces: { 'imperial_basin:10': 1 },
|
|
...destination,
|
|
fighters: true,
|
|
};
|
|
const done = applyAction(g, 'al', action);
|
|
assert.equal(player(done, 'al').forces['arrakeen:10'], 1);
|
|
assert.equal(player(done, 'al').advisors?.arrakeen, undefined);
|
|
assert.equal(done.phase, 5);
|
|
conserved(done);
|
|
const locked = structuredClone(g);
|
|
player(locked, 'al').advisors!.imperial_basin.lockedTurn = g.turn;
|
|
const before = structuredClone(locked);
|
|
assert.throws(
|
|
() => applyAction(locked, 'al', action),
|
|
/advisor|flip|turn|fighter/i,
|
|
);
|
|
assert.deepEqual(locked, before);
|
|
});
|
|
|
|
for (const form of ['printed', 'worthless'] as const)
|
|
void test(`${form} cancellation of a real Baliset declaration resumes movement into the Ecaz ally plus CHOAM`, () => {
|
|
let g = fixture();
|
|
g.advanced = true;
|
|
player(g, 'en').faction = 'choam';
|
|
player(g, 'x').faction = 'beneGesserit';
|
|
army(g, 'x', { 'polar_sink:0': 20 });
|
|
army(g, 'ec', { 'imperial_basin:10': 2 });
|
|
army(g, 'al', { 'arrakeen:10': 1 });
|
|
army(g, 'en', { 'arrakeen:10': 1 });
|
|
const baliset = hold(g, 'en', 'Baliset');
|
|
const canceler = form === 'printed' ? 'ec' : 'x';
|
|
const card = hold(
|
|
g,
|
|
canceler,
|
|
form === 'printed' ? 'Karama' : 'Jubba Cloak',
|
|
);
|
|
g = applyAction(g, 'ec', {
|
|
type: 'move',
|
|
forces: { 'imperial_basin:10': 2 },
|
|
...destination,
|
|
});
|
|
assert.equal(g.decision?.kind, 'choamMovement');
|
|
g = applyAction(g, 'en', {
|
|
type: 'card',
|
|
card: baliset,
|
|
mode: 'choam',
|
|
target: 'ec',
|
|
territory: 'arrakeen',
|
|
});
|
|
assert.equal(g.response?.kind, 'choamWorthless');
|
|
g = applyAction(JSON.parse(JSON.stringify(g)) as Game, canceler, {
|
|
type: 'card',
|
|
card,
|
|
mode: 'cancel',
|
|
});
|
|
assert.equal(g.pendingChoamMove, null);
|
|
assert.equal(player(g, 'ec').forces['arrakeen:10'], 2);
|
|
assert.equal(player(g, 'ec').moved, 1);
|
|
assert.equal(player(g, 'al').forces['arrakeen:10'], 1);
|
|
assert.equal(player(g, 'en').forces['arrakeen:10'], 1);
|
|
assert.ok(player(g, 'en').hand.some((c) => c.id === baliset));
|
|
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
|
|
assert.equal(g.phase, 5);
|
|
assert.equal(g.active, 'ec');
|
|
conserved(g);
|
|
});
|