475 lines
18 KiB
TypeScript
475 lines
18 KiB
TypeScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
applyAction,
|
||
createGame,
|
||
newPlayer,
|
||
viewGame,
|
||
normalizeAutomaticGame,
|
||
RuleError,
|
||
type Game,
|
||
type Action,
|
||
} from '../game/engine';
|
||
import { baseDeck } from '../game/cards';
|
||
import { TERRITORIES, MOBILE_LOCATION } from '../game/board';
|
||
import { botActions } from '../game/bots';
|
||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||
import { richeseCards } from '../game/richese-cards';
|
||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||
const key = (id: string) =>
|
||
`${id}:${TERRITORIES.find((t) => t.id === id)!.sectors[0]}`;
|
||
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
||
const send = (g: Game, id: string, a: Action) => applyAction(g, id, a);
|
||
function fixture(advanced = false) {
|
||
const g = createGame('GAMONT22', newPlayer('c', 'CHOAM', 'choam'), advanced, [
|
||
'choam',
|
||
]);
|
||
g.players.push(
|
||
newPlayer('e', 'Emperor', 'emperor'),
|
||
newPlayer('b', 'Bene Gesserit', 'beneGesserit'),
|
||
newPlayer('g', 'Guild', 'guild'),
|
||
);
|
||
g.status = 'playing';
|
||
g.phase = 7;
|
||
g.order = ['c', 'e', 'b', 'g'];
|
||
g.storm = 18;
|
||
g.deck = baseDeck();
|
||
for (const p of g.players) {
|
||
p.spice = 10;
|
||
p.forces = {};
|
||
p.reserves = 20;
|
||
p.traitorChoices = [];
|
||
}
|
||
player(g, 'e').forces = Object.fromEntries(
|
||
['arrakeen', 'carthag', 'sietch_tabr'].map((t) => [key(t), 1]),
|
||
);
|
||
player(g, 'e').reserves = 17;
|
||
hold(g, 'c', 'Trip to Gamont');
|
||
return g;
|
||
}
|
||
function hold(g: Game, id: string, name: string) {
|
||
const i = g.deck.findIndex((c) => c.name === name);
|
||
assert.ok(i >= 0);
|
||
const c = g.deck.splice(i, 1)[0];
|
||
player(g, id).hand.push(c);
|
||
return c.id;
|
||
}
|
||
function ready(state: Game) {
|
||
let g = state;
|
||
for (const p of g.players)
|
||
if (!g.ready.includes(p.id)) g = send(g, p.id, { type: 'ready' });
|
||
return g;
|
||
}
|
||
const done = (g: Game) => send(g, 'c', { type: 'decision', done: true });
|
||
const mentat = (g = fixture()) => done(ready(g));
|
||
const closing = (g: Game) => done(ready(g));
|
||
function trip(g: Game, target = 'e', from = key('arrakeen'), elite = 0) {
|
||
const card = player(g, 'c').hand.find((c) => c.name === 'Trip to Gamont')!.id;
|
||
return send(g, 'c', {
|
||
type: 'card',
|
||
mode: 'choam',
|
||
card,
|
||
target,
|
||
from,
|
||
elite,
|
||
});
|
||
}
|
||
function contest(g: Game, id = 'e') {
|
||
hold(g, id, 'Karama');
|
||
return g;
|
||
}
|
||
function allow(state: Game) {
|
||
let g = state;
|
||
for (let i = 0; g.response && i < 30; i++)
|
||
g = send(
|
||
g,
|
||
g.players.find(
|
||
(p) =>
|
||
!viewGame(g, p.id).responseControls?.hasPassed &&
|
||
!!viewGame(g, p.id).responseControls?.cancelCards.length,
|
||
)!.id,
|
||
{
|
||
type: 'passResponse',
|
||
},
|
||
);
|
||
assert.equal(g.response, null);
|
||
return g;
|
||
}
|
||
void test('CHOAM tables defer victory until all Mentat actions, market and final opportunity are complete', () => {
|
||
let g = mentat();
|
||
assert.equal(g.phase, 8);
|
||
assert.equal(g.status, 'playing');
|
||
assert.deepEqual(g.winner, []);
|
||
assert.equal(viewGame(g, 'e').mentatVictoryPending, true);
|
||
g = closing(g);
|
||
assert.equal(g.decision?.kind, 'choamMentat');
|
||
assert.equal(g.status, 'playing');
|
||
assert.throws(
|
||
() => send(g, 'e', { type: 'decision', done: true }),
|
||
RuleError,
|
||
);
|
||
g = done(g);
|
||
assert.equal(g.status, 'finished');
|
||
assert.deepEqual(g.winner, ['e']);
|
||
assert.equal(g.turn, 1);
|
||
});
|
||
void test('Trip to Gamont returns one ordinary force to reserves before victory without losses or income', () => {
|
||
for (const advanced of [false, true]) {
|
||
let g = closing(mentat(contest(fixture(advanced))));
|
||
const before = structuredClone(g);
|
||
g = trip(g);
|
||
assert.equal(player(g, 'e').reserves, 17);
|
||
assert.equal(g.response?.location, key('arrakeen'));
|
||
g = allow(g);
|
||
assert.equal(player(g, 'e').forces[key('arrakeen')], undefined);
|
||
assert.equal(player(g, 'e').reserves, 18);
|
||
assert.equal(player(g, 'e').tanks, before.players[1].tanks);
|
||
assert.equal(player(g, 'e').battleLosses, 0);
|
||
for (const p of g.players)
|
||
assert.equal(p.spice, player(before, p.id).spice);
|
||
assert.equal(g.decision?.kind, 'choamMentat');
|
||
g = done(g);
|
||
assert.equal(g.status, 'playing');
|
||
assert.equal(g.turn, 2);
|
||
}
|
||
});
|
||
void test('the final opportunity is public and identical whether CHOAM holds the card or has an empty hand', () => {
|
||
const a = fixture(),
|
||
b = structuredClone(a);
|
||
player(b, 'c').hand = [];
|
||
const ga = closing(mentat(a)),
|
||
gb = closing(mentat(b));
|
||
assert.deepEqual(viewGame(ga, 'e').decision, viewGame(gb, 'e').decision);
|
||
assert.equal(viewGame(ga, 'e').choamWorthless, null);
|
||
});
|
||
void test('Trip may be played during ordinary Mentat and clears readiness before later victory confirmation', () => {
|
||
let g = mentat();
|
||
g = send(g, 'e', { type: 'ready' });
|
||
g = allow(trip(g));
|
||
assert.deepEqual(g.ready, []);
|
||
assert.equal(g.decision, null);
|
||
assert.equal(g.status, 'playing');
|
||
g = done(closing(g));
|
||
assert.equal(g.turn, 2);
|
||
});
|
||
void test('clean proactive ordinary Trip discard offers Semuta before one force returns', () => {
|
||
const initial = fixture();
|
||
initial.players[1] = newPlayer('e', 'Richese', 'richese');
|
||
player(initial, 'e').forces = Object.fromEntries(
|
||
['arrakeen', 'carthag', 'sietch_tabr'].map(t => [key(t), 1]));
|
||
player(initial, 'e').reserves = 17;
|
||
initial.richeseCache = richeseCards();
|
||
initial.semutaPreview = true;
|
||
const index = initial.richeseCache.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
||
assert.ok(index >= 0);
|
||
const semuta = initial.richeseCache.splice(index, 1)[0];
|
||
player(initial, 'e').hand.push(semuta);
|
||
contest(initial, 'b');
|
||
const card = player(initial, 'c').hand.find(card => card.name === 'Trip to Gamont')!.id;
|
||
const physical = (state: Game) => [
|
||
...state.deck, ...state.discard, ...state.richeseCache!,
|
||
...state.players.flatMap(p => p.hand),
|
||
].map(card => card.id).sort();
|
||
const original = physical(initial);
|
||
const absent = structuredClone(initial);
|
||
player(absent, 'e').hand = [absent.richeseCache!.splice(0, 1)[0]];
|
||
absent.richeseCache!.push(semuta);
|
||
const pending = allow(trip(mentat(initial)));
|
||
const neutral = allow(trip(mentat(absent)));
|
||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'choamGamontDiscard');
|
||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||
assert.equal(neutral.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||
assert.equal(player(pending, 'e').forces[key('arrakeen')], 1);
|
||
assert.equal(player(pending, 'e').reserves, 17);
|
||
assert.equal(pending.discard.filter(used => used.id === card).length, 1);
|
||
for (const id of ['c', 'b', 'g'])
|
||
assert.deepEqual(viewGame(pending, id), viewGame(neutral, id));
|
||
assert.equal(viewGame(pending, 'e').semutaReaction?.canCommit, true);
|
||
assert.equal(botActions(viewGame(pending, 'e'))[0]?.type, 'semutaCommit');
|
||
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending);
|
||
for (const change of [
|
||
(state: Game) => {
|
||
const c = state.pendingTreacheryDiscard!.continuation;
|
||
if (c.kind === 'choamGamontDiscard') c.target = 'b';
|
||
},
|
||
(state: Game) => { delete player(state, 'e').forces[key('arrakeen')]; },
|
||
(state: Game) => { state.pendingTreacheryDiscard!.batch.entries[0].card.name = 'Forgery'; },
|
||
]) {
|
||
const corrupt: Game = JSON.parse(JSON.stringify(pending));
|
||
change(corrupt);
|
||
const before = JSON.stringify(corrupt);
|
||
assert.throws(() => send(corrupt, 'e', { type: 'semutaCommit', event }));
|
||
assert.equal(JSON.stringify(corrupt), before);
|
||
}
|
||
let declined: Game = JSON.parse(JSON.stringify(pending));
|
||
for (const id of ['c', 'b', 'g', 'e'])
|
||
declined = send(declined, id, { type: 'semutaPass', event });
|
||
const claimed = send(JSON.parse(JSON.stringify(pending)), 'e',
|
||
{ type: 'semutaCommit', event });
|
||
for (const state of [declined, claimed]) {
|
||
assert.equal(state.pendingTreacheryDiscard, null);
|
||
assert.equal(player(state, 'e').forces[key('arrakeen')], undefined);
|
||
assert.equal(player(state, 'e').reserves, 18);
|
||
assert.deepEqual(state.ready, []);
|
||
assert.deepEqual(physical(state), original);
|
||
assert.equal(done(closing(state)).turn, 2);
|
||
assert.throws(() => send(state, 'e', { type: 'semutaCommit', event }));
|
||
}
|
||
assert.equal(declined.discard.filter(used => used.id === card).length, 1);
|
||
assert.equal(player(claimed, 'e').hand.filter(used => used.id === card).length, 1);
|
||
assert.equal(claimed.discard.filter(used => used.id === semuta.id).length, 1);
|
||
});
|
||
|
||
void test('a Karama cancellation keeps both the selected force and Trip card and does not skip the victory opportunity', () => {
|
||
const initial = fixture();
|
||
const karama = hold(initial, 'e', 'Karama');
|
||
let g = trip(closing(mentat(initial)));
|
||
g = send(g, 'e', { type: 'card', card: karama, mode: 'cancel' });
|
||
assert.equal(g.decision?.kind, 'choamMentat');
|
||
assert.equal(player(g, 'e').forces[key('arrakeen')], 1);
|
||
assert.ok(player(g, 'c').hand.some((c) => c.name === 'Trip to Gamont'));
|
||
assert.throws(() => trip(g), RuleError);
|
||
g = done(g);
|
||
assert.deepEqual(g.winner, ['e']);
|
||
});
|
||
void test('elite recall preserves typed token counts and does not use revival allowance', () => {
|
||
const initial = fixture(true);
|
||
player(initial, 'e').forces[key('arrakeen')] = 2;
|
||
player(initial, 'e').reserves = 16;
|
||
player(initial, 'e').elites = {
|
||
forces: { [key('arrakeen')]: 1 },
|
||
reserves: 4,
|
||
tanks: 0,
|
||
revived: 1,
|
||
};
|
||
const g = allow(trip(mentat(initial), 'e', key('arrakeen'), 1));
|
||
const e = player(g, 'e');
|
||
assert.equal(e.forces[key('arrakeen')], 1);
|
||
assert.equal(e.elites!.forces[key('arrakeen')], undefined);
|
||
assert.equal(e.elites!.reserves, 5);
|
||
assert.equal(e.elites!.revived, 1);
|
||
assert.equal(e.reserves, 17);
|
||
assert.equal(e.tanks, 0);
|
||
});
|
||
void test('an ordinary recall from a mixed stack preserves the elite on the board', () => {
|
||
const initial = fixture(true);
|
||
player(initial, 'e').forces[key('arrakeen')] = 2;
|
||
player(initial, 'e').reserves = 16;
|
||
player(initial, 'e').elites = {
|
||
forces: { [key('arrakeen')]: 1 },
|
||
reserves: 4,
|
||
tanks: 0,
|
||
revived: 0,
|
||
};
|
||
const g = allow(trip(mentat(initial)));
|
||
assert.equal(player(g, 'e').forces[key('arrakeen')], 1);
|
||
assert.equal(player(g, 'e').elites!.forces[key('arrakeen')], 1);
|
||
assert.equal(player(g, 'e').elites!.reserves, 4);
|
||
});
|
||
void test('wrong phase, self targets, reserves, empty sectors, excessive quantities and unavailable force types fail atomically', () => {
|
||
const g = mentat();
|
||
const card = player(g, 'c').hand[0].id;
|
||
const before = structuredClone(g);
|
||
for (const fields of [
|
||
{ target: 'c' },
|
||
{ target: 'missing' },
|
||
{ from: 'reserves' },
|
||
{ from: key('red_chasm') },
|
||
{ amount: 2 },
|
||
{ elite: 1 },
|
||
])
|
||
assert.throws(
|
||
() =>
|
||
send(g, 'c', {
|
||
type: 'card',
|
||
mode: 'choam',
|
||
card,
|
||
target: 'e',
|
||
from: key('arrakeen'),
|
||
...fields,
|
||
}),
|
||
RuleError,
|
||
);
|
||
assert.deepEqual(g, before);
|
||
g.phase = 7;
|
||
assert.throws(() => trip(g), RuleError);
|
||
});
|
||
void test('a target in storm can be recalled and another player’s advisors can be returned', () => {
|
||
const initial = fixture(true);
|
||
initial.storm = TERRITORIES.find((t) => t.id === 'arrakeen')!.sectors[0];
|
||
let g = allow(trip(mentat(initial)));
|
||
assert.equal(player(g, 'e').forces[key('arrakeen')], undefined);
|
||
g = mentat(fixture(true));
|
||
player(g, 'b').forces[key('arrakeen')] = 1;
|
||
player(g, 'b').reserves = 19;
|
||
player(g, 'b').advisors = { arrakeen: {} };
|
||
g = allow(trip(g, 'b'));
|
||
assert.equal(player(g, 'b').reserves, 20);
|
||
assert.equal(player(g, 'b').advisors?.arrakeen, undefined);
|
||
});
|
||
void test('recalling the last enemy beside Bene Gesserit advisors settles their stance before checking control', () => {
|
||
const initial = fixture(true);
|
||
player(initial, 'b').forces[key('arrakeen')] = 1;
|
||
player(initial, 'b').reserves = 19;
|
||
player(initial, 'b').advisors = { arrakeen: {} };
|
||
const g = allow(trip(mentat(initial)));
|
||
assert.equal(player(g, 'b').advisors?.arrakeen, undefined);
|
||
assert.equal(player(g, 'b').forces[key('arrakeen')], 1);
|
||
});
|
||
void test('a force inside the mobile stronghold can be recalled without moving the stronghold', () => {
|
||
const initial = fixture(true);
|
||
const ix = newPlayer('i', 'Ixians', 'ixians');
|
||
initial.players.push(ix);
|
||
initial.order.push('i');
|
||
ix.forces = { [MOBILE_LOCATION]: 2 };
|
||
ix.reserves = 18;
|
||
ix.elites = {
|
||
forces: { [MOBILE_LOCATION]: 1 },
|
||
reserves: 6,
|
||
tanks: 0,
|
||
revived: 0,
|
||
};
|
||
initial.mobileStronghold = { location: key('red_chasm') };
|
||
const g = allow(trip(mentat(initial), 'i', MOBILE_LOCATION, 1));
|
||
assert.equal(player(g, 'i').forces[MOBILE_LOCATION], 1);
|
||
assert.equal(player(g, 'i').elites!.reserves, 7);
|
||
assert.deepEqual(g.mobileStronghold, initial.mobileStronghold);
|
||
});
|
||
void test('a copy acquired in the closing allied exchange can still be played before victory', () => {
|
||
const initial = fixture();
|
||
const old = player(initial, 'c').hand.pop()!;
|
||
player(initial, 'b').hand.push(old);
|
||
player(initial, 'c').ally = 'b';
|
||
player(initial, 'b').ally = 'c';
|
||
const offered = hold(initial, 'c', 'Shield');
|
||
let g = ready(mentat(initial));
|
||
g = send(g, 'c', { type: 'decision', mode: 'trade', card: offered });
|
||
g = send(g, 'b', { type: 'decision', card: old.id });
|
||
g = send(g, 'c', { type: 'decision', accept: true });
|
||
g = done(g);
|
||
assert.equal(g.decision?.kind, 'choamMentat');
|
||
g = allow(trip(g));
|
||
g = done(g);
|
||
assert.equal(g.status, 'playing');
|
||
assert.equal(g.turn, 2);
|
||
});
|
||
void test('cash-in of a pending Trip card causes no recall and restores the final decision after reconnect', () => {
|
||
const initial = fixture(true);
|
||
contest(initial);
|
||
const karama = hold(initial, 'c', 'Karama');
|
||
let g = closing(mentat(initial));
|
||
const tripCard = player(g, 'c').hand.find(
|
||
(c) => c.name === 'Trip to Gamont',
|
||
)!.id;
|
||
g = trip(g);
|
||
g = send(g, 'c', {
|
||
type: 'card',
|
||
mode: 'special',
|
||
card: karama,
|
||
cards: [tripCard],
|
||
});
|
||
g = allow(JSON.parse(JSON.stringify(g)));
|
||
assert.equal(player(g, 'e').forces[key('arrakeen')], 1);
|
||
assert.equal(g.decision?.kind, 'choamMentat');
|
||
g = done(g);
|
||
assert.deepEqual(g.winner, ['e']);
|
||
});
|
||
void test('last-turn fallback and Bene Gesserit prediction use the board after Trip resolves', () => {
|
||
const initial = fixture();
|
||
initial.turn = 10;
|
||
player(initial, 'b').prediction = { faction: 'emperor', turn: 10 };
|
||
let g = closing(mentat(initial));
|
||
assert.equal(g.status, 'playing');
|
||
g = allow(trip(g));
|
||
g = done(g);
|
||
assert.deepEqual(g.winner, ['g']);
|
||
const predicted = fixture();
|
||
player(predicted, 'b').prediction = { faction: 'emperor', turn: 1 };
|
||
g = done(closing(mentat(predicted)));
|
||
assert.deepEqual(g.winner, ['b']);
|
||
});
|
||
void test('bribes and Inflation settle once at Mentat entry even while victory is deferred', () => {
|
||
const initial = fixture();
|
||
player(initial, 'e').bribes = 4;
|
||
initial.inflation = {
|
||
side: 'double',
|
||
placedTurn: 0,
|
||
updatedTurn: 0,
|
||
flipped: false,
|
||
};
|
||
let g = mentat(initial);
|
||
assert.equal(player(g, 'e').spice, 14);
|
||
assert.equal(g.inflation?.side, 'cancel');
|
||
g = closing(g);
|
||
g = allow(trip(g));
|
||
assert.equal(player(g, 'e').spice, 14);
|
||
assert.equal(g.inflation?.side, 'cancel');
|
||
g = done(g);
|
||
assert.equal(player(g, 'e').spice, 14);
|
||
});
|
||
void test('all AI levels preserve Trip from cash-in and market sale, play it and finish the final window', () => {
|
||
for (const difficulty of DIFFICULTIES) {
|
||
const initial = fixture(true);
|
||
player(initial, 'c').bot = difficulty;
|
||
player(initial, 'c').spice = 0;
|
||
hold(initial, 'c', 'Karama');
|
||
let g = mentat(initial);
|
||
const a = botActions(viewGame(g, 'c'))[0];
|
||
assert.equal(a.mode, 'choam');
|
||
g = ready(g);
|
||
const market = botActions(viewGame(g, 'c'))[0];
|
||
assert.equal(market.done, true);
|
||
g = send(g, 'c', market);
|
||
const action = botActions(viewGame(g, 'c'))[0];
|
||
assert.equal(action.mode, 'choam');
|
||
g = allow(send(g, 'c', action));
|
||
const finish = botActions(viewGame(g, 'c'))[0];
|
||
assert.equal(finish.done, true);
|
||
g = send(g, 'c', finish);
|
||
assert.equal(g.status, 'playing');
|
||
}
|
||
});
|
||
void test('Trip can remove a contesting force and create CHOAM’s win with its tech-token bonus', () => {
|
||
const initial = fixture();
|
||
player(initial, 'c').forces = {
|
||
[key('arrakeen')]: 1,
|
||
[key('tueks_sietch')]: 1,
|
||
};
|
||
player(initial, 'c').reserves = 18;
|
||
initial.techTokens = {
|
||
axlotl: { owner: 'c', spice: 0 },
|
||
heighliners: { owner: 'c', spice: 0 },
|
||
production: { owner: 'c', spice: 0 },
|
||
};
|
||
let g = closing(mentat(initial));
|
||
assert.deepEqual(g.winner, []);
|
||
g = allow(trip(g));
|
||
g = done(g);
|
||
assert.deepEqual(g.winner, ['c']);
|
||
assert.equal(player(g, 'e').reserves, 18);
|
||
});
|
||
void test('a missing selected force aborts the pending return without discarding Trip or duplicating a reserve', () => {
|
||
let g = trip(closing(mentat(contest(fixture()))));
|
||
delete player(g, 'e').forces[key('arrakeen')];
|
||
player(g, 'e').reserves++;
|
||
const card = player(g, 'c').hand.find((c) => c.name === 'Trip to Gamont')!.id;
|
||
g = allow(g);
|
||
assert.equal(player(g, 'e').reserves, 18);
|
||
assert.ok(player(g, 'c').hand.some((c) => c.id === card));
|
||
assert.equal(g.decision?.kind, 'choamMentat');
|
||
});
|
||
void test('Bene Gesserit Worthless Karama counter-window retains the final Mentat continuation', () => {
|
||
const initial = fixture(true);
|
||
const worthless = hold(initial, 'b', 'Baliset');
|
||
contest(initial);
|
||
let g = trip(closing(mentat(initial)));
|
||
g = send(g, 'b', { type: 'card', mode: 'cancel', card: worthless });
|
||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||
g = allow(JSON.parse(JSON.stringify(g)));
|
||
assert.equal(player(g, 'e').forces[key('arrakeen')], 1);
|
||
assert.equal(g.decision?.kind, 'choamMentat');
|
||
g = done(g);
|
||
assert.deepEqual(g.winner, ['e']);
|
||
});
|