355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
RuleError,
|
|
} from '../game/engine';
|
|
import { baseDeck, treacheryDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
|
import { charityAmount, charityMultiplier } from '../game/charity';
|
|
import { createTechTokens } from '../game/tech-tokens';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
function fixture(advanced = false) {
|
|
const g = createGame('INFLATE2', newPlayer('c', 'CHOAM', 'choam'), advanced, [
|
|
'choam',
|
|
]);
|
|
g.players.push(
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
newPlayer('b', 'Bene Gesserit', 'beneGesserit'),
|
|
);
|
|
g.status = 'playing';
|
|
g.phase = 7;
|
|
g.order = ['c', 'e', 'b'];
|
|
g.deck = baseDeck();
|
|
g.players.forEach((p) => {
|
|
p.spice = 20;
|
|
p.traitorChoices = [];
|
|
});
|
|
return g;
|
|
}
|
|
function ready(state: Game) {
|
|
let g = state;
|
|
const players = g.phase === 2 && g.charityOrder ? [...g.charityOrder.remaining] : g.players.map(p => p.id);
|
|
for (const player of players) g = applyAction(g, player, { type: 'ready' });
|
|
while (g.decision?.kind === 'choamMarket')
|
|
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
|
|
return g;
|
|
}
|
|
function charityTurn(state: Game, player: string) {
|
|
let g = state;
|
|
while (g.phase === 2 && g.charityOrder && g.charityOrder.remaining[0] !== player) {
|
|
const current = g.charityOrder.remaining[0];
|
|
assert.ok(current, 'the requested Charity opportunity must still be pending');
|
|
g = applyAction(g, current, { type: 'ready' });
|
|
}
|
|
return g;
|
|
}
|
|
function allow(state: Game) {
|
|
let g = state;
|
|
while (g.response) {
|
|
const p = g.players.find(
|
|
(p) =>
|
|
!viewGame(g, p.id).responseControls?.hasPassed &&
|
|
!!viewGame(g, p.id).responseControls?.cancelCards.length,
|
|
)!;
|
|
g = applyAction(g, p.id, { type: 'passResponse' });
|
|
}
|
|
return g;
|
|
}
|
|
function place(g: Game, side: 'double' | 'cancel' = 'double') {
|
|
return applyAction(g, 'c', { type: 'choamInflation', side });
|
|
}
|
|
function contest(g: Game, count = 1, id = 'e') {
|
|
for (let n = 0; n < count; n++) {
|
|
const index = g.deck.findIndex((card) => card.effect === 'karama');
|
|
assert.ok(index >= 0);
|
|
g.players.find((p) => p.id === id)!.hand.push(g.deck.splice(index, 1)[0]);
|
|
}
|
|
return g;
|
|
}
|
|
function cancel(g: Game) {
|
|
const card = g.players
|
|
.find((p) => p.id === 'e')!
|
|
.hand.find((card) => card.effect === 'karama');
|
|
assert.ok(card, 'canceller must hold Karama before the declaration');
|
|
return applyAction(g, 'e', { type: 'card', card: card.id, mode: 'cancel' });
|
|
}
|
|
function nextCharity(state: Game) {
|
|
const g = structuredClone(state);
|
|
g.turn++;
|
|
g.phase = 1;
|
|
g.nexus = true;
|
|
g.ready = [];
|
|
return ready(g);
|
|
}
|
|
function mentat(state: Game) {
|
|
const g = structuredClone(state.phase === 2 && state.charityOrder ? ready(state) : state);
|
|
g.phase = 7;
|
|
g.ready = [];
|
|
return ready(g);
|
|
}
|
|
void test('clean printed Karama cancellation offers Semuta before CHOAM Inflation settles', () => {
|
|
let g = fixture();
|
|
g.players[1] = newPlayer('e', 'Richese', 'richese');
|
|
g.richeseCache = richeseCards();
|
|
g.semutaPreview = true;
|
|
const semuta = g.richeseCache.findIndex(c => c.id === SEMUTA_DRUG_ID);
|
|
assert.ok(semuta >= 0);
|
|
g.players[2].hand.push(g.richeseCache.splice(semuta, 1)[0]);
|
|
contest(g);
|
|
g = place(ready(g), 'double');
|
|
assert.equal(g.response?.kind, 'choamInflation');
|
|
const spent = g.players[1].hand.find(c => c.effect === 'karama')!;
|
|
g = cancel(g);
|
|
const event = g.pendingTreacheryDiscard!.batch.event;
|
|
assert.equal(g.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.inflation, undefined);
|
|
assert.equal(g.inflationAttemptTurn, g.turn);
|
|
assert.equal(viewGame(g, 'b').semutaReaction?.canCommit, true);
|
|
assert.equal(viewGame(g, 'c').semutaReaction?.canCommit, false);
|
|
let declined: Game = JSON.parse(JSON.stringify(g));
|
|
for (const id of ['c', 'e', 'b'])
|
|
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
|
assert.equal(declined.pendingTreacheryDiscard, null);
|
|
assert.equal(declined.inflation, undefined);
|
|
assert.equal(declined.inflationAttemptTurn, g.turn);
|
|
const claimed = applyAction(JSON.parse(JSON.stringify(g)), 'b', { type: 'semutaCommit', event });
|
|
assert.equal(claimed.pendingTreacheryDiscard, null);
|
|
assert.equal(claimed.inflation, undefined);
|
|
assert.equal(claimed.inflationAttemptTurn, g.turn);
|
|
assert.equal(claimed.players[2].hand.filter(c => c.id === spent.id).length, 1);
|
|
assert.equal(claimed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
|
assert.throws(() => place(claimed), /already attempted/);
|
|
});
|
|
|
|
void test('Inflation placement is a CHOAM Mentat action with a response before the token enters play', () => {
|
|
const initial = contest(fixture());
|
|
assert.throws(() => place(initial), /Mentat/);
|
|
let g = ready(initial);
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { type: 'choamInflation', side: 'double' }),
|
|
/CHOAM/,
|
|
);
|
|
assert.throws(
|
|
() => applyAction(g, 'c', { type: 'choamInflation', side: 'invalid' }),
|
|
/Choose/,
|
|
);
|
|
g = applyAction(g, 'e', { type: 'ready' });
|
|
g = place(g);
|
|
assert.equal(g.response?.kind, 'choamInflation');
|
|
assert.equal(g.inflation, undefined);
|
|
assert.deepEqual(g.ready, []);
|
|
g = allow(g);
|
|
assert.deepEqual(g.inflation, {
|
|
side: 'double',
|
|
placedTurn: 1,
|
|
updatedTurn: 1,
|
|
flipped: false,
|
|
});
|
|
assert.equal(g.inflationUsed, true);
|
|
assert.equal(charityMultiplier(g), 1);
|
|
assert.equal(initial.inflation, undefined);
|
|
});
|
|
void test('both Inflation orders last two charity phases, flip once and are permanently removed', () => {
|
|
for (const side of ['double', 'cancel'] as const) {
|
|
let g = allow(place(ready(fixture()), side));
|
|
for (let i = 0; i < 2; i++) {
|
|
const expected = (i === 0 ? side === 'double' : side === 'cancel')
|
|
? 2
|
|
: 0;
|
|
const before = g.players[0].spice;
|
|
g = allow(nextCharity(g));
|
|
assert.equal(charityMultiplier(g), expected);
|
|
assert.equal(g.players[0].spice, before + 6 * expected);
|
|
g = mentat(g);
|
|
assert.equal(g.response, null);
|
|
if (i === 0) {
|
|
assert.equal(g.inflation?.flipped, true);
|
|
assert.notEqual(g.inflation?.side, side);
|
|
} else assert.equal(g.inflation, null);
|
|
}
|
|
assert.equal(g.inflationUsed, true);
|
|
assert.throws(() => place(g), /already used/);
|
|
const before = g.players[0].spice;
|
|
g = allow(nextCharity(g));
|
|
assert.equal(g.players[0].spice, before + 6);
|
|
}
|
|
});
|
|
void test('Double changes ordinary payout, preserves ordinary eligibility and doubles advanced Bene Gesserit', () => {
|
|
const initial = allow(nextCharity(allow(place(ready(fixture(true))))));
|
|
for (const [starting, ordinary, bg] of [
|
|
[0, 4, 4],
|
|
[1, 2, 4],
|
|
[2, 0, 4],
|
|
[20, 0, 4],
|
|
]) {
|
|
let g = structuredClone(initial);
|
|
g.players[1].spice = starting;
|
|
g.players[2].spice = starting;
|
|
assert.equal(charityAmount(g, g.players[1]), ordinary);
|
|
assert.equal(charityAmount(g, g.players[2]), bg);
|
|
const before = g.players[0].spice;
|
|
g = charityTurn(g, 'e');
|
|
if (ordinary) g = applyAction(g, 'e', { type: 'charity' });
|
|
else assert.throws(() => applyAction(g, 'e', { type: 'charity' }), /fewer/);
|
|
g = charityTurn(g, 'b');
|
|
g = allow(applyAction(g, 'b', { type: 'charity' }));
|
|
assert.equal(g.players[1].spice, starting + ordinary);
|
|
assert.equal(g.players[2].spice, starting + bg);
|
|
assert.equal(g.players[0].spice, before - ordinary - bg);
|
|
}
|
|
});
|
|
void test('Cancel suppresses all claims and CHOAM income, including wealthy advanced Bene Gesserit', () => {
|
|
let g = nextCharity(allow(place(ready(fixture(true)), 'cancel')));
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.players[0].spice, 20);
|
|
for (const id of g.order) {
|
|
g = charityTurn(g, id);
|
|
const p = g.players.find(player => player.id === id)!;
|
|
assert.equal(charityAmount(g, p), 0);
|
|
assert.throws(() => applyAction(g, id, { type: 'charity' }), /fewer/);
|
|
assert.equal(viewGame(g, id).charity.incomePending, false);
|
|
}
|
|
g = ready(g);
|
|
assert.equal(g.phase, 3);
|
|
});
|
|
void test('canceling doubled CHOAM income preserves doubled charity from the bank', () => {
|
|
let g = cancel(nextCharity(contest(allow(place(ready(fixture(true)))))));
|
|
g.players[0].spice = 0;
|
|
g.players[1].spice = 1;
|
|
g = applyAction(g, 'c', { type: 'charity' });
|
|
g = charityTurn(g, 'e');
|
|
g = applyAction(g, 'e', { type: 'charity' });
|
|
g = charityTurn(g, 'b');
|
|
g = allow(applyAction(g, 'b', { type: 'charity' }));
|
|
assert.deepEqual(
|
|
g.players.map((p) => p.spice),
|
|
[4, 3, 24],
|
|
);
|
|
assert.equal(g.inflation?.side, 'double');
|
|
});
|
|
void test('canceling doubled wealthy Bene Gesserit charity does not charge CHOAM', () => {
|
|
let g = allow(nextCharity(allow(place(ready(fixture(true))))));
|
|
const before = g.players[0].spice;
|
|
g = charityTurn(g, 'b');
|
|
g = cancel(applyAction(contest(g), 'b', { type: 'charity' }));
|
|
assert.equal(g.players[0].spice, before);
|
|
assert.equal(g.players[2].spice, 20);
|
|
});
|
|
void test('canceled placement preserves the token for a later Mentat but prevents another attempt this phase', () => {
|
|
let g = cancel(place(ready(contest(fixture()))));
|
|
assert.equal(g.inflationUsed, undefined);
|
|
assert.equal(g.inflation, undefined);
|
|
assert.throws(() => place(g, 'cancel'), /already attempted/);
|
|
g.turn++;
|
|
g = allow(place(g, 'cancel'));
|
|
assert.equal(g.inflation?.side, 'cancel');
|
|
assert.equal(g.inflation?.placedTurn, 2);
|
|
});
|
|
void test('mandatory flip and removal do not open a Karama window and do not repeat on phase reentry', () => {
|
|
let g = allow(nextCharity(allow(place(ready(fixture())))));
|
|
g = mentat(g);
|
|
assert.equal(g.inflation?.side, 'cancel');
|
|
const before = structuredClone(g.inflation);
|
|
g = mentat(g);
|
|
assert.deepEqual(g.inflation, before);
|
|
assert.equal(g.response, null);
|
|
const card = g.deck.find((c) => c.effect === 'karama')!;
|
|
g.players[1].hand.push(card);
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { type: 'card', card: card.id, mode: 'cancel' }),
|
|
RuleError,
|
|
);
|
|
});
|
|
void test('Double prohibits bribes immediately, then permits them after flipping to Cancel', () => {
|
|
let g = allow(place(ready(fixture())));
|
|
g.phase = 0;
|
|
assert.throws(
|
|
() => applyAction(g, 'e', { type: 'bribe', target: 'b', amount: 1 }),
|
|
/Bribes are prohibited/,
|
|
);
|
|
g = mentat(allow(nextCharity(g)));
|
|
g.phase = 0;
|
|
g = applyAction(g, 'e', { type: 'bribe', target: 'b', amount: 1 });
|
|
assert.equal(g.players[2].bribes, 1);
|
|
});
|
|
void test('existing bribes still settle at Mentat when Inflation flips onto Double', () => {
|
|
let g = allow(place(ready(fixture()), 'cancel'));
|
|
g = allow(nextCharity(g));
|
|
g = applyAction(g, 'e', { type: 'bribe', target: 'b', amount: 3 });
|
|
g = mentat(g);
|
|
assert.equal(g.inflation?.side, 'double');
|
|
assert.equal(g.players[2].spice, 23);
|
|
assert.equal(g.players[2].bribes, 0);
|
|
});
|
|
void test('Inflation leaves Spice Production payout unchanged and CHOAM income alone does not trigger it', () => {
|
|
let g = fixture();
|
|
g.techTokens = createTechTokens();
|
|
g.techTokens.production.owner = 'e';
|
|
g = allow(nextCharity(allow(place(ready(g)))));
|
|
assert.equal(g.techTokens!.production.spice, 0);
|
|
g.players[1].spice = 0;
|
|
g = applyAction(g, 'e', { type: 'charity' });
|
|
assert.equal(g.players[1].spice, 4);
|
|
assert.equal(g.techTokens!.production.spice, 1);
|
|
g = ready(g);
|
|
assert.equal(g.players[1].spice, 5);
|
|
});
|
|
void test('Amal phase opening precedes the mandatory Mentat flip', () => {
|
|
let g = allow(nextCharity(allow(place(ready(fixture())))));
|
|
g.expansions.push('ix');
|
|
g.deck = treacheryDeck(['ix']);
|
|
const amal = g.deck.find((c) => c.effect === 'amal')!;
|
|
g.deck = g.deck.filter((c) => c.id !== amal.id);
|
|
g.players[0].hand.push(amal);
|
|
g = mentat(g);
|
|
assert.ok(g.phaseOpening);
|
|
assert.equal(g.inflation?.side, 'double');
|
|
const before = g.players[0].spice;
|
|
g = applyAction(g, 'c', { type: 'card', card: amal.id });
|
|
assert.equal(g.players[0].spice, Math.floor(before / 2));
|
|
g = ready(g);
|
|
assert.equal(g.inflation?.side, 'cancel');
|
|
});
|
|
void test('Inflation lifecycle persists across JSON reconnects with identical public status', () => {
|
|
let g = allow(place(ready(fixture())));
|
|
g = JSON.parse(JSON.stringify(g)) as Game;
|
|
assert.deepEqual(viewGame(g, 'c').inflation, viewGame(g, 'e').inflation);
|
|
g = mentat(allow(nextCharity(g)));
|
|
g = JSON.parse(JSON.stringify(g)) as Game;
|
|
g = mentat(allow(nextCharity(g)));
|
|
assert.equal(g.inflation, null);
|
|
assert.equal(viewGame(g, 'b').inflationUsed, true);
|
|
});
|
|
void test('all AI levels place Inflation legally, finish its response and do not claim canceled charity', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let g = ready(fixture(true));
|
|
g.players[0].bot = difficulty;
|
|
g = applyAction(g, 'c', botActions(viewGame(g, 'c'))[0]);
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.inflationUsed, true);
|
|
g = allow(g);
|
|
assert.equal(botActions(viewGame(g, 'c'))[0].type, 'ready');
|
|
if (g.inflation!.side === 'double') g = mentat(allow(nextCharity(g)));
|
|
g = allow(nextCharity(g));
|
|
assert.equal(charityMultiplier(g), 0);
|
|
g.players.forEach((p) => {
|
|
p.bot = difficulty;
|
|
p.spice = 0;
|
|
});
|
|
for (const id of g.order) {
|
|
assert.equal(g.charityOrder?.remaining[0], id);
|
|
const actions = botActions(viewGame(g, g.charityOrder!.remaining[0]));
|
|
assert.equal(actions[0]?.type, 'ready');
|
|
g = applyAction(g, id, actions[0]);
|
|
}
|
|
}
|
|
});
|