324 lines
11 KiB
TypeScript
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);
|
|
});
|