Support whole AND/OR ordinary bribe commitments

This commit is contained in:
Quintine committed 2026-10-26 15:53:57 +11:00
1 parent c81e00e6dd
commit 0e3c155876
21 files changed
+1778 -111

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
BRIBE_EXPRESSION_MAX_DEPTH,
BRIBE_EXPRESSION_MAX_LEAVES,
BribeExpressionPromiseError,
bribeCompletionCandidates,
bribeExpressionBranches,
bribeExpressionRecipients,
bribeExpressionText,
evaluateBribeExpression,
liveBribeExpressionPromises,
parseBribeExpression,
parseBribeExpressionClaim,
recordBribeExpressionPayment,
validateBribeExpressionPromises,
type BribeExpression,
type BribeExpressionProgress,
type BribeExpressionPromise,
} from '../game/bribe-expression-promises';
import { type BribePromise } from '../game/bribe-promises';
const seats = ['payer', 'asker', 'a', 'b', 'c', 'other-payer'];
const pay = (recipient = 'a', minimum = 3): BribeExpression => ({ kind: 'payment', recipient, minimum });
const group = (kind: 'and' | 'or', ...terms: BribeExpression[]): BribeExpression => ({ kind, terms });
const progress = (recipient: string, amount: number): BribeExpressionProgress => [{ recipient, amount }];
const promise = (event: string, claim: BribeExpression = pay(), extra: Partial<BribeExpressionPromise> = {}): BribeExpressionPromise =>
({ event, turn: 2, claim, player: 'payer', asker: 'asker', answer: true, progress: [], ...extra });
const atomic = (event: string, recipient = 'a', minimum = 3, extra: Partial<BribePromise> = {}): BribePromise =>
({ event, turn: 2, recipient, minimum, player: 'payer', asker: 'asker', answer: true, progress: 0, ...extra });
const candidates = (atomics: BribePromise[], expressions: BribeExpressionPromise[]) =>
[...bribeCompletionCandidates(atomics, expressions, 'payer', 2)];
void test('bribe expressions accept sixteen leaves, four group levels and safe spice minima without a gameplay cap', () => {
assert.equal(BRIBE_EXPRESSION_MAX_DEPTH, 4); assert.equal(BRIBE_EXPRESSION_MAX_LEAVES, 16);
const sixteen = group('and', ...Array.from({ length: 16 }, (_, index) => pay(`recipient-${index}`, index + 1)));
const parsed = parseBribeExpression(sixteen);
assert.deepEqual(parsed, sixteen); assert.notEqual(parsed, sixteen);
if ('terms' in parsed && 'terms' in sixteen) {
assert.notEqual(parsed.terms, sixteen.terms); assert.notEqual(parsed.terms[0], sixteen.terms[0]);
}
const nested = (depth: number): BribeExpression => depth === 0 ? pay('a', 1) : group('or', pay('b'), nested(depth - 1));
assert.deepEqual(parseBribeExpression(nested(4)), nested(4));
assert.throws(() => parseBribeExpression(nested(5)), BribeExpressionPromiseError);
assert.throws(() => parseBribeExpression(group('or', ...Array.from({ length: 17 }, () => pay()))), BribeExpressionPromiseError);
assert.throws(() => parseBribeExpression(group('and', group('or', ...Array.from({ length: 9 }, () => pay())),
group('or', ...Array.from({ length: 8 }, () => pay('b'))))), BribeExpressionPromiseError);
assert.deepEqual(parseBribeExpressionClaim({ turn: Number.MAX_SAFE_INTEGER, claim: pay('a', Number.MAX_SAFE_INTEGER) }),
{ turn: Number.MAX_SAFE_INTEGER, claim: pay('a', Number.MAX_SAFE_INTEGER) });
assert.deepEqual(parseBribeExpression(pay('a', 1001)), pay('a', 1001));
});
void test('strict parsing validates decisive later subtrees, exact node fields and all safe positive minima', () => {
const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] }, { kind: 'or', terms: [pay()] },
{ kind: 'and', terms: [pay(), pay('b')], extra: true },
{ kind: 'payment', recipient: ' a', minimum: 1 }, { kind: 'payment', recipient: '', minimum: 1 },
{ kind: 'payment', recipient: 'a', minimum: 1, progress: 0 }, { kind: 'not', terms: [pay(), pay()] },
...[0, -1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '3'].map(minimum =>
({ kind: 'payment', recipient: 'a', minimum }))];
for (const input of invalid) assert.throws(() => parseBribeExpression(input), BribeExpressionPromiseError);
const decisive = group('or', pay('a', 1), { kind: 'payment', recipient: 'b', minimum: 0 });
assert.throws(() => evaluateBribeExpression(decisive, progress('a', 1)), BribeExpressionPromiseError);
assert.throws(() => evaluateBribeExpression(group('and', pay('a', 1), decisive), []), BribeExpressionPromiseError);
assert.throws(() => [...bribeExpressionBranches(promise('bad-later', decisive, { progress: progress('a', 1) }))],
BribeExpressionPromiseError);
for (const input of [{ turn: 0, claim: pay() }, { turn: '2', claim: pay() }, { turn: 2, claim: pay(), privateSpice: 9 },
{ turn: 2 }, { claim: pay() }]) assert.throws(() => parseBribeExpressionClaim(input), BribeExpressionPromiseError);
});
void test('evaluation, public text and recipients retain nested whole meaning with exact recipient identities', () => {
const claim = group('and', pay('a', 3), group('or', pay('b', 2), pay('a', 6)));
assert.deepEqual(bribeExpressionRecipients(claim), ['a', 'b']);
assert.equal(evaluateBribeExpression(claim, progress('a', 3)), false);
assert.equal(evaluateBribeExpression(claim, progress('a', 6)), true);
assert.equal(evaluateBribeExpression(claim, [...progress('a', 3), ...progress('b', 2)]), true);
assert.equal(bribeExpressionText(claim, id => id.toUpperCase()),
'(pay at least 3 spice to A in ordinary bribes AND (pay at least 2 spice to B in ordinary bribes OR pay at least 6 spice to A in ordinary bribes))');
});
void test('whole Yes keeps dynamic OR alternatives after partial payment and allows either actual route', () => {
const original = promise('choice', group('or', pay('a', 3), pay('b', 2)));
assert.deepEqual(candidates([], [original]), [[{ recipient: 'a', amount: 3 }], [{ recipient: 'b', amount: 2 }]]);
const partial = recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'a', amount: 1 });
assert.deepEqual(partial[0].progress, progress('a', 1)); assert.equal(partial[0].fulfilled, undefined);
assert.deepEqual(candidates([], partial), [[{ recipient: 'a', amount: 2 }], [{ recipient: 'b', amount: 2 }]]);
assert.equal(recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'a', amount: 2 })[0].fulfilled, true);
const byB = recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'b', amount: 2 });
assert.equal(byB[0].fulfilled, true); assert.deepEqual([...bribeExpressionBranches(byB[0])], [[]]);
assert.deepEqual(original.progress, []); assert.deepEqual(partial[0].progress, progress('a', 1));
});
void test('whole No to nested AND permits a completed conjunct and rejects completing either remaining alternative', () => {
const original = promise('not-all', group('and', pay('a', 2), group('or', pay('b', 3), pay('c', 4))), { answer: false });
assert.deepEqual([...bribeExpressionBranches(original)], [
[{ recipient: 'a', minimum: 2, progress: 0, answer: false }],
[{ recipient: 'b', minimum: 3, progress: 0, answer: false }, { recipient: 'c', minimum: 4, progress: 0, answer: false }],
]);
const partial = recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'a', amount: 2 });
assert.equal(partial[0].fulfilled, undefined);
assert.deepEqual([...bribeExpressionBranches(partial[0])], [[
{ recipient: 'b', minimum: 3, progress: 0, answer: false }, { recipient: 'c', minimum: 4, progress: 0, answer: false },
]]);
assert.throws(() => recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'b', amount: 3 }), BribeExpressionPromiseError);
assert.throws(() => recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'c', amount: 4 }), BribeExpressionPromiseError);
const opposite = recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'b', amount: 3 });
assert.throws(() => recordBribeExpressionPayment(opposite, 'payer', 2, { recipient: 'a', amount: 2 }), BribeExpressionPromiseError);
});
void test('whole No to nested OR forbids its first leaf but allows one child of the remaining AND', () => {
const original = promise('not-either', group('or', pay('a', 2), group('and', pay('b', 3), pay('c', 4))), { answer: false });
assert.throws(() => recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'a', amount: 2 }), BribeExpressionPromiseError);
const partial = recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'b', amount: 3 });
assert.doesNotThrow(() => recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'a', amount: 1 }));
assert.throws(() => recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'c', amount: 4 }), BribeExpressionPromiseError);
assert.deepEqual(candidates([atomic('needs-c', 'c', 4)], partial), []);
const noOr = promise('neither', group('or', pay('a', 2), pay('b', 3)), { answer: false });
assert.deepEqual(candidates([atomic('needs-a', 'a', 2)], [noOr]), []);
assert.deepEqual(candidates([], [noOr]), [[]]);
});
void test('same-recipient conjuncts share cumulative payments while different recipients require separate spice', () => {
const original = promise('share', group('and', pay('a', 3), pay('a', 5), pay('b', 2)));
assert.deepEqual(candidates([], [original]), [[{ recipient: 'a', amount: 5 }, { recipient: 'b', amount: 2 }]]);
const partial = recordBribeExpressionPayment([original], 'payer', 2, { recipient: 'a', amount: 3 });
assert.deepEqual(candidates([], partial), [[{ recipient: 'a', amount: 2 }, { recipient: 'b', amount: 2 }]]);
assert.deepEqual([...bribeExpressionBranches(promise('same-cap', group('or', pay('a', 3), pay('a', 5)), { answer: false }))],
[[{ recipient: 'a', minimum: 3, progress: 0, answer: false }]]);
});
void test('branches simplify already true leaves and keep whole No alternatives without a stored route', () => {
const partial = promise('partly-true', group('and', pay('a', 3), pay('b', 2)), { progress: progress('a', 3) });
assert.deepEqual([...bribeExpressionBranches(partial)], [[{ recipient: 'b', minimum: 2, progress: 0, answer: true }]]);
assert.deepEqual([...bribeExpressionBranches({ ...partial, answer: false })],
[[{ recipient: 'b', minimum: 2, progress: 0, answer: false }]]);
const trueOr = promise('already-true', group('or', pay('a', 3), pay('b', 2)), { progress: progress('a', 3) });
assert.deepEqual([...bribeExpressionBranches(trueOr)], [[]]);
assert.deepEqual([...bribeExpressionBranches({ ...trueOr, answer: false })], []);
const alternatives = promise('still-lazy', group('or', pay('a', 3), pay('b', 2)));
const first = bribeExpressionBranches(alternatives);
assert.deepEqual(first.next().value, [{ recipient: 'a', minimum: 3, progress: 0, answer: true }]);
assert.equal('branch' in alternatives, false);
const next = recordBribeExpressionPayment([alternatives], 'payer', 2, { recipient: 'b', amount: 2 });
assert.equal(next[0].fulfilled, true); assert.equal('branch' in next[0], false);
});
void test('joint scalar and expression routes preserve each answer interval, caps and distinct recipient budgets', () => {
const older = atomic('old-a', 'a', 8, { progress: 3 });
const later = promise('later-route', group('or', pay('a', 5), pay('b', 3)));
assert.deepEqual(candidates([older], [later]), [
[{ recipient: 'a', amount: 5 }], [{ recipient: 'a', amount: 5 }, { recipient: 'b', amount: 3 }],
]);
const cap = atomic('a-cap', 'a', 9, { progress: 4, answer: false });
assert.deepEqual(candidates([older, cap], [later]), []);
assert.deepEqual(candidates([cap], [later]), [[{ recipient: 'b', amount: 3 }]]);
const notBoth = promise('not-both', group('and', pay('a', 5), pay('b', 3)), { answer: false });
assert.deepEqual(candidates([older], [notBoth]), [[{ recipient: 'a', amount: 5 }]]);
assert.deepEqual(candidates([older, atomic('also-b', 'b', 3)], [notBoth]), []);
const second = promise('needs-c', group('and', pay('a', 2), pay('c', 4)));
assert.deepEqual(candidates([older], [later, second]), [
[{ recipient: 'a', amount: 5 }, { recipient: 'c', amount: 4 }],
[{ recipient: 'a', amount: 5 }, { recipient: 'b', amount: 3 }, { recipient: 'c', amount: 4 }],
]);
assert.deepEqual(candidates([], [promise('repeat-route', group('or', pay('a'), pay('a')))]), [[{ recipient: 'a', amount: 3 }]]);
});
void test('new answers exclude prior payments and terminal, other-payer, turn and recipient entries retain references', () => {
const fulfilled = recordBribeExpressionPayment([promise('earlier', pay('a', 2))], 'payer', 2, { recipient: 'a', amount: 2 })[0];
const later = promise('new-answer', pay('a', 2));
const records = [fulfilled, promise('released', pay(), { released: true }), promise('old-turn', pay(), { turn: 1 }),
promise('other', pay(), { player: 'other-payer' }), promise('other-recipient', pay('b')), later];
assert.deepEqual(liveBribeExpressionPromises(records, 'payer', 2), [records[4], later]);
assert.deepEqual(candidates([], [fulfilled, later]), [[{ recipient: 'a', amount: 2 }]]);
const paid = recordBribeExpressionPayment(records, 'payer', 2, { recipient: 'a', amount: 1 });
for (let index = 0; index < 5; index++) assert.equal(paid[index], records[index]);
assert.deepEqual(paid[5].progress, progress('a', 1)); assert.deepEqual(later.progress, []);
const unrelated = recordBribeExpressionPayment(records, 'absent', 2, { recipient: 'a', amount: 1 });
records.forEach((record, index) => assert.equal(unrelated[index], record));
});
void test('rejected ordinary payments leave every whole-answer ledger unchanged and do not partly fulfill another promise', () => {
const records = [promise('yes', pay('a', 3)), promise('no', group('or', pay('a', 3), pay('b', 2)), { answer: false })];
const partial = recordBribeExpressionPayment(records, 'payer', 2, { recipient: 'a', amount: 2 });
const before = structuredClone(partial);
partial.forEach(entry => { entry.progress.forEach(Object.freeze); Object.freeze(entry.progress); Object.freeze(entry); });
Object.freeze(partial);
assert.throws(() => recordBribeExpressionPayment(partial, 'payer', 2, { recipient: 'a', amount: 1 }), BribeExpressionPromiseError);
assert.deepEqual(partial, before); assert.equal(partial[0].fulfilled, undefined);
assert.deepEqual(candidates([], partial), []); assert.deepEqual(partial, before);
});
void test('safe arithmetic rejects payment and total-route overflow including progress on nonselected leaves', () => {
const maximum = Number.MAX_SAFE_INTEGER;
const near = promise('near', pay('a', maximum), { progress: progress('a', maximum - 1) });
assert.deepEqual(candidates([], [near]), [[{ recipient: 'a', amount: 1 }]]);
assert.equal(recordBribeExpressionPayment([near], 'payer', 2, { recipient: 'a', amount: 1 })[0].progress[0].amount, maximum);
assert.throws(() => recordBribeExpressionPayment([near], 'payer', 2, { recipient: 'a', amount: 2 }), BribeExpressionPromiseError);
assert.deepEqual(candidates([], [promise('too-large', group('and', pay('a', maximum), pay('b', 1)))]), []);
assert.deepEqual(candidates([], [promise('exact', group('and', pay('a', maximum - 1), pay('b', 1)))]),
[[{ recipient: 'a', amount: maximum - 1 }, { recipient: 'b', amount: 1 }]]);
const partial = promise('nonselected', group('and', pay('a', 1), pay('b', 2)), { progress: progress('a', maximum) });
assert.deepEqual(candidates([atomic('requires-one-more', 'a', 1)], [partial]), []);
assert.deepEqual(candidates([], [partial]), [[{ recipient: 'b', amount: 2 }]]);
});
void test('progress and event inputs require exact unique recipients, safe amounts and matching expression leaves', () => {
const invalidProgress: unknown[] = [null, {}, progress('a', -1), progress('a', 0.5), progress('a', Number.MAX_SAFE_INTEGER + 1),
[...progress('a', 1), ...progress('a', 2)], progress(' a', 1), [{ recipient: 'a', amount: 1, receipt: true }]];
for (const input of invalidProgress)
assert.throws(() => evaluateBribeExpression(pay(), input as BribeExpressionProgress), BribeExpressionPromiseError);
for (const payment of [{ recipient: 'a', amount: 0 }, { recipient: 'a', amount: -1 }, { recipient: 'a', amount: 0.5 },
{ recipient: 'a', amount: Infinity }, { recipient: 'a', amount: Number.MAX_SAFE_INTEGER + 1 },
{ recipient: 'a ', amount: 1 }, { recipient: 'a', amount: 1, receipt: true }])
assert.throws(() => recordBribeExpressionPayment([], 'payer', 2, payment), BribeExpressionPromiseError);
assert.throws(() => candidates([], [promise('unmatched-progress', pay(), { progress: progress('b', 1) })]), BribeExpressionPromiseError);
});
void test('ledger validation binds unique events, participants, order and whole-answer terminal consistency', () => {
const valid = [promise('live', pay(), { progress: progress('a', 2), order: 1 }),
promise('fulfilled', pay(), { progress: progress('a', 3), fulfilled: true }),
promise('closed-no', pay(), { answer: false, fulfilled: true }), promise('released', pay(), { released: true }),
promise('asker-recipient', pay('asker'))];
const before = structuredClone(valid);
assert.doesNotThrow(() => validateBribeExpressionPromises(valid, seats));
assert.doesNotThrow(() => validateBribeExpressionPromises(JSON.parse(JSON.stringify(valid)) as BribeExpressionPromise[], seats));
const base = promise('event');
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
...[{ event: '' }, { player: 'absent' }, { asker: 'absent' }, { asker: 'payer' }, { claim: pay('absent') }, { claim: pay('payer') },
{ answer: 'yes' }, { fulfilled: 1 }, { released: 1 }, { fulfilled: true }, { fulfilled: true, released: true },
{ progress: progress('a', 3) }, { answer: false, progress: progress('a', 3) },
{ answer: false, progress: progress('a', 3), released: true }, { turn: 0 }, { order: 0 }, { order: 1.5 }]
.map(extra => [{ ...base, ...extra }])];
for (const records of invalid)
assert.throws(() => validateBribeExpressionPromises(records as BribeExpressionPromise[], seats), BribeExpressionPromiseError);
assert.deepEqual(valid, before);
});
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import type { Action, Game } from '../game/engine';
import { parseBribeExpression, type BribeExpression } from '../game/bribe-expression-promises';
import {
actTruthBribe, advanceTruthBribe, answerTruthBribe, declareTruthBribe, finishTruthBribe,
nextTruthBribe, settleTruthBribe, truthBribeBalances, truthBribeClean, truthBribeDeferred,
truthBribeInventory, truthBribePlayer, createTruthBribeFixture,
type TruthBribeStep, type TruthBribeTransition,
} from './fixture-truth-bribe';
import { createMentatBribeFixture } from './fixture-mentat-bribes';
export type TruthBribeExpressionStep = TruthBribeStep;
export type TruthBribeExpressionTransition = TruthBribeTransition;
export const actTruthBribeExpression = actTruthBribe;
export const nextTruthBribeExpression = nextTruthBribe;
export const advanceTruthBribeExpression = advanceTruthBribe;
export const settleTruthBribeExpression = settleTruthBribe;
export const finishTruthBribeExpression = finishTruthBribe;
export const truthBribeExpressionClean = truthBribeClean;
export const truthBribeExpressionPlayer = truthBribePlayer;
export const truthBribeExpressionInventory = truthBribeInventory;
export const truthBribeExpressionBalances = truthBribeBalances;
export const truthBribeExpressionDeferred = truthBribeDeferred;
/** The original public H/A/E base33 source supplies the exact unopened deals,
* ordinary auctions and ten spendable Emperor spice. Phase7/8 follows its real
* Collection/Mentat controls. Only the question changes: no played clock,
* purse, hand, receipt, answer or promise is installed by this adapter. */
export function createTruthBribeExpressionFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; phase?: 3 | 7 | 8; claim?: BribeExpression;
} = {}) {
const phase = options.phase ?? 3;
const base = phase === 8 ? createMentatBribeFixture({ advanced: options.advanced }) :
createTruthBribeFixture({ advanced: options.advanced, window: options.window, phase });
const claim = parseBribeExpression(options.claim ?? { kind: 'and', terms: [
{ kind: 'payment', recipient: base.asker, minimum: 3 },
{ kind: 'payment', recipient: base.opponent, minimum: 2 },
] });
const question = { kind: 'bribeExpression' as const, target: base.target, turn: base.game.turn, claim };
return { ...base, base, phase, claim, question, questionAction: { type: 'truthAsk', question } as Action,
sourceDisclosure: `${base.sourceDisclosure} This thin adapter changes only the grouped ordinary-payment question; each recipient starts at zero future payment progress after its real answer.` };
}
export type TruthBribeExpressionFixture = ReturnType<typeof createTruthBribeExpressionFixture>;
export function declareTruthBribeExpression(f: TruthBribeExpressionFixture, state = f.game,
options: { copies?: 1 | 2 } = {}) {
return declareTruthBribe(f, state, options);
}
export function askTruthBribeExpression(game: Game, asker: string, target: string, claim: BribeExpression,
actions: TruthBribeExpressionStep[] = []): Game {
return actTruthBribeExpression(game, asker, { type: 'truthAsk', question: {
kind: 'bribeExpression', target, turn: game.turn, claim: parseBribeExpression(claim),
} }, actions);
}
export const answerTruthBribeExpression = answerTruthBribe;
export function bindTruthBribeExpression(f: TruthBribeExpressionFixture, answer: 'yes' | 'no' = 'yes',
claim = f.claim, state = f.game) {
const declared = declareTruthBribeExpression(f, state), actions = [...declared.actions];
const asked = askTruthBribeExpression(declared.game, f.asker, f.target, claim, actions);
const game = answerTruthBribeExpression(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
/** Preserve the actual opening boundary. A question asked after opening waits
* through the next real Storm and Collection; an earlier payment is credited
* at this turn's opening. The adapter never collects receipts itself. */
export function completeTruthBribeExpression(f: TruthBribeExpressionFixture, state = f.game) {
let game = state; const turn = game.turn, late = game.phase === 8;
const actions: TruthBribeExpressionStep[] = [];
const transition = (until: (state: Game) => boolean): TruthBribeExpressionTransition => {
const before = structuredClone(game), from = actions.length;
game = advanceTruthBribeExpression(game, until, actions);
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
const boundary = () => transition(state => state.turn === turn + 1 && state.phase === 0 && truthBribeExpressionClean(state));
const nextTurnBefore = late ? boundary() : null;
const openingTurn = turn + Number(late);
const collection = transition(state => state.turn === openingTurn && state.phase === 7 && truthBribeExpressionClean(state));
const mentat = transition(state => state.turn === openingTurn && state.phase === 8 && truthBribeExpressionClean(state));
const nextTurn = nextTurnBefore ?? boundary();
assert.deepEqual(truthBribeExpressionInventory(game), f.originalCards);
return { game, collection, mentat, nextTurn, actions, actionCount: actions.length };
}
+3 -2
View File
@@ -130,13 +130,14 @@ void test('Basic native selected families qualify, while optional modules and un
}
});
void test('earlier current-round movement question profiles include declared Basic native families', () => {
void test('earlier current-round movement question profiles include declared Basic and Advanced native families', () => {
for (const phase of [0, 1, 2, 3, 4]) {
const classic = movementBoundary(); classic.phase = phase; classic.movementRemaining = [];
assert.equal(movementPromiseTargetAvailable(classic, 'target'), true);
const native = structuredClone(classic); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), false);
classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), true);
native.advanced = true; assert.equal(movementPromiseTargetAvailable(native, 'target'), true);
}
for (const phase of [-1, 6, 8]) {
const g = movementBoundary(); g.phase = phase;
+20
View File
@@ -159,6 +159,26 @@ void test('controlled bribe callbacks expire when their original phase changes b
}
});
void test('the shared bribe guide honors its exact live scalar or whole-expression event when both ledgers exist', () => {
for (const source of ['scalar', 'expression'] as const) {
const game = projected(), sent: Action[] = [];
game.bribeExpressionPromises = [{ event: 'controlled-whole-bribe-event', player: 'p', asker: 'a', turn: 2,
claim: { kind: 'or', terms: [{ kind: 'payment', recipient: 'r', minimum: 3 }, { kind: 'payment', recipient: 'a', minimum: 2 }] },
answer: true, progress: [] }];
game.bribePromiseGuide!.action.bribePromiseEvent = source === 'scalar' ? game.bribePromises[0].event : game.bribeExpressionPromises[0].event;
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button);
assert.equal(sent[0], game.bribePromiseGuide!.action);
if (source === 'scalar') game.bribePromises[0].fulfilled = true;
else game.bribeExpressionPromises[0].released = true;
press(button); assert.equal(sent.length, 1, 'A different live event does not preserve the old callback.');
game.bribePromiseGuide = { action: { ...game.bribePromiseGuide!.action,
bribePromiseEvent: source === 'scalar' ? game.bribeExpressionPromises[0].event : game.bribePromises[0].event } };
const current = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(current.props.disabled, false); press(current); assert.equal(sent.length, 2);
}
});
void test('stale bribe guides respect current scope, exact recipient, strictly clear timing and projected payment bounds', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.status = 'setup'; },
@@ -0,0 +1,232 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { BribeExpressionFields, bribeExpressionInputError } from '../components/bribe-expression-fields';
import { BribeExpressionPromises } from '../components/bribe-expression-promises';
import { BribePromises, BribePromiseGuideButton } from '../components/bribe-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { parseBribeExpression, type BribeExpression } from '../game/bribe-expression-promises';
import { createTechTokens } from '../game/tech-tokens';
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
const recipients = [{ id: 'r', name: 'Recipient' }, { id: 'a', name: 'Asker' }];
const payment = (minimum = 1, recipient = 'r'): BribeExpression => ({ kind: 'payment', recipient, minimum });
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function find(tree: ReactNode, key: string, value: string): Element {
const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found;
}
const control = (tree: ReactNode, key: string) => find(tree, 'data-bribe-expression-control', key);
const click = (button: Element) => (button.props.onClick as () => void)();
function select(tree: ReactNode, path: string, field: string, value: string) {
const input = find(tree, 'id', `truth-bribe-expression-${path}-${field}`);
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
const input = find(tree, 'id', `truth-bribe-expression-${path}-minimum`);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
}
function editor(initial: BribeExpression, disabled = false, choices = recipients) {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => BribeExpressionFields({ value, recipients: choices, disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(BribeExpressionFields, { value, recipients: choices, disabled, onChange() {} })),
};
}
function nested(depth: number): BribeExpression {
let value = payment();
for (let i = 0; i < depth; i++) value = { kind: 'and', terms: [value, payment(2, 'a')] };
return value;
}
function guide(props: Parameters<typeof BribePromiseGuideButton>[0]) {
const button = BribePromiseGuideButton(props); assert.ok(button); return button;
}
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(BribeExpressionPromises, { game, busy, send() {} }));
/** These personalized projections exercise controlled UI behavior. They do not
* install authoritative promises, cards, funding or ordinary payments. */
function projected(phase = 3, advanced = false): GameView {
const source = createGame('TRUTHBRIBEEXPRESSIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
joinGame(source, newPlayer('r', 'Recipient', 'harkonnen'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'a';
game.auction = { bid: 0, bidder: null, active: 'a', remaining: 2, card: null, inspectionBlocked: null };
game.bribeOptions = { available: 8, incoming: 0, blocked: null,
targets: [{ id: 'r', maximum: 6, limitation: null }, { id: 'a', maximum: 8, limitation: null }] };
game.bribeExpressionPromises = [{ event: 'controlled-bribe-expression-event', player: 'p', asker: 'a', turn: 2,
claim: { kind: 'or', terms: [payment(3), { kind: 'and', terms: [payment(2, 'a'), payment(5)] }] },
answer: true, progress: [{ recipient: 'r', amount: 1 }] }];
game.bribePromiseGuide = { action: { type: 'bribe', target: 'r', amount: 2, bribePromiseEvent: 'controlled-bribe-expression-event' } };
return game;
}
void test('whole-tree callbacks author all sixteen conditions, nested AND or OR, exact recipients and singleton collapse', () => {
const e = editor(payment(2)); click(control(e.tree(), 'or-1'));
assert.ok(e.value.kind === 'or'); assert.deepEqual(e.value.terms[1], payment(1, ''));
assert.ok(bribeExpressionInputError(e.value)); select(e.tree(), '1.2', 'recipient', 'a');
for (let count = 3; count <= 16; count++) {
click(control(e.tree(), 'add-1')); minimum(e.tree(), `1.${count}`, count);
select(e.tree(), `1.${count}`, 'recipient', count % 2 ? 'r' : 'a');
}
assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16);
assert.deepEqual(parseBribeExpression(e.value), e.value); assert.match(e.html(), /16 \/ 16 conditions/);
assert.equal((e.html().match(/Minimum future ordinary bribe payments in spice/g) ?? []).length, 16);
assert.match(e.html(), /max="9007199254740991"/);
const before = structuredClone(e.value), changes = e.changes;
for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
const grouped = editor({ kind: 'and', terms: [payment(3), payment(2, 'a')] });
click(control(grouped.tree(), 'or-1.2')); select(grouped.tree(), '1.2', 'meaning', 'and');
click(control(grouped.tree(), 'nested-or-1')); click(control(grouped.tree(), 'nested-and-1'));
assert.ok(grouped.value.kind === 'and'); assert.equal(grouped.value.terms.length, 4);
click(control(grouped.tree(), 'remove-1.2.2')); assert.deepEqual(grouped.value.terms[1], payment(2, 'a'));
click(control(grouped.tree(), 'remove-1.4')); click(control(grouped.tree(), 'remove-1.3'));
click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, payment(2, 'a'));
});
void test('four-group depth limits hold while numeric drafts and new empty recipient choices remain repairable', () => {
const deep = editor(nested(4)); assert.equal(bribeExpressionInputError(deep.value), null);
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1']) {
assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
}
assert.equal(deep.changes, 0); minimum(deep.tree(), '1.1.1.1.1', Number.MAX_SAFE_INTEGER); assert.equal(deep.changes, 1);
for (const invalid of [NaN, -1, 0, 0.5, Number.MAX_SAFE_INTEGER + 1, Infinity]) {
const e = editor(payment()); minimum(e.tree(), '1', invalid);
const draft = e.value; assert.ok(draft.kind === 'payment'); assert.ok(Object.is(draft.minimum, invalid));
assert.ok(bribeExpressionInputError(e.value)); assert.match(e.html(), /role="alert"/);
click(control(e.tree(), 'or-1')); const wrapped = e.value; assert.ok(wrapped.kind === 'or');
assert.deepEqual(wrapped.terms[0], payment(invalid));
minimum(e.tree(), '1.1', 2); select(e.tree(), '1.2', 'recipient', 'a');
assert.equal(bribeExpressionInputError(e.value), null);
}
for (const invalid of [nested(5), { kind: 'and', terms: [payment()] }, { kind: 'or', terms: Array.from({ length: 17 }, () => payment()) },
{ kind: 'payment', recipient: '', minimum: 1 }, { kind: 'payment', recipient: 'r', minimum: 0 },
{ kind: 'gift', recipient: 'r', minimum: 1 }]) assert.ok(bribeExpressionInputError(invalid));
const empty = editor(payment(1, ''), false, []); minimum(empty.tree(), '1', 2);
assert.deepEqual(empty.value, payment(2, '')); assert.ok(bribeExpressionInputError(empty.value));
select(empty.tree(), '1', 'recipient', 'r'); assert.equal(empty.changes, 1);
});
void test('disabled editor callbacks and unknown recipients cannot overwrite the controlled tree', () => {
const frozen = editor({ kind: 'and', terms: [payment(), payment(2, 'a')] }, true), before = structuredClone(frozen.value);
for (const key of ['add-1', 'nested-and-1', 'or-1', 'remove-1.2']) click(control(frozen.tree(), key));
minimum(frozen.tree(), '1.1', 4); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'recipient', 'r');
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
const e = editor(payment()); select(e.tree(), '1', 'recipient', 'unknown'); select(e.tree(), '1', 'recipient', '');
assert.equal(e.changes, 0); select(e.tree(), '1', 'recipient', 'a'); assert.deepEqual(e.value, payment(1, 'a'));
});
void test('own panels preserve the complete unpinned expression, per-recipient progress, whole No and terminal status', () => {
const game = projected(); game.bribePromiseGuide = null;
game.bribeExpressionPromises.push(
{ ...game.bribeExpressionPromises[0], event: 'no-expression', answer: false, claim: { kind: 'and', terms: [payment(3), payment(2, 'a')] } },
{ ...game.bribeExpressionPromises[0], event: 'fulfilled-expression', fulfilled: true },
{ ...game.bribeExpressionPromises[0], event: 'released-expression', released: true },
{ ...game.bribeExpressionPromises[0], event: 'foreign-expression', player: 'a', claim: payment(999, 'private-foreign-recipient') },
);
const before = structuredClone(game), html = markup(game);
assert.match(html, /Your bribe expression promises/); assert.match(html, / OR /); assert.match(html, / AND /);
assert.match(html, /Recorded future ordinary payments: Recipient: 1 spice; Asker: 0 spice/);
assert.match(html, /No negates the whole expression, not each condition separately/);
assert.match(html, /Legal alternatives remain available; no fixed branch is selected for you/);
assert.match(html, /Smaller payments to the same recipient add together after the answer/);
assert.match(html, /Prior payments, gifts and ally aid do not count/);
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
assert.match(html, /href="\/rules#truthtrance-bribe-expression"/);
assert.doesNotMatch(html, /999|private-foreign-recipient|controlled-bribe-expression-event/); assert.deepEqual(game, before);
assert.equal(BribeExpressionPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('panels explain next-opening receipt, late next-turn collection and No lasting beyond opening collection', () => {
const game = projected();
for (const phase of [0, 1, 2, 3, 4, 5, 6, 7]) {
game.phase = phase; const html = markup(game);
assert.match(html, /Your next Mentat opening is still ahead/);
assert.match(html, /Accepted payments count now and their recipients collect at that opening/);
assert.match(html, /No stays binding until the actual turn ends; opening collection does not end it/);
}
game.phase = 8; const html = markup(game);
assert.match(html, /Late payments still honor this turn’s answers; their recipients collect at next turn’s Mentat opening/);
assert.match(html, /The sender pays immediately/); assert.match(html, /Collection does not count the payment a second time/);
assert.match(html, /Payments made during Mentat Pause wait until the start of next turn&#x27;s Mentat Pause/);
game.bribeExpressionPromises[0].fulfilled = true;
assert.doesNotMatch(markup(game), /No stays binding until the actual turn ends/);
});
void test('one shared guide handles scalar and whole promises without duplicating the payment action', () => {
const game = projected(); assert.match(markup(game), /data-testid="bribe-promise-guide"/);
game.bribePromises = [{ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, recipient: 'r', minimum: 1,
answer: true, progress: 1, fulfilled: true }];
assert.doesNotMatch(markup(game), /data-testid="bribe-promise-guide"/);
const both = renderToStaticMarkup(createElement('div', null,
createElement(BribePromises, { game, busy: false, send() {} }),
createElement(BribeExpressionPromises, { game, busy: false, send() {} })));
assert.equal((both.match(/data-testid="bribe-promise-guide"/g) ?? []).length, 1);
assert.match(both, /Pay 2 spice to Recipient while honoring your answers/);
game.bribePromises[0].player = 'a'; assert.match(markup(game), /data-testid="bribe-promise-guide"/);
});
void test('actual shared guide callbacks send the exact whole-expression native payment in every supported phase', () => {
for (const advanced of [false, true]) for (let phase = 0; phase <= 8; phase++) {
const game = projected(phase, advanced), sent: Action[] = [];
game.ready = ['p'];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.bribePromiseGuide!.action]); assert.equal(sent[0], game.bribePromiseGuide!.action);
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
}
});
void test('stale aggregate-event callbacks retain current ownership, exact phase, funding, room and quiet-window guards', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 8; }, game => { game.status = 'setup'; },
game => { game.bribePromiseGuide = { action: { ...game.bribePromiseGuide!.action } }; },
game => { game.bribePromiseGuide = null; }, game => { game.bribePromiseGuide!.action.amount = 3; },
game => { game.bribeExpressionPromises[0].event = 'expired'; }, game => { game.bribeExpressionPromises[0].player = 'a'; },
game => { game.bribeExpressionPromises[0].turn = 1; }, game => { game.bribeExpressionPromises[0].fulfilled = true; },
game => { game.bribeExpressionPromises[0].released = true; }, game => { game.bribeExpressionPromises = []; },
game => { game.players = game.players.filter(player => player.id !== 'r'); },
game => { game.players.find(player => player.id === 'p')!.ally = 'r'; },
game => { game.bribeOptions.blocked = 'Finish a decision first.'; }, game => { game.bribeOptions.blocked = ''; },
game => { game.bribeOptions.available = 1; }, game => { game.bribeOptions.available = NaN; },
game => { game.bribeOptions.targets[0].maximum = 1; }, game => { game.bribeOptions.targets[0].maximum = NaN; },
game => { game.bribeOptions.targets = []; }, game => { game.phaseOpening = { passed: [] }; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.techTokens = createTechTokens(); },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [];
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy expression guide cannot dispatch.'); } };
const button = guide(props); props.busy = true; press(button);
for (const event of ['', 'unrelated-event']) {
const game = projected(), sent: Action[] = [];
game.bribePromiseGuide!.action.bribePromiseEvent = event;
const current = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(current.props.disabled, true); press(current); assert.deepEqual(sent, []);
}
const game = projected(), sent: Action[] = [];
for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'bribes', 'reserves', 'tanks'])
Object.defineProperty(seat, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden lot inspection'); } });
assert.match(markup(game), /Pay 2 spice to Recipient while honoring your answers/);
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, [game.bribePromiseGuide!.action]);
});
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import type { BribeExpression } from '../game/bribe-expression-promises';
import {
actTruthLeaderRevival as act, createTruthLeaderRevivalFixture as fixture,
declareTruthLeaderRevival as declare, finishTruthLeaderRevival as finish,
settleTruthLeaderRevival as settle, truthLeaderRevivalInventory as inventory,
truthLeaderRevivalPlayer as player,
} from './fixture-truth-leader-revival';
import { askTruthBribeExpression as ask, answerTruthBribeExpression as answer } from './fixture-truth-bribe-expression';
for (const first of ['bribe', 'revival'] as const) void test(`native grouped payment progress retains shared ordinary leader funding with ${first} accepted first`, () => {
// Inherit the original H/A/T Ix47 shipment/projectile-death/next-Storm
// source. T owns four real spice and a dead native Hidar; no purse, disc,
// question or promise is installed by this funding supplement.
const f = fixture(); assert.equal(f.originalCards.length, 47); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(player(f.game, f.target).spice, 4);
const claim: BribeExpression = { kind: 'and', terms: [
{ kind: 'payment', recipient: f.asker, minimum: 1 },
{ kind: 'payment', recipient: f.opponent, minimum: 1 },
] };
let game = declare(f, f.game, { copies: 2 }).game;
const bribe = () => { game = answer(ask(game, f.asker, f.target, claim), f.target, 'yes'); };
const revival = () => {
game = act(game, f.asker, { type: 'truthAsk', question: f.question });
assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['yes', 'no']);
game = answer(game, f.target, 'yes');
};
if (first === 'bribe') { bribe(); revival(); } else { revival(); bribe(); }
const before = structuredClone(game);
// T's actual free force return can prepare one owned spice; even that
// native income cannot rescue H3 while A1 and the leader's2 remain due.
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 })); assert.deepEqual(game, before);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
assert.equal(player(game, f.target).spice, 3);
assert.deepEqual(game.bribeExpressionPromises![0].progress, [{ recipient: f.asker, amount: 1 }]);
assert.ok(!game.bribeExpressionPromises![0].fulfilled); assert.ok(!game.leaderRevivalPromises![0].fulfilled);
game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 1 });
assert.equal(player(game, f.target).spice, 2); assert.equal(game.bribeExpressionPromises![0].fulfilled, true);
const guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'reviveLeader'); assert.equal(guide.action.leader, f.leader);
game = settle(act(game, f.target, guide.action));
assert.equal(player(game, f.target).spice, 0); assert.equal(game.leaderRevivalPromises![0].fulfilled, true);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
assert.equal(player(game, f.asker).bribes, 1); assert.equal(player(game, f.opponent).bribes, 1);
for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
@@ -0,0 +1,267 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { BribeExpression } from '../game/bribe-expression-promises';
import { enterMentatBribeWithUnpaidPromise } from './fixture-mentat-bribes';
import {
actTruthBribeExpression as act, answerTruthBribeExpression as answer,
askTruthBribeExpression as ask, bindTruthBribeExpression as bind,
completeTruthBribeExpression as complete, createTruthBribeExpressionFixture as fixture,
declareTruthBribeExpression as declare, finishTruthBribeExpression as finish,
truthBribeExpressionBalances as balances, truthBribeExpressionInventory as inventory,
truthBribeExpressionPlayer as player,
} from './fixture-truth-bribe-expression';
const payment = (recipient: string, minimum: number): BribeExpression => ({ kind: 'payment', recipient, minimum });
const group = (kind: 'and' | 'or', ...terms: BribeExpression[]): BribeExpression => ({ kind, terms });
const promise = (game: Game, target: string, index = 0) => game.bribeExpressionPromises!.filter(row => row.player === target)[index];
const progress = (game: Game, target: string, recipient: string, index = 0) =>
promise(game, target, index).progress.find(row => row.recipient === recipient)?.amount ?? 0;
const pay = (game: Game, actor: string, target: string, amount: number) => act(game, actor, { type: 'bribe', target, amount });
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function completePayments(game: Game, target: string): Game {
for (let count = 0; count < 8 && game.bribeExpressionPromises!.some(row => row.player === target && row.answer && !row.fulfilled); count++) {
const guide = viewGame(game, target).bribePromiseGuide; assert.ok(guide);
game = act(game, target, guide.action);
}
assert.ok(game.bribeExpressionPromises!.filter(row => row.player === target && row.answer).every(row => row.fulfilled));
return game;
}
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} grouped bribe AND requires both actual cumulative recipient payments before its one opening receipt`, () => {
const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.originalCards.length, 33); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
const source = structuredClone(f.game), before = balances(f.game), bound = bind(f);
assert.deepEqual(f.game, source); assert.deepEqual(promise(bound.game, f.target).progress, []);
assert.equal(bound.game.auction!.active, f.asker); assert.notEqual(bound.game.auction!.active, f.target);
let game = pay(bound.game, f.target, f.asker, 1);
assert.equal(progress(game, f.target, f.asker), 1); assert.ok(!promise(game, f.target).fulfilled);
game = pay(game, f.target, f.asker, 2);
assert.equal(progress(game, f.target, f.asker), 3); assert.equal(progress(game, f.target, f.opponent), 0);
assert.ok(!promise(game, f.target).fulfilled); assert.equal(player(game, f.target).spice, 7);
game = pay(game, f.target, f.opponent, 2);
assert.equal(progress(game, f.target, f.opponent), 2); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).spice, 5);
for (const recipient of [f.asker, f.opponent]) assert.equal(player(game, recipient).spice, before[recipient]);
assert.equal(player(game, f.asker).bribes, 3); assert.equal(player(game, f.opponent).bribes, 2);
const result = complete(f, game);
for (const [recipient, amount] of [[f.asker, 3], [f.opponent, 2]] as const) {
assert.equal(player(result.mentat.before, recipient).bribes, amount);
assert.equal(player(result.mentat.after, recipient).spice - player(result.mentat.before, recipient).spice, amount);
assert.equal(player(result.mentat.after, recipient).bribes, 0);
assert.equal(progress(result.game, f.target, recipient), amount);
}
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0);
assert.equal(result.game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('native grouped bribe OR accepts the actual alternate recipient instead of saving its suggested route', () => {
const f = fixture(), claim = group('or', payment(f.asker, 3), payment(f.opponent, 2));
let game = bind(f, 'yes', claim).game;
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide);
assert.ok(typeof guide.action.target === 'string');
const recipient = guide.action.target === f.asker ? f.opponent : f.asker;
const amount = recipient === f.asker ? 3 : 2;
assert.notEqual(recipient, guide.action.target); game = pay(game, f.target, recipient, amount);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 10 - amount);
assert.equal(progress(game, f.target, recipient), amount);
assert.equal(progress(game, f.target, guide.action.target), 0);
rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards);
});
for (const first of ['harkonnen', 'atreides']) void test(`native grouped bribe AND No permits ${first} first and rejects only completing both recipients`, () => {
const f = fixture(); let game = bind(f, 'no').game;
const other = first === f.asker ? f.opponent : f.asker;
const firstMinimum = first === f.asker ? 3 : 2, otherMinimum = other === f.asker ? 3 : 2;
game = pay(game, f.target, first, firstMinimum);
game = pay(game, f.target, other, otherMinimum - 1);
assert.equal(progress(game, f.target, first), firstMinimum);
assert.equal(progress(game, f.target, other), otherMinimum - 1); assert.ok(!promise(game, f.target).fulfilled);
rejected(game, f.target, { type: 'bribe', target: other, amount: 1 });
game = finish(game); assert.equal(game.turn, 2); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native grouped bribe OR No caps either qualifying recipient while allowing smaller cumulative payments', () => {
const f = fixture(); let game = bind(f, 'no', group('or', payment(f.asker, 3), payment(f.opponent, 2))).game;
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 2 });
game = pay(game, f.target, f.asker, 2); game = pay(game, f.target, f.opponent, 1);
for (const recipient of [f.asker, f.opponent]) rejected(game, f.target, { type: 'bribe', target: recipient, amount: 1 });
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).spice, 7); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native repeated recipient leaves share one actual payment total rather than charging their thresholds twice', () => {
const f = fixture(), claim = group('and', payment(f.asker, 3), payment(f.asker, 5));
let game = bind(f, 'yes', claim).game;
game = pay(game, f.target, f.asker, 3);
assert.equal(progress(game, f.target, f.asker), 3); assert.ok(!promise(game, f.target).fulfilled);
game = pay(game, f.target, f.asker, 2);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(promise(game, f.target).progress.length, 1);
assert.equal(progress(game, f.target, f.asker), 5); assert.equal(player(game, f.target).spice, 5);
assert.equal(player(game, f.asker).bribes, 5); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native prior bribes stay in the real receipt but are excluded from newly answered grouped progress', () => {
const f = fixture(); let game = pay(f.game, f.target, f.asker, 3);
game = bind(f, 'yes', f.claim, game).game;
assert.deepEqual(promise(game, f.target).progress, []); assert.equal(player(game, f.asker).bribes, 3);
game = pay(game, f.target, f.opponent, 2); assert.ok(!promise(game, f.target).fulfilled);
game = pay(game, f.target, f.asker, 3);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(progress(game, f.target, f.asker), 3);
assert.equal(player(game, f.asker).bribes, 6); assert.equal(player(game, f.target).spice, 2);
const result = complete(f, game);
assert.equal(player(result.mentat.after, f.asker).spice - player(result.mentat.before, f.asker).spice, 6);
assert.equal(progress(result.game, f.target, f.asker), 3); assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('two original Truths share scalar H7 with grouped OR H7 A4 but spending A4 must retain the scalar H7', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
turn: game.turn, recipient: f.asker, minimum: 7 } });
game = answer(game, f.target, 'yes'); game = ask(game, f.asker, f.target, group('or', payment(f.asker, 7), payment(f.opponent, 4)));
// Earlier H7 Yes already makes H7 OR A4 true; No cannot evade it.
assert.deepEqual(viewGame(game, f.target).truthBribeExpressionAnswers, ['yes']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' });
game = answer(game, f.target, 'yes');
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 });
game = pay(game, f.target, f.opponent, 3); assert.ok(!promise(game, f.target).fulfilled);
game = pay(game, f.target, f.asker, 7);
assert.equal(game.bribePromises![0].progress, 7); assert.equal(game.bribePromises![0].fulfilled, true);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 0);
assert.equal(progress(game, f.target, f.opponent), 3); assert.equal(progress(game, f.target, f.asker), 7);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truths retain scalar No H5 while a grouped OR Yes uses actual A2', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target,
turn: game.turn, recipient: f.asker, minimum: 5 } });
game = answer(game, f.target, 'no'); game = ask(game, f.asker, f.target, group('or', payment(f.asker, 5), payment(f.opponent, 2)));
game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide);
assert.ok(typeof guide.action.amount === 'number');
assert.equal(guide.action.target, f.opponent); assert.equal(guide.action.amount, 2);
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 5 });
game = act(game, f.target, guide.action);
assert.equal(game.bribePromises![0].progress, 0); assert.ok(!game.bribePromises![0].fulfilled);
assert.equal(progress(game, f.target, f.opponent), 2); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).spice, 8); assert.deepEqual(inventory(game), f.originalCards);
});
void test('two whole questions retain one joint purse and a dynamically available OR completion', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = answer(ask(game, f.asker, f.target, group('or', payment(f.asker, 8), payment(f.opponent, 6))), f.target, 'yes');
game = ask(game, f.asker, f.target, group('and', payment(f.asker, 3), payment(f.opponent, 3)));
assert.deepEqual(viewGame(game, f.target).truthBribeExpressionAnswers, ['yes', 'no']); game = answer(game, f.target, 'yes');
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 8 });
game = pay(game, f.target, f.asker, 3);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target, 1).fulfilled);
game = pay(game, f.target, f.opponent, 6);
assert.ok(game.bribeExpressionPromises!.every(row => row.fulfilled)); assert.equal(player(game, f.target).spice, 1);
for (const index of [0, 1]) {
assert.equal(progress(game, f.target, f.asker, index), 3); assert.equal(progress(game, f.target, f.opponent, index), 6);
}
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two whole questions preserve every No OR cap while funding the compatible A2 Yes alternative', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = answer(ask(game, f.asker, f.target, group('or', payment(f.asker, 5), payment(f.opponent, 3))), f.target, 'no');
game = answer(ask(game, f.asker, f.target, group('or', payment(f.asker, 5), payment(f.opponent, 2))), f.target, 'yes');
game = pay(game, f.target, f.asker, 4); game = pay(game, f.target, f.opponent, 2);
assert.ok(!promise(game, f.target).fulfilled); assert.equal(promise(game, f.target, 1).fulfilled, true);
for (const recipient of [f.asker, f.opponent]) rejected(game, f.target, { type: 'bribe', target: recipient, amount: 1 });
game = finish(game); assert.ok(game.bribeExpressionPromises!.every(row => row.fulfilled));
assert.equal(player(game, f.target).spice, 4); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native Mentat grouped Yes reopens only its ready payer and its late receipts wait for the actual next opening', () => {
const f = fixture({ phase: 8 }); let game = act(f.game, f.target, { type: 'ready' });
game = act(game, f.asker, { type: 'ready' }); assert.equal(game.phase, 8);
assert.ok(game.ready.includes(f.target)); assert.ok(game.ready.includes(f.asker)); assert.ok(!game.ready.includes(f.opponent));
game = bind(f, 'yes', f.claim, game).game;
assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.asker)); assert.ok(!game.ready.includes(f.opponent));
rejected(game, f.target, { type: 'ready' }); const before = balances(game);
game = pay(game, f.target, f.asker, 3); rejected(game, f.target, { type: 'ready' });
game = pay(game, f.target, f.opponent, 2); game = act(game, f.target, { type: 'ready' });
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(game.ready.includes(f.target));
for (const recipient of [f.asker, f.opponent]) assert.equal(player(game, recipient).spice, before[recipient]);
const result = complete(f, game);
assert.equal(result.nextTurn.after.turn, 2); assert.equal(result.nextTurn.after.phase, 0);
for (const [recipient, amount] of [[f.asker, 3], [f.opponent, 2]] as const) {
for (const state of [result.nextTurn.after, result.collection.after, result.mentat.before]) {
assert.equal(player(state, recipient).bribes, amount); assert.equal(player(state, f.target).spice, 5);
}
assert.equal(player(result.mentat.after, recipient).spice - player(result.mentat.before, recipient).spice, amount);
assert.equal(player(result.mentat.after, recipient).bribes, 0);
}
assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('native whole No remains binding across opening receipts and late payments until the actual turn ends', () => {
const f = fixture({ phase: 7 }); let game = bind(f, 'no').game;
game = pay(game, f.target, f.asker, 3); const before = player(game, f.asker).spice;
game = enterMentatBribeWithUnpaidPromise(game);
assert.equal(game.phase, 8); assert.equal(player(game, f.asker).spice, before + 3);
assert.equal(player(game, f.asker).bribes, 0); assert.equal(progress(game, f.target, f.asker), 3);
game = pay(game, f.target, f.opponent, 1);
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 1 });
assert.ok(!promise(game, f.target).fulfilled); game = finish(game);
assert.equal(game.turn, 2); assert.equal(promise(game, f.target).fulfilled, true);
game = pay(game, f.target, f.opponent, 2);
assert.equal(progress(game, f.target, f.opponent), 1); assert.equal(player(game, f.opponent).bribes, 3);
assert.equal(player(game, f.target).spice, 4); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native grouped question and ordinary guide retain exact target ownership current turn and payment event', () => {
const f = fixture(), declared = declare(f);
rejected(declared.game, f.opponent, f.questionAction);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, claim: group('and', payment(f.asker, 3), payment(f.target, 1)) } });
const claim = group('and', payment(f.asker, 3), payment(f.asker, 2));
let game = ask(declared.game, f.asker, f.target, claim);
assert.equal(viewGame(game, f.asker).truthBribeExpressionAnswers, null);
assert.deepEqual(viewGame(game, f.target).truthBribeExpressionAnswers, ['yes', 'no']);
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide);
assert.ok(typeof guide.action.amount === 'number');
assert.equal(guide.action.bribePromiseEvent, promise(game, f.target).event);
rejected(game, f.opponent, guide.action);
rejected(game, f.target, { ...guide.action, bribePromiseEvent: 'stale-grouped-bribe-event' });
rejected(game, f.target, { ...guide.action, amount: guide.action.amount + 1 });
game = act(game, f.target, guide.action); rejected(game, f.target, guide.action);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies submit the exact legal ordinary guide until the whole grouped AND is paid', () => {
const f = fixture({ phase: 8 }), bound = bind(f).game;
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(bound);
for (let count = 0; count < 8 && !promise(game, f.target).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const guide = view.bribePromiseGuide; assert.ok(guide, difficulty);
const action = botActions(view).find(action => action.type === 'bribe' && action.bribePromiseEvent === guide.action.bribePromiseEvent &&
action.target === guide.action.target && action.amount === guide.action.amount);
assert.ok(action, difficulty); game = act(game, f.target, action);
}
assert.equal(promise(game, f.target).fulfilled, true, difficulty);
assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.asker).bribes, 3);
assert.equal(player(game, f.opponent).bribes, 2); assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('native exact grouped guides complete both recipients without changing the original auction turn', () => {
const f = fixture(), game = completePayments(bind(f).game, f.target);
assert.equal(game.auction!.active, f.game.auction!.active); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(progress(game, f.target, f.asker), 3); assert.equal(progress(game, f.target, f.opponent), 2);
assert.equal(player(game, f.target).spice, 5); assert.deepEqual(inventory(game), f.originalCards);
});