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

324 lines
11 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
RuleError,
type Game,
} from '../game/engine';
import { baseDeck, treacheryDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function fixture() {
const g = createGame('CASHIN22', newPlayer('c', 'CHOAM', 'choam'), true, [
'choam',
]);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('b', 'Bene Gesserit', 'beneGesserit'),
);
g.status = 'playing';
g.phase = 4;
g.order = ['c', 'e', 'b'];
g.deck = baseDeck();
g.players.forEach((p) => {
p.spice = 0;
p.traitorChoices = [];
});
hold(g, 'c', 'Karama');
hold(g, 'c', 'Baliset');
hold(g, 'c', 'Snooper');
return g;
}
function hold(g: Game, id: string, name: string) {
const i = g.deck.findIndex((c) => c.name === name);
assert.ok(i >= 0, name);
const c = g.deck.splice(i, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(c);
return c.id;
}
const activation = (g: Game) =>
g.players[0].hand.find((c) => c.effect === 'karama')!.id;
const named = (g: Game, name: string) =>
g.players[0].hand.find((c) => c.name === name)!.id;
const cash = (
g: Game,
cards = g.players[0].hand
.filter((c) => c.effect !== 'karama')
.map((c) => c.id),
) =>
applyAction(g, 'c', {
type: 'card',
mode: 'special',
card: activation(g),
cards,
});
const live = (g: Game) =>
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort();
function contest(g: Game, id = 'e') {
hold(g, id, 'Karama');
return g;
}
function allow(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find(
(p) =>
!viewGame(g, p.id).responseControls?.hasPassed &&
!!viewGame(g, p.id).responseControls?.cancelCards.length,
)!.id,
{ type: 'passResponse' },
);
return g;
}
void test('CHOAM special Karama works in all nine phases, paying three per selected card and spending activation separately', () => {
for (let phase = 0; phase < 9; phase++) {
const initial = fixture();
initial.phase = phase;
const g = cash(initial);
assert.equal(g.players[0].spice, 6);
assert.equal(g.players[0].hand.length, 0);
assert.equal(g.discard.length, 3);
assert.equal(g.players[0].specialKaramaUsed, true);
assert.deepEqual(live(g), live(initial));
assert.equal(initial.players[0].spice, 0);
}
});
void test('cash-in can discard a second Karama as an ordinary card', () => {
const initial = fixture();
const second = hold(initial, 'c', 'Karama');
const g = cash(initial, [second]);
assert.equal(g.players[0].spice, 3);
assert.equal(g.players[0].hand.length, 2);
assert.equal(g.discard.length, 2);
});
void test('invalid selections fail atomically: activation, duplicates, foreign IDs and an empty selection', () => {
const g = fixture();
const card = named(g, 'Baliset');
const before = structuredClone(g);
for (const cards of [[activation(g)], [card, card], ['missing'], []])
assert.throws(() => cash(g, cards), RuleError);
assert.deepEqual(g, before);
});
void test('the power requires advanced CHOAM, an actual Karama and an unused once-per-game ability', () => {
let g = fixture();
g.advanced = false;
assert.throws(() => cash(g), /advanced/);
g.advanced = true;
g.status = 'setup';
assert.throws(() => cash(g), /advanced/);
g.status = 'playing';
assert.throws(
() =>
applyAction(g, 'c', {
type: 'card',
mode: 'special',
card: named(g, 'Baliset'),
cards: [named(g, 'Snooper')],
}),
/Karama/,
);
const second = hold(g, 'c', 'Karama');
g = cash(g, [named(g, 'Baliset')]);
assert.ok(g.players[0].hand.some((c) => c.id === second));
assert.throws(() => cash(g, [named(g, 'Snooper')]), /already been used/);
});
void test('an ongoing power response and its passed players survive immediate cash-in', () => {
const g = fixture();
hold(g, 'c', 'Karama'); // One activation remains available after cash-in.
g.response = { kind: 'emperorIncome', owner: 'e', passed: ['b'], amount: 4 };
const after = cash(g);
assert.deepEqual(after.response, g.response);
assert.equal(after.players[0].spice, 6);
});
void test('cash-in preserves phase-opening confirmations and its proceeds are then subject to Amal', () => {
let g = fixture();
g.expansions.push('ix');
g.phaseOpening = { passed: ['e'], initialize: true };
g = cash(g);
assert.deepEqual(g.phaseOpening?.passed, ['e']);
assert.equal(g.players[0].spice, 6);
const amal = treacheryDeck(['ix']).find((c) => c.effect === 'amal')!;
g.players[2].hand.push(amal);
g = applyAction(g, 'b', { type: 'card', card: amal.id });
assert.equal(g.players[0].spice, 3);
});
void test('cash-in can fund a declared revival without losing its pending response', () => {
let g = fixture();
contest(g);
g.players[0].spice = 1;
g.players[0].tanks = 1;
g.players[0].reserves = 19;
g = applyAction(g, 'c', { type: 'revive', amount: 1 });
const pending = structuredClone(g.pendingRevival);
g = cash(g);
assert.deepEqual(g.pendingRevival, pending);
assert.equal(g.response?.kind, 'choamRevival');
g = allow(g);
assert.equal(g.players[0].spice, 6);
assert.equal(g.players[0].reserves, 20);
});
void test('selling a pending market card with special Karama prevents a second payout when that sale resumes', () => {
let g = fixture();
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'choamMarket');
contest(g);
const worthless = named(g, 'Baliset');
g = applyAction(g, 'c', { type: 'decision', mode: 'sell', card: worthless });
g = cash(g, [worthless]);
assert.equal(g.players[0].spice, 3);
g = allow(g);
assert.equal(g.players[0].spice, 3);
assert.equal(g.decision?.kind, 'choamMarket');
});
void test('a pending allied exchange remains private and aborts if cash-in consumes the offer', () => {
let g = fixture();
g.players[0].ally = 'e';
g.players[1].ally = 'c';
const returned = hold(g, 'e', 'Shield');
const offered = named(g, 'Snooper');
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = applyAction(g, 'c', { type: 'decision', mode: 'trade', card: offered });
g = applyAction(g, 'e', { type: 'decision', card: returned });
g = cash(g, [offered]);
assert.equal(g.decision?.kind, 'choamTradeConfirm');
g = applyAction(g, 'c', { type: 'decision', accept: true });
assert.equal(g.choamTradeTurn, undefined);
assert.equal(g.players[1].hand[0].id, returned);
assert.equal(g.players[0].spice, 3);
});
void test('cash-in cannot strand a winning auction bid and counts only the committed allied payment', () => {
let g = fixture();
g.phase = 3;
g.auction = {
cards: [g.deck.shift()!],
index: 0,
bid: 10,
bidder: 'c',
active: 'e',
passed: [],
opener: 0,
allyPayment: 1,
};
g.players[0].ally = 'e';
g.players[1].ally = 'c';
g.aid = { e: { recipient: 'c', amount: 9 } };
assert.throws(() => cash(g), /honor your current/);
g.auction.bid = 7;
g = cash(g);
assert.equal(g.players[0].spice, 6);
assert.equal(g.auction?.bid, 7);
});
void test('sealed and prescience-committed cards cannot be cashed in or used as activation', () => {
const g = fixture();
const shield = named(g, 'Snooper');
g.battle = {
territory: 'arrakeen',
attacker: 'c',
defender: 'e',
prepared: true,
plans: {
c: { dial: 0, support: 0, leader: null, weapon: null, defense: shield },
},
revealed: false,
traitorCalls: {},
};
assert.throws(() => cash(g, [shield]));
assert.ok(!viewGame(g, 'c').choamCashIn?.cards.some((c) => c.id === shield));
g.battle.plans = {};
g.battle.prescience = { player: 'e', field: 'defense', value: shield };
assert.throws(() => cash(g, [shield]));
g.battle.prescience.value = activation(g);
assert.throws(() => cash(g, [named(g, 'Baliset')]));
});
void test('only CHOAM sees its cash-in choices; used status and state survive persistence', () => {
let g = fixture();
assert.ok(viewGame(g, 'c').choamCashIn?.karamas.length);
assert.equal(viewGame(g, 'e').choamCashIn, null);
assert.equal(viewGame(g, 'e').players[0].hand, undefined);
g = JSON.parse(JSON.stringify(cash(g))) as Game;
assert.equal(viewGame(g, 'c').choamCashIn, null);
assert.equal(viewGame(g, 'e').players[0].specialKaramaUsed, true);
});
void test('all AI levels can turn unused Worthless cards into spendable spice once', () => {
for (const difficulty of DIFFICULTIES) {
let g = fixture();
g.players[0].bot = difficulty;
const action = botActions(viewGame(g, 'c'))[0];
assert.equal(action.mode, 'special');
g = applyAction(g, 'c', action);
assert.equal(g.players[0].spice, 3);
assert.equal(g.players[0].hand[0].name, 'Snooper');
assert.equal(g.players[0].specialKaramaUsed, true);
}
});
void test('binding battle answers can depend on future CHOAM cash-in while retaining the promised defense', () => {
let g = fixture();
g.phase = 6;
g.active = 'c';
g.storm = 18;
for (const p of g.players.slice(0, 2)) {
p.forces = { 'arrakeen:10': 6 };
p.reserves = 14;
}
g.battle = {
territory: 'arrakeen',
attacker: 'c',
defender: 'e',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
};
hold(g, 'c', 'Cheap Hero');
const truth = hold(g, 'e', 'Truthtrance');
g = applyAction(g, 'e', { type: 'card', card: truth });
while (g.truthtrance?.stage === 'priority')
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
g = applyAction(g, 'e', {
type: 'truthAsk',
question: {
kind: 'battlePlan',
target: 'c',
claim: {
kind: 'and',
terms: [
{ kind: 'support', compare: 'gte', value: 3 },
{ kind: 'defense', name: 'Snooper' },
],
},
},
});
assert.ok(viewGame(g, 'c').truthBattleAnswers?.includes('yes'));
g = applyAction(g, 'c', { type: 'truthAnswer', answer: 'yes' });
const v = viewGame(g, 'c');
const preparation = v.battle!.compliantPreparation!.actions[0];
assert.equal(preparation.mode, 'special');
assert.deepEqual(preparation.cards, [named(g, 'Baliset')]);
assert.equal(viewGame(g, 'e').battle!.compliantPreparation, null);
for (const difficulty of DIFFICULTIES) {
let trial = structuredClone(g);
trial.players[0].bot = difficulty;
const action = botActions(viewGame(trial, 'c'))[0];
assert.equal(action.mode, 'special');
trial = applyAction(trial, 'c', action);
trial = applyAction(trial, 'c', botActions(viewGame(trial, 'c'))[0]);
assert.ok(trial.battle!.plans.c.support >= 3);
}
g = applyAction(g, 'c', preparation);
const plan = viewGame(g, 'c').battle!.compliantPlan!;
assert.ok(plan.support >= 3);
g = applyAction(g, 'c', { type: 'battlePlan', ...plan });
assert.ok(g.battle!.plans.c);
});