Files
Dune/tests/truth-charity-payments-runtime.test.ts
T

297 lines
23 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 {
actTruthCharityPayments as act, advanceTruthCharityPayments as advance, answerTruthCharityPayments as answer,
askTruthCharityPayments as ask, bindTruthCharityPayments as bind, createTruthCharityPaymentsFixture as fixture,
declareTruthCharityPayments as declare, finishTruthCharityPayments as finish,
nextTruthCharityPaymentsNative as nativeStep, reachTruthCharityPaymentsClaim as reachClaim,
settleTruthCharityPayments as settle, truthCharityPaymentsClean as clean, truthCharityPaymentsFact as fact,
truthCharityPaymentsGroup as group, truthCharityPaymentsInventory as inventory,
truthCharityPaymentsPayment as payment, truthCharityPaymentsPlayer as player,
truthCharityPaymentsPromise as promise, truthCharityPaymentsQuestion as question,
truthCharityPaymentsShip as ship, truthCharityPaymentsSpice as spice, truthCharityPaymentsTraitors as traitors,
type TruthCharityPaymentsFixture, type TruthCharityPaymentsKind, type TruthCharityPaymentsStep,
} from './fixture-truth-charity-payments';
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: TruthCharityPaymentsFixture, copies: 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: TruthCharityPaymentsFixture, amount: number, recipient = f.recipient,
actions: TruthCharityPaymentsStep[] = []) => act(game, f.target, { type: 'bribe', target: recipient, amount }, actions);
void test('Charity installment sources preserve native Amal reductions, openings, original cards and an unused actual claim', () => {
for (const source of ['amalOpening', 'afterAmal', 'thumper', 'phase2'] as const) {
const f = fixture({ source }); 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']).map(card => card.id).sort());
assert.deepEqual(f.originalTraitors, traitorDeck(f.setup.players, true).sort()); assert.ok(f.originalTraitors.includes(CHEAP_HERO_TRAITOR));
assert.equal(f.originalCards.length, 47); assert.equal(f.originalSpice.length, spiceDeck(true).length);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
assert.equal(f.game.phase, source === 'phase2' ? 2 : 1);
assert.equal(!!f.game.phaseOpening, source === 'amalOpening');
assert.equal(player(f.game, f.target).spice, source === 'amalOpening' || source === 'thumper' ? 10 : 5);
assert.notEqual(player(f.game, f.target).charityTurn, f.game.turn);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActions.length);
assert.equal(f.sourceCounts.inherited, f.base.sourceActionCount);
assert.equal(f.sourceCounts.nativeContinuation, f.sourceActions.length - f.base.sourceActionCount);
assert.equal(f.sourceCounts.actualAmalReduction, f.reduction?.steps.length ?? 0);
if (f.reduction) {
assert.equal(player(f.reduction.before, f.target).spice, 10); assert.equal(player(f.reduction.after, f.target).spice, 5);
assert.ok(f.reduction.steps.some(step => step.actor === f.target && step.action.type === 'card' && step.action.card === f.card));
}
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
assert.deepEqual(spice(f.game), f.originalSpice);
}
});
void test('actual Amal then five paid, one native Charity claim and one later installment fulfills six with frozen facts', () => {
const f = fixture(), raw: TruthQuestion = { ...f.question, mixed: group('and', fact(f.spiceFact), f.mixed) };
const actions: TruthCharityPaymentsStep[] = []; let game = bind(f, 'yes', raw, f.game, 0, actions).game;
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.card);
game = act(game, f.target, guide.action, actions); assert.equal(player(game, f.target).spice, 5);
assert.ok(game.phaseOpening); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 5 }); game = settle(game, actions);
game = pay(game, f, 5, f.recipient, actions); assert.equal(player(game, f.target).spice, 0);
assert.notEqual(viewGame(game, f.target).turnActionGuide?.action.type, 'charity', 'A future-phase quote is not an executable current action');
game = reachClaim(game, actions); assert.notEqual(player(game, f.target).charityTurn, game.turn);
assert.equal(charityQuote(game, player(game, f.target)).total, 2);
const claim = viewGame(game, f.target).turnActionGuide; assert.ok(claim); assert.equal(claim.action.type, 'charity');
assert.equal(claim.action.turnActionPromiseEvent, promise(game).event); assert.equal(claim.action.turnActionPromisePhase, 2);
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, claim.action, actions);
assert.equal(player(game, f.target).charityTurn, game.turn); assert.equal(player(game, f.target).spice, 2);
assert.equal(game.turnActionPromises![0].payments.find(row => row.recipient === f.recipient)?.amount, 5);
const final = viewGame(game, f.target).turnActionGuide; assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 1);
game = act(game, f.target, final.action, actions);
assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 6); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(game.turnActionPromises![0].mixed, raw.kind === 'turnActions' ? raw.mixed : null);
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('bare, grouped and mixed payment questions use the same real Charity installment path after actual Amal', () => {
for (const kind of ['bribe', 'bribeExpression', 'mixedBribe'] as const) {
const f = fixture({ source: 'afterAmal' }), raw = question(f, kind);
let game = bind(f, 'yes', raw).game;
const first = viewGame(game, f.target).bribePromiseGuide; assert.ok(first, kind);
assert.equal(first.action.bribePromiseEvent, promise(game, kind).event); assert.notEqual(first.action.type, 'charity');
game = pay(game, f, 5); game = reachClaim(game);
const claim = viewGame(game, f.target).bribePromiseGuide; assert.ok(claim, kind); assert.equal(claim.action.type, 'charity');
assert.equal(claim.action.bribePromiseEvent, promise(game, kind).event);
game = act(game, f.target, claim.action); assert.equal(player(game, f.target).spice, 2);
const final = viewGame(game, f.target).bribePromiseGuide; assert.ok(final); assert.equal(final.action.type, 'bribe'); assert.equal(final.action.amount, 1);
game = act(game, f.target, final.action);
assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 1);
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
}
});
void test('required printed Thumper occurs before future Charity and cumulative eleven is funded by ten plus one actual claim', () => {
const f = fixture({ source: 'thumper' }); let game = bind(f).game;
game = settle(act(game, f.target, f.nativeAction));
assert.equal(game.discard.filter(card => card.id === f.card).length, 1); assert.equal(game.turnActionPromises![0].windowCards?.thumper?.played, true);
assert.equal(player(game, f.target).spice, 10); assert.notEqual(player(game, f.target).charityTurn, game.turn);
game = pay(game, f, 10); game = reachClaim(game);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, 'charity');
game = act(game, f.target, guide.action); game = pay(game, f, 1);
assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 11);
assert.equal(player(game, f.target).charityTurn, game.turn); assert.equal(promise(game).fulfilled, true); physical(game, f);
});
void test('different recipients share one purse and one actual Charity while their installments accumulate separately', () => {
const f = fixture({ source: 'afterAmal' });
const raw: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
mixed: group('and', payment(f.recipient, 3), payment(f.observer, 3)) };
let game = bind(f, 'yes', raw).game; game = pay(game, f, 3); game = pay(game, f, 2, f.observer);
assert.equal(player(game, f.target).spice, 0); game = reachClaim(game); game = act(game, f.target, f.charity);
game = pay(game, f, 1, f.observer);
assert.equal(player(game, f.target).spice, 1); assert.equal(player(game, f.recipient).bribes, 3); assert.equal(player(game, f.observer).bribes, 3);
assert.deepEqual(game.turnActionPromises![0].payments.slice().sort((a, b) => a.recipient.localeCompare(b.recipient)),
[{ recipient: f.observer, amount: 3 }, { recipient: f.recipient, amount: 3 }].sort((a, b) => a.recipient.localeCompare(b.recipient)));
assert.equal(promise(game).fulfilled, true); rejected(game, f.target, f.charity); physical(game, f);
});
void test('a cumulative bare No cap survives the genuine Charity income and never treats that claim as another payment', () => {
const f = fixture({ source: 'afterAmal' }), raw = question(f, 'bribe'); let game = bind(f, 'no', raw).game;
game = pay(game, f, 5); game = reachClaim(game); game = act(game, f.target, f.charity);
assert.equal(player(game, f.target).spice, 2); assert.equal(game.bribePromises![0].progress, 5);
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.ok(!game.bribePromises![0].released); game = finish(game);
assert.equal(game.bribePromises![0].progress, 5); assert.equal(player(game, f.target).charityTurn, 1); physical(game, f);
});
void test('already claimed or genuinely expired Charity cannot be reserved again for a new positive payment', () => {
const claimed = fixture({ source: 'phase2' }); let game = pay(claimed.game, claimed, 5);
game = act(game, claimed.target, claimed.charity); game = pay(game, claimed, 2);
assert.equal(player(game, claimed.target).spice, 0); assert.equal(player(game, claimed.target).charityTurn, game.turn);
rejected(game, claimed.target, claimed.charity);
let asked = ask(declare(claimed, game).game, claimed.asker, question(claimed, 'bribe', 1));
assert.deepEqual(viewGame(asked, claimed.target).truthBribeAnswers, ['no']); rejected(asked, claimed.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(asked, claimed.target, 'no'); assert.equal(game.bribePromises![0].progress, 0); physical(game, claimed);
const expired = fixture({ source: 'afterAmal' }); game = pay(expired.game, expired, 5);
game = advance(game, state => state.phase === 3 && clean(state), [], true);
assert.notEqual(player(game, expired.target).charityTurn, game.turn); assert.equal(player(game, expired.target).spice, 0);
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('a Basic phase2 positive answer reopens real own Ready and cannot forfeit its necessary Charity claim', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture({ source: 'phase2' }); let source = pay(f.game, f, 5);
source = act(source, f.target, { type: 'ready' }); assert.ok(source.ready.includes(f.target));
let game = bind(f, 'yes', question(f, kind, 1), source).game;
assert.equal(game.ready.includes(f.target), false); rejected(game, f.target, { type: 'ready' });
const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
assert.ok(guide); assert.equal(guide.action.type, 'charity'); game = act(game, f.target, guide.action);
game = pay(game, f, 1); assert.equal(promise(game, kind).fulfilled, true); assert.equal(player(game, f.target).spice, 1);
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
}
});
void test('Advanced future Charity can fund an answer but its real claim guide waits for the original owned turn', () => {
const f = fixture({ source: 'phase2', advanced: true });
assert.notEqual(f.game.charityOrder!.remaining[0], f.target);
let game = bind(f, 'yes').game;
game = pay(game, f, 5);
assert.equal(player(game, f.target).spice, 0);
assert.notEqual(viewGame(game, f.target).turnActionGuide?.action.type, 'charity');
rejected(game, f.target, f.charity);
while (game.charityOrder!.remaining[0] !== f.target)
game = act(game, game.charityOrder!.remaining[0], { type: 'ready' });
const guide = viewGame(game, f.target).turnActionGuide;
assert.equal(guide?.action.type, 'charity');
rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, guide!.action);
game = pay(game, f, 1);
assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 1);
physical(game, f);
});
void test('new Advanced answers cannot reopen an actually completed Charity opportunity for another claim', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture({ source: 'phase2', advanced: true }), respondent = 'atreides';
let game = act(f.game, respondent, { type: 'bribe', target: f.asker, amount: player(f.game, respondent).spice });
while (!game.ready.includes(respondent))
game = act(game, game.charityOrder!.remaining[0], { type: 'ready' });
assert.equal(game.phase, 2); assert.equal(game.active, f.target);
const queue = structuredClone(game.charityOrder), ready = [...game.ready];
const raw: TruthQuestion = { ...question(f, kind, 1), target: respondent };
const asked = ask(declare(f, game).game, f.asker, raw);
const view = viewGame(asked, respondent);
assert.deepEqual(kind === 'turnActions' ? view.truthTurnActionAnswers : view.truthBribeAnswers, ['no']);
rejected(asked, respondent, { type: 'truthAnswer', answer: 'yes' });
game = answer(asked, respondent, 'no');
assert.deepEqual(game.charityOrder, queue); assert.deepEqual(game.ready, ready);
rejected(game, respondent, f.charity);
assert.notEqual(player(game, respondent).charityTurn, game.turn);
physical(game, f);
}
});
void test('one native Charity is shared with shipment and cannot be claimed early to strand remaining payments', () => {
const f = fixture({ source: 'afterAmal' });
const impossible: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn,
mixed: group('and', payment(f.recipient, 6), ship()) };
const asked = ask(declare(f).game, f.asker, impossible);
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, 5), ship()) };
let game = bind(f, 'yes', funded).game; game = pay(game, f, 5); game = reachClaim(game); game = act(game, f.target, f.charity);
assert.equal(player(game, f.target).spice, 2);
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.target).charityTurn, game.turn); physical(game, f);
const opening = fixture(); assert.ok(opening.printed);
const seven: TruthQuestion = { kind: 'turnActions', target: opening.target, turn: opening.game.turn,
mixed: group('and', opening.printed, payment(opening.recipient, 7)) };
let manual = bind(opening, 'yes', seven).game;
manual = settle(act(manual, opening.target, opening.nativeAction)); manual = pay(manual, opening, 4);
manual = reachClaim(manual); assert.equal(player(manual, opening.target).spice, 1);
assert.equal(charityQuote(manual, player(manual, opening.target)).total, 1);
rejected(manual, opening.target, opening.charity);
assert.notEqual(player(manual, opening.target).charityTurn, manual.turn); assert.ok(!promise(manual).released);
const prepay = viewGame(manual, opening.target).turnActionGuide; assert.ok(prepay);
assert.equal(prepay.action.type, 'bribe'); assert.equal(prepay.action.amount, 1);
manual = act(manual, opening.target, prepay.action); assert.equal(player(manual, opening.target).spice, 0);
const claim = viewGame(manual, opening.target).turnActionGuide; assert.ok(claim); assert.equal(claim.action.type, 'charity');
manual = act(manual, opening.target, claim.action); assert.equal(player(manual, opening.target).spice, 2);
const remaining = viewGame(manual, opening.target).turnActionGuide; assert.ok(remaining);
assert.equal(remaining.action.type, 'bribe'); assert.equal(remaining.action.amount, 2);
manual = act(manual, opening.target, remaining.action);
assert.equal(player(manual, opening.target).spice, 0); assert.equal(player(manual, opening.recipient).bribes, 7);
assert.equal(promise(manual).fulfilled, true); assert.ok(!promise(manual).released); physical(manual, opening);
});
void test('two genuine Truths cannot reserve the same Charity twice across different recipient promises', () => {
const f = fixture({ source: 'afterAmal' }); let game = bind(f, 'yes', question(f, 'bribe', 6)).game;
const earlier = structuredClone(game.bribePromises![0]), history = structuredClone(game.truthHistory![0]);
const second = question(f, 'bribe', 2, f.observer), asked = ask(declare(f, game, 1).game, f.asker, second);
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, 5); assert.notEqual(player(game, f.target).charityTurn, game.turn); physical(game, f, 2);
});
void test('current Charity guide packets retain exact owner and original promise event through JSON and reject stale claims', () => {
for (const kind of ['turnActions', 'bribe'] as const) {
const f = fixture({ source: 'phase2' }); let game = bind(f, 'yes', question(f, kind)).game; game = pay(game, f, 5);
const view = viewGame(game, f.target), guide = kind === 'turnActions' ? view.turnActionGuide : view.bribePromiseGuide;
assert.ok(guide); assert.equal(guide.action.type, 'charity');
const event = kind === 'turnActions' ? guide.action.turnActionPromiseEvent : guide.action.bribePromiseEvent;
assert.equal(event, promise(game, kind).event); rejected(game, f.asker, guide.action);
rejected(game, f.target, kind === 'turnActions' ? { ...guide.action, turnActionPromiseEvent: 'stale-charity-event' }
: { ...guide.action, bribePromiseEvent: 'stale-charity-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).charityTurn, game.turn);
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, 1); game = act(game, f.target, final.action);
assert.equal(promise(game, kind).fulfilled, true); physical(game, f);
}
});
void test('all four minimal profiles execute actual prepayments, one Charity and later installments using current-phase guides', () => {
for (const kind of ['turnActions', 'bribe'] as TruthCharityPaymentsKind[]) {
const f = fixture({ source: kind === 'turnActions' ? 'amalOpening' : 'afterAmal' });
const raw = kind === 'turnActions' ? f.question : question(f, kind), asked = ask(declare(f).game, f.asker, raw);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthCharityPaymentsStep[] = [];
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 < 100 && !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 next = nativeStep(game); assert.ok(next, `${kind}/${difficulty}`); game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, kind).fulfilled, true, `${kind}/${difficulty}`);
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 >= 2);
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).charityTurn, game.turn); physical(game, f);
}
}
});