Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,292 @@
|
||||
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');
|
||||
});
|
||||
Reference in new issue
Block a user