Support whole AND/OR ordinary bribe commitments
This commit is contained in:
1 parent
c81e00e6dd
commit
0e3c155876
21 files changed
+1778
-111
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'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);
|
||||
});
|
||||
Reference in new issue
Block a user