Files
Dune/tests/ecaz-placement.test.ts
2026-09-08 07:37:52 +10:00

293 lines
10 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { createAmbassadors } from '../game/ecaz-ambassadors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { TERRITORIES, MOBILE_STRONGHOLD } from '../game/board';
function fixture(choam = false): Game {
const g = createGame('ECAZPLACE', newPlayer('e', 'Ecaz', 'ecaz'));
g.players.push(newPlayer('other', 'Other', choam ? 'choam' : 'emperor'));
g.status = 'playing';
g.phase = 4;
g.turn = 3;
g.storm = 18;
g.order = ['e', 'other'];
g.deck = baseDeck();
g.ecazAmbassadors = createAmbassadors(() => 0);
for (const player of g.players) {
player.spice = 10;
player.hand = [];
player.forces = {};
}
return g;
}
function ready(state = fixture()) {
let g = state;
for (const player of g.players)
g = applyAction(g, player.id, { type: 'ready' });
return g;
}
function allow(state: Game) {
let g = state;
for (let step = 0; g.response && step < 20; step++) {
const player = g.players.find((p) => !g.response!.passed.includes(p.id));
assert.ok(player);
g = applyAction(g, player.id, { type: 'passResponse' });
}
assert.equal(g.response, null);
return g;
}
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const supplied = (g: Game) =>
g.ecazAmbassadors!.tokens.filter((token) => token.zone === 'supply');
function declare(g: Game, token = supplied(g)[0].id, territory = 'arrakeen') {
return applyAction(g, 'e', { type: 'decision', token, territory });
}
function rejected(g: Game, player: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, player, action));
assert.deepEqual(g, before);
}
void test('all factions ready opens Ecaz at end of Revival; placements charge one then two only after all responses', () => {
const initial = fixture();
initial.players[1].hand.push(
initial.deck.splice(
initial.deck.findIndex((c) => c.effect === 'karama'),
1,
)[0],
);
const firstReady = applyAction(initial, 'e', { type: 'ready' });
assert.equal(firstReady.decision, null);
let g = applyAction(firstReady, 'other', { type: 'ready' });
assert.equal(g.phase, 4);
assert.deepEqual(g.decision, { kind: 'ecazPlacement', player: 'e' });
assert.equal(viewGame(g, 'e').ambassadors!.nextCost, 1);
for (const [index, territory] of ['arrakeen', 'carthag'].entries()) {
const token = supplied(g)[0].id;
const beforeSpice = g.players[0].spice;
const beforeTokens = structuredClone(g.ecazAmbassadors);
g = declare(g, token, territory);
assert.equal(g.response?.kind, 'ecazPlacement');
assert.equal(g.pendingEcazPlacement?.cost, index + 1);
assert.equal(g.players[0].spice, beforeSpice);
assert.deepEqual(g.ecazAmbassadors, beforeTokens);
g = applyAction(g, 'e', { type: 'passResponse' });
assert.equal(g.players[0].spice, beforeSpice);
g = applyAction(g, 'other', { type: 'passResponse' });
assert.equal(g.players[0].spice, beforeSpice - (index + 1));
assert.equal(
g.ecazAmbassadors!.tokens.find((t) => t.id === token)?.location,
territory,
);
assert.equal(g.decision?.kind, 'ecazPlacement');
assert.equal(viewGame(g, 'e').ambassadors!.nextCost, index + 2);
}
g = applyAction(g, 'e', { type: 'decision', decline: true });
assert.equal(g.phase, 5);
assert.equal(g.ecazPlacementTurn, 3);
assert.equal(g.players[0].spice, 7);
assert.equal(
g.ecazAmbassadors!.tokens.filter((t) => t.zone === 'placed').length,
2,
);
});
void test('placement rejects wrong owner, phase, completed opportunity, invalid target and unavailable token atomically', () => {
const g = ready();
const token = supplied(g)[0].id;
const action: Action = { type: 'decision', token, territory: 'arrakeen' };
rejected(g, 'other', action);
for (const territory of ['imperial_basin', MOBILE_STRONGHOLD, 'missing'])
rejected(g, 'e', { ...action, territory });
for (const token of [
'missing',
g.ecazAmbassadors!.tokens.find((t) => t.zone === 'pool')!.id,
])
rejected(g, 'e', { ...action, token });
for (const field of ['phase', 'completed', 'spice', 'storm'] as const) {
const changed = structuredClone(g);
if (field === 'phase') changed.phase = 5;
if (field === 'completed') changed.ecazPlacementTurn = changed.turn;
if (field === 'spice') changed.players[0].spice = 0;
if (field === 'storm')
changed.storm = TERRITORIES.find((t) => t.id === 'arrakeen')!.sectors[0];
rejected(changed, 'e', action);
}
const placed = allow(declare(g));
rejected(placed, 'e', { ...action, territory: 'carthag' });
rejected(placed, 'e', { ...action, token: supplied(placed)[0].id });
});
void test('JSON response restoration charges exactly once and rejects stale response context', () => {
const initial = fixture();
initial.players[1].hand.push(
initial.deck.splice(
initial.deck.findIndex((c) => c.effect === 'karama'),
1,
)[0],
);
let pending = declare(ready(initial));
pending = applyAction(pending, 'e', { type: 'passResponse' });
const after = applyAction(reload(pending), 'other', { type: 'passResponse' });
assert.equal(after.players[0].spice, 9);
assert.equal(after.ecazAmbassadors!.placement?.count, 1);
assert.equal(after.pendingEcazPlacement, null);
rejected(after, 'other', { type: 'passResponse' });
for (const field of ['turn', 'phase', 'price', 'completed'] as const) {
const stale = reload(pending);
if (field === 'turn') stale.turn++;
if (field === 'phase') stale.phase = 5;
if (field === 'price') stale.pendingEcazPlacement!.cost = 99;
if (field === 'completed') stale.ecazPlacementTurn = stale.turn;
rejected(stale, 'other', { type: 'passResponse' });
}
});
void test('Karama stops remaining placements without paying the canceled placement or refunding earlier ones', () => {
let g = allow(declare(ready()));
const first = g.ecazAmbassadors!.tokens.find(
(token) => token.zone === 'placed',
)!;
const next = supplied(g)[0].id;
const karama = g.deck.splice(
g.deck.findIndex((card) => card.effect === 'karama'),
1,
)[0];
g.players[1].hand.push(karama);
g = declare(g, next, 'carthag');
g = applyAction(reload(g), 'other', {
type: 'card',
card: karama.id,
mode: 'cancel',
});
assert.equal(g.phase, 5);
assert.equal(g.players[0].spice, 9);
assert.equal(
g.ecazAmbassadors!.tokens.find((token) => token.id === first.id)?.location,
'arrakeen',
);
assert.equal(
g.ecazAmbassadors!.tokens.find((token) => token.id === next)?.zone,
'supply',
);
assert.deepEqual(g.ecazAmbassadors!.placement, {
turn: 3,
count: 1,
blocked: true,
});
assert.equal(g.ecazPlacementTurn, 3);
assert.ok(g.discard.some((card) => card.id === karama.id));
rejected(g, 'e', { type: 'decision', token: next, territory: 'carthag' });
// Resume a later end-of-Revival fixture with the persisted custody and block stamp.
g.phase = 4;
g.turn++;
g.ready = [];
g.active = null;
g = ready(reload(g));
assert.equal(viewGame(g, 'e').ambassadors!.blocked, false);
assert.equal(viewGame(g, 'e').ambassadors!.nextCost, 1);
g = allow(declare(g, next, 'carthag'));
assert.equal(g.players[0].spice, 8);
assert.deepEqual(g.ecazAmbassadors!.placement, {
turn: 4,
count: 1,
blocked: false,
});
});
void test('public Ambassador faces and locations do not reveal either private Treachery hand', () => {
const g = ready();
g.players[0].hand.push(g.deck.pop()!);
g.players[1].hand.push(g.deck.pop()!);
const placed = allow(declare(g));
const own = viewGame(placed, 'e');
const other = viewGame(placed, 'other');
assert.deepEqual(own.ambassadors, other.ambassadors);
assert.equal(other.ambassadors!.tokens.length, 11);
assert.ok(
other.ambassadors!.tokens.every(
(token) => typeof token.effect === 'string',
),
);
assert.equal(
other.ambassadors!.tokens.find((token) => token.zone === 'placed')
?.location,
'arrakeen',
);
assert.deepEqual(
own.players.find((p) => p.id === 'e')!.hand,
placed.players[0].hand,
);
assert.equal(other.players.find((p) => p.id === 'e')!.hand, undefined);
assert.equal(own.players.find((p) => p.id === 'other')!.hand, undefined);
other.ambassadors!.tokens[0].location = 'tampered';
assert.notEqual(placed.ecazAmbassadors!.tokens[0].location, 'tampered');
});
void test('all four bot profiles complete a finite placement opportunity with legal choices and exact budget', () => {
for (const difficulty of DIFFICULTIES) {
const initial = fixture();
initial.players[0].spice = 7;
for (const p of initial.players) p.bot = difficulty;
let g = ready(initial);
let steps = 0;
while (g.phase === 4 && steps++ < 40) {
const id = g.response
? g.players.find((p) => !g.response!.passed.includes(p.id))!.id
: g.decision!.player;
const actions = botActions(viewGame(g, id));
assert.ok(
actions.length > 0,
`${difficulty}: waiting player has an action`,
);
if (!g.response)
assert.deepEqual(
botActions(viewGame(g, 'other')),
[],
'other seat cannot place for Ecaz',
);
g = applyAction(reload(g), id, actions[0]);
}
assert.ok(steps < 40, difficulty);
assert.equal(g.phase, 5);
assert.equal(g.players[0].spice, 1);
assert.equal(g.ecazAmbassadors!.placement?.count, 3);
assert.equal(
g.ecazAmbassadors!.tokens.filter((token) => token.zone === 'placed')
.length,
3,
);
}
});
void test('CHOAM end-phase market finishes before Ecaz placement and is not reopened after placement completes', () => {
let g = ready(fixture(true));
assert.equal(g.phase, 4);
assert.equal(g.decision?.kind, 'choamMarket');
assert.equal(g.ecazPlacementTurn, undefined);
rejected(g, 'e', {
type: 'decision',
token: supplied(g)[0].id,
territory: 'arrakeen',
});
g = applyAction(g, 'other', { type: 'decision', done: true });
assert.equal(g.choamMarket, null);
assert.equal(g.decision?.kind, 'ecazPlacement');
g = allow(declare(g));
g = applyAction(g, 'e', { type: 'decision', decline: true });
assert.equal(g.phase, 5);
assert.equal(g.choamMarket, null);
assert.notEqual(g.decision?.kind, 'choamMarket');
});