251 lines
20 KiB
TypeScript
251 lines
20 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { charityQuote } from '../game/charity';
|
|
import { spiceDeck, treacheryDeck } from '../game/cards';
|
|
import { viewGame, type Action, type Game } from '../game/engine';
|
|
import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors';
|
|
import { truthFactAnswer, type TruthQuestion } from '../game/truthtrance';
|
|
import {
|
|
actTruthChoamIncome as act, advanceTruthChoamIncome as advance, allowTruthChoamIncome as allow,
|
|
answerTruthChoamIncome as answer, askTruthChoamIncome as ask, bindTruthChoamIncome as bind,
|
|
createTruthChoamIncomeFixture as fixture, declareTruthChoamIncome as declare,
|
|
nextTruthChoamIncomeNative as nativeStep, reachTruthChoamIncomeResponse as reachIncome,
|
|
settleTruthChoamIncome as settle, truthChoamIncomeClean as clean,
|
|
truthChoamIncomeGroup as group, truthChoamIncomeInventory as inventory,
|
|
truthChoamIncomePayment as payment, truthChoamIncomePlayer as player,
|
|
truthChoamIncomePromise as promise, truthChoamIncomeQuestion as question,
|
|
truthChoamIncomeShip as ship, truthChoamIncomeSpice as spice, truthChoamIncomeTraitors as traitors,
|
|
type TruthChoamIncomeFixture, type TruthChoamIncomeStep,
|
|
} from './fixture-truth-choam-income';
|
|
|
|
function rejected(game: Game, actor: string, action: Action) {
|
|
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
|
}
|
|
function physical(game: Game, f: TruthChoamIncomeFixture, copies: 0 | 1 | 2 = 1) {
|
|
for (const truth of f.truths.slice(0, copies)) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
|
|
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
|
|
assert.deepEqual(spice(game), f.originalSpice);
|
|
}
|
|
const pay = (game: Game, f: TruthChoamIncomeFixture, amount: number, recipient = f.recipient,
|
|
actions: TruthChoamIncomeStep[] = []) => act(game, f.target, { type: 'bribe', target: recipient, amount }, actions);
|
|
|
|
void test('native CHOAM income sources retain the original Ix deal, automatic receipt and distinct unused ordinary claim', () => {
|
|
for (const options of [{}, { stage: 'afterIncome' as const }, { advanced: true }, { advanced: true, bg: true }] ) {
|
|
const f = fixture(options); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
|
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
|
assert.deepEqual(f.originalCards, treacheryDeck(['ix', 'choam']).map(card => card.id).sort()); assert.equal(f.originalCards.length, 47);
|
|
assert.deepEqual(f.originalTraitors, traitorDeck(f.afterSetup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR));
|
|
assert.equal(f.originalSpice.length, spiceDeck(true).length); assert.equal(f.originalSpice.length, 22);
|
|
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
|
|
assert.ok(player(f.game, f.observer).hand.some(card => card.id === f.karama));
|
|
assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), f.startingChoamCards);
|
|
assert.equal(f.startCash, 2); assert.equal(f.incomeAmount, 2 * f.game.players.length);
|
|
assert.equal(player(f.game, f.target).spice, f.startCash + (f.income ? f.incomeAmount : 0));
|
|
assert.notEqual(player(f.game, f.target).charityTurn, f.game.turn);
|
|
assert.equal(f.sourceActionCount, f.actions.length); assert.equal(f.sourceCounts.selected, f.actions.length);
|
|
assert.equal(f.sourceCounts.beforeIncome, f.beforeIncomeActions.length); assert.equal(f.sourceCounts.actualIncomeResponse, f.income?.steps.length ?? 0);
|
|
if (f.income) {
|
|
assert.equal(f.income.before.response?.kind, 'choamCharity'); assert.ok(f.income.steps.every(step => step.action.type === 'passResponse'));
|
|
assert.equal(player(f.income.before, f.target).spice, 2); assert.equal(player(f.income.after, f.target).spice, 2 + f.incomeAmount);
|
|
assert.deepEqual(f.game.choamCharity, { turn: 1, canceled: false });
|
|
} else assert.notEqual(f.game.choamCharity?.turn, f.game.turn);
|
|
const asked = ask(declare(f).game, f.asker, question(f, 'bribe', f.maximum)), before = structuredClone(asked);
|
|
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['yes', 'no']);
|
|
assert.deepEqual(asked, before, 'A quote never credits automatic income or stamps an ordinary claim'); physical(asked, f, 0);
|
|
}
|
|
});
|
|
|
|
void test('allowed automatic CHOAM opening income funds whole, bare, grouped and mixed installments with frozen facts', () => {
|
|
for (const kind of ['turnActions', 'bribe', 'bribeExpression', 'mixedBribe'] as const) {
|
|
const f = fixture(), raw = question(f, kind); const actions: TruthChoamIncomeStep[] = [];
|
|
let game = bind(f, 'yes', raw, f.game, 0, actions).game;
|
|
const recipientCash = player(game, f.recipient).spice;
|
|
game = pay(game, f, f.startCash, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
|
|
assert.equal(player(game, f.recipient).spice, recipientCash); assert.equal(player(game, f.recipient).bribes, f.startCash);
|
|
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.ok(!promise(game, kind).fulfilled);
|
|
assert.notEqual(game.choamCharity?.turn, game.turn); assert.notEqual(player(game, f.target).charityTurn, game.turn);
|
|
game = reachIncome(game, actions); assert.equal(game.response?.kind, 'choamCharity');
|
|
rejected(game, f.target, f.charity); assert.equal(player(game, f.target).spice, 0);
|
|
game = allow(game, actions); assert.equal(player(game, f.target).spice, f.incomeAmount);
|
|
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); assert.notEqual(player(game, f.target).charityTurn, game.turn);
|
|
game = pay(game, f, f.incomeAmount, f.recipient, actions);
|
|
assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 0);
|
|
assert.equal(player(game, f.recipient).spice, recipientCash); assert.equal(player(game, f.recipient).bribes, f.minimum);
|
|
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'bribe').length, 2);
|
|
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 0); physical(game, f);
|
|
}
|
|
});
|
|
|
|
void test('the fixed CHOAM receipt and one ordinary Charity fund the maximum without sharing either marker twice', () => {
|
|
const f = fixture(); const actions: TruthChoamIncomeStep[] = [];
|
|
let game = bind(f, 'yes', question(f, 'turnActions', f.maximum), f.game, 0, actions).game;
|
|
game = pay(game, f, f.startCash, f.recipient, actions); game = allow(reachIncome(game, actions), actions);
|
|
game = pay(game, f, f.incomeAmount, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
|
|
assert.equal(charityQuote(game, player(game, f.target)).total, 2); assert.notEqual(player(game, f.target).charityTurn, game.turn);
|
|
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'charity');
|
|
assert.equal(guide.action.turnActionPromiseEvent, promise(game).event); assert.equal(guide.action.turnActionPromisePhase, 2);
|
|
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, guide.action, actions);
|
|
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).charityTurn, game.turn);
|
|
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
|
|
assert.equal(game.turnActionPromises![0].payments[0].amount, f.minimum, 'Neither automatic nor claimed income is itself a payment');
|
|
game = pay(game, f, 2, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
|
|
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.recipient).bribes, f.maximum);
|
|
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 1);
|
|
rejected(game, f.target, f.charity); physical(game, f);
|
|
});
|
|
|
|
void test('two original Truths cannot reserve the same automatic CHOAM income and ordinary claim for different recipients', () => {
|
|
const f = fixture(); let game = bind(f, 'yes', question(f, 'bribe', f.maximum)).game;
|
|
const earlier = structuredClone(game.bribePromises![0]), history = structuredClone(game.truthHistory![0]);
|
|
const asked = ask(declare(f, game, 1).game, f.asker, question(f, 'bribe', 1, f.observer));
|
|
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
game = answer(asked, f.target, 'no'); assert.deepEqual(game.bribePromises![0], earlier); assert.deepEqual(game.truthHistory![0], history);
|
|
assert.equal(player(game, f.target).spice, f.startCash); assert.notEqual(game.choamCharity?.turn, game.turn);
|
|
assert.notEqual(player(game, f.target).charityTurn, game.turn); physical(game, f, 2);
|
|
});
|
|
|
|
void test('real Karama cancellation releases a large fixed-income commitment while a current-cash plus ordinary-claim alternative survives', () => {
|
|
for (const minimum of [10, 4]) {
|
|
const f = fixture(); let game = bind(f, 'yes', question(f, 'turnActions', minimum)).game;
|
|
game = pay(game, f, f.startCash); game = reachIncome(game);
|
|
game = settle(act(game, f.observer, f.cancel));
|
|
assert.deepEqual(game.choamCharity, { turn: 1, canceled: true }); assert.equal(player(game, f.target).spice, 0);
|
|
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
|
assert.equal(game.turnActionPromises![0].payments[0].amount, f.startCash);
|
|
if (minimum === 10) { assert.equal(promise(game).released, true); assert.ok(!promise(game).fulfilled); }
|
|
else {
|
|
assert.ok(!promise(game).released); const claim = viewGame(game, f.target).turnActionGuide; assert.ok(claim);
|
|
assert.equal(claim.action.type, 'charity'); game = act(game, f.target, claim.action); game = pay(game, f, 2);
|
|
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.recipient).bribes, 4);
|
|
}
|
|
physical(game, f);
|
|
}
|
|
});
|
|
|
|
void test('already collected automatic income and claimed or expired ordinary Charity cannot be quoted a second time', () => {
|
|
const f = fixture({ stage: 'afterIncome' });
|
|
let asked = ask(declare(f).game, f.asker, question(f, 'bribe', f.maximum + 1));
|
|
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['no']);
|
|
let game = pay(f.game, f, f.minimum); game = act(game, f.target, f.charity); game = pay(game, f, 2);
|
|
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).charityTurn, game.turn);
|
|
rejected(game, f.target, f.charity); asked = ask(declare(f, game).game, f.asker, question(f, 'bribe', 1));
|
|
assert.deepEqual(viewGame(asked, f.target).truthBribeAnswers, ['no']); game = answer(asked, f.target, 'no'); physical(game, f);
|
|
const expired = fixture({ stage: 'afterIncome' }); game = pay(expired.game, expired, expired.minimum);
|
|
game = advance(game, state => state.phase === 3 && clean(state), [], true);
|
|
assert.equal(player(game, expired.target).spice, 0); assert.notEqual(player(game, expired.target).charityTurn, game.turn);
|
|
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); rejected(game, expired.target, expired.charity);
|
|
asked = ask(declare(expired, game).game, expired.asker, question(expired, 'bribe', 1));
|
|
assert.deepEqual(viewGame(asked, expired.target).truthBribeAnswers, ['no']); game = answer(asked, expired.target, 'no'); physical(game, expired);
|
|
});
|
|
|
|
void test('payments and a later original shipment share the same single CHOAM receipt and ordinary claim', () => {
|
|
const f = fixture();
|
|
const unfunded: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
|
|
mixed: group('and', payment(f.recipient, f.maximum - 1), ship()) };
|
|
const asked = ask(declare(f).game, f.asker, unfunded);
|
|
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']); rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
const funded: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
|
|
mixed: group('and', payment(f.recipient, f.minimum), ship()) };
|
|
let game = bind(f, 'yes', funded).game; game = pay(game, f, f.startCash); game = allow(reachIncome(game));
|
|
game = pay(game, f, f.incomeAmount); game = act(game, f.target, f.charity);
|
|
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), [], true);
|
|
game = settle(act(game, f.target, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 }));
|
|
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
|
|
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.recipient).bribes, f.minimum);
|
|
assert.equal(player(game, f.target).charityTurn, 1); assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); physical(game, f);
|
|
});
|
|
|
|
void test('an actual opposing ordinary claim debits CHOAM and releases a commitment beyond the remaining fixed income without veto', () => {
|
|
const f = fixture({ stage: 'afterIncome' }); let game = bind(f, 'yes', question(f, 'bribe', f.maximum)).game;
|
|
const opponentCash = player(game, f.observer).spice;
|
|
game = act(game, f.observer, { type: 'bribe', target: f.recipient, amount: opponentCash });
|
|
assert.equal(player(game, f.observer).spice, 0); assert.equal(player(game, f.target).spice, f.minimum);
|
|
game = act(game, f.observer, f.charity);
|
|
assert.equal(player(game, f.observer).spice, 2); assert.equal(player(game, f.target).spice, f.minimum - 2);
|
|
assert.equal(player(game, f.observer).charityTurn, game.turn); assert.notEqual(player(game, f.target).charityTurn, game.turn);
|
|
assert.equal(promise(game, 'bribe').released, true); assert.equal(game.bribePromises![0].progress, 0); physical(game, f);
|
|
});
|
|
|
|
void test('Advanced BG forced settlement after CHOAM passes last releases the fixed-income promise without attributing a voluntary debit', () => {
|
|
const f = fixture({ advanced: true, bg: true }); assert.ok(f.bg);
|
|
assert.ok(f.providerCard); assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.providerCard]);
|
|
for (const kind of ['turnActions', 'bribe'] as const) {
|
|
let game = bind(f, 'yes', question(f, kind, f.minimum), structuredClone(f.game)).game;
|
|
game = pay(game, f, f.startCash); game = allow(reachIncome(game));
|
|
assert.equal(player(game, f.target).spice, f.incomeAmount); assert.equal(player(game, f.bg).spice, 5);
|
|
// CHOAM's current cash meets this fixed-income answer. Finish its actual
|
|
// turn before BG acts; the closed ordinary claim cannot repair a later debit.
|
|
while (game.charityOrder!.remaining[0] !== f.bg)
|
|
game = act(game, game.charityOrder!.remaining[0], { type: 'ready' });
|
|
game = act(game, f.bg, f.charity); assert.equal(game.response?.kind, 'bgCharity');
|
|
assert.equal(player(game, f.target).spice, f.incomeAmount); assert.ok(!promise(game, kind).released);
|
|
while (game.response?.kind === 'bgCharity') {
|
|
const response = game.response;
|
|
const actor = game.players.find(candidate => candidate.id !== f.target && candidate.id !== response.owner &&
|
|
!response.passed.includes(candidate.id) && !!viewGame(game, candidate.id).responseControls?.cancelCards.length);
|
|
if (!actor) break;
|
|
game = act(game, actor.id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(game.response?.kind, 'bgCharity'); assert.ok(!game.response.passed.includes(f.target));
|
|
assert.ok(viewGame(game, f.target).responseControls?.cancelCards.includes(f.providerCard));
|
|
game = act(game, f.target, { type: 'passResponse' });
|
|
assert.equal(game.response, null); assert.equal(player(game, f.bg).spice, 7);
|
|
assert.equal(player(game, f.target).spice, f.incomeAmount - 2); assert.equal(player(game, f.bg).charityTurn, game.turn);
|
|
assert.notEqual(player(game, f.target).charityTurn, game.turn); assert.equal(promise(game, kind).released, true);
|
|
assert.equal(kind === 'turnActions' ? game.turnActionPromises![0].payments[0].amount : game.bribePromises![0].progress, f.startCash);
|
|
assert.ok(player(game, f.target).hand.some(card => card.id === f.providerCard)); physical(game, f);
|
|
}
|
|
});
|
|
|
|
void test('actual Charity guides retain the original CHOAM promise event and owner through JSON without crediting income twice', () => {
|
|
for (const kind of ['turnActions', 'bribe'] as const) {
|
|
const f = fixture({ stage: 'afterIncome' }); let game = bind(f, 'yes', question(f, kind, f.maximum)).game;
|
|
game = pay(game, f, f.minimum);
|
|
const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
|
|
assert.ok(guide); assert.equal(guide.action.type, 'charity');
|
|
assert.equal(kind === 'turnActions' ? guide.action.turnActionPromiseEvent : guide.action.bribePromiseEvent, promise(game, kind).event);
|
|
rejected(game, f.asker, guide.action);
|
|
rejected(game, f.target, kind === 'turnActions' ? { ...guide.action, turnActionPromiseEvent: 'stale-choam-income-event' }
|
|
: { ...guide.action, bribePromiseEvent: 'stale-choam-income-event' });
|
|
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet); rejected(game, f.target, packet);
|
|
assert.equal(player(game, f.target).spice, 2); assert.equal(player(game, f.target).charityTurn, game.turn);
|
|
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false });
|
|
const next = viewGame(game, f.target), final = kind === 'turnActions' ? next.turnActionGuide : next.bribePromiseGuide;
|
|
assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 2);
|
|
game = act(game, f.target, final.action); assert.equal(promise(game, kind).fulfilled, true); physical(game, f);
|
|
}
|
|
});
|
|
|
|
void test('all four minimal profiles await the real CHOAM response then use legal installments and one ordinary Charity', () => {
|
|
for (const kind of ['turnActions', 'bribe'] as const) {
|
|
const f = fixture(), asked = ask(declare(f).game, f.asker, question(f, kind, f.maximum));
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let game = structuredClone(asked); const actions: TruthChoamIncomeStep[] = [];
|
|
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
|
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, `${kind}/${difficulty}`);
|
|
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
|
|
for (let count = 0; count < 160 && !promise(game, kind).fulfilled; count++) {
|
|
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
|
const guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
|
|
if (guide) {
|
|
const action = botActions(view).find(candidate => kind === 'turnActions'
|
|
? candidate.turnActionPromiseEvent === guide.action.turnActionPromiseEvent
|
|
: candidate.bribePromiseEvent === guide.action.bribePromiseEvent);
|
|
assert.ok(action, `${kind}/${difficulty}`); assert.deepEqual(action, guide.action);
|
|
if (action.type === 'charity') assert.equal(game.phase, 2);
|
|
game = act(game, f.target, action, actions);
|
|
} else {
|
|
const step = nativeStep(game); assert.ok(step, `${kind}/${difficulty}`); game = act(game, step.actor, step.action, actions);
|
|
}
|
|
}
|
|
assert.equal(promise(game, kind).fulfilled, true, `${kind}/${difficulty}`);
|
|
assert.deepEqual(game.choamCharity, { turn: 1, canceled: false }); assert.equal(player(game, f.target).charityTurn, game.turn);
|
|
assert.equal(actions.filter(step => step.actor === f.target && step.action.type === 'charity').length, 1);
|
|
assert.ok(actions.filter(step => step.actor === f.target && step.action.type === 'bribe').length >= 3);
|
|
assert.equal(player(game, f.recipient).bribes, f.maximum); assert.equal(player(game, f.target).spice, 0); physical(game, f);
|
|
}
|
|
}
|
|
});
|