Files
Dune/tests/choam-gamont.test.ts
T

475 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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']);
});