293 lines
10 KiB
TypeScript
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');
|
|
});
|