Add cumulative Truthtrance bribe-payment commitments

This commit is contained in:
Quintine committed 2026-10-26 13:29:37 +11:00
1 parent 7fc21113e3
commit 335b4ce638
18 files changed
+1453 -21

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
BribePromiseError,
bribeClaimText,
bribePromiseModeSupported,
bribePromiseRequirements,
liveBribePromises,
parseBribeClaim,
recordPromisedBribe,
validateBribePromises,
type BribePromise,
} from '../game/bribe-promises';
const seats = ['payer', 'asker', 'recipient-a', 'recipient-b', 'other-payer'] as const;
function promise(event: string, minimum: number, extra: Partial<BribePromise> = {}): BribePromise {
return { event, turn: 2, recipient: 'recipient-a', minimum, player: 'payer', asker: 'asker',
answer: true, progress: 0, ...extra };
}
void test('bribe claims canonicalize positive safe turn and threshold while retaining an exact trimmed recipient', () => {
const input = { turn: 2, recipient: 'recipient-a', minimum: 6, priorPayments: 4, privateSpice: 9 };
const parsed = parseBribeClaim(input);
assert.deepEqual(parsed, { turn: 2, recipient: 'recipient-a', minimum: 6 });
assert.notEqual(parsed, input);
assert.deepEqual(parseBribeClaim({ turn: Number.MAX_SAFE_INTEGER, recipient: 'recipient-a', minimum: Number.MAX_SAFE_INTEGER }),
{ turn: Number.MAX_SAFE_INTEGER, recipient: 'recipient-a', minimum: Number.MAX_SAFE_INTEGER });
for (const field of ['turn', 'minimum'] as const)
for (const value of [0, -1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '6', null, undefined])
assert.throws(() => parseBribeClaim({ ...input, [field]: value }), BribePromiseError);
for (const recipient of ['', ' ', ' recipient-a', 'recipient-a ', 1, null, undefined])
assert.throws(() => parseBribeClaim({ ...input, recipient }), BribePromiseError);
for (const value of [null, undefined, [], 'six', 6]) assert.throws(() => parseBribeClaim(value), BribePromiseError);
assert.match(bribeClaimText(parsed, id => id === 'recipient-a' ? 'Atreides' : id), /at least 6 spice to Atreides/);
assert.match(bribeClaimText(parsed), /ordinary bribes from now until this turn ends/);
});
void test('ordinary bribe scope preserves bare classic and selected native modes without widening optional modules', () => {
const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'],
players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) };
for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false },
{ ...classic, expansions: ['ix', 'choam', 'ecaz'] },
{ ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] },
]) assert.equal(bribePromiseModeSupported(game), true);
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
assert.equal(bribePromiseModeSupported({ ...native, [option]: true }), false, option);
for (const game of [
{ ...native, expansions: ['ix', 'choam'] }, { ...native, expansions: ['ix', 'ix', 'ecaz'] },
{ ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
{ ...classic, players: [{ faction: 'atreides' }] }, { ...classic, players: [...native.players, { faction: 'atreides' }] },
{ ...classic, advanced: 'basic' as unknown as boolean },
]) assert.equal(bribePromiseModeSupported(game), false);
});
void test('future payments accumulate only for the exact recipient and a later answer excludes earlier payments', () => {
const original = [promise('first-answer', 5), promise('other-recipient', 2, { recipient: 'recipient-b' })];
const first = recordPromisedBribe(original, 'payer', 2, { recipient: 'recipient-a', amount: 2 });
assert.equal(first[0].progress, 2); assert.equal(first[0].fulfilled, undefined); assert.equal(first[1], original[1]);
const second = recordPromisedBribe(first, 'payer', 2, { recipient: 'recipient-a', amount: 3 });
assert.equal(second[0].progress, 5); assert.equal(second[0].fulfilled, true);
const later = [...second, promise('later-answer', 2)];
assert.deepEqual(bribePromiseRequirements(later, 'payer', 2), [
{ recipient: 'recipient-b', amount: 2 }, { recipient: 'recipient-a', amount: 2 },
], 'The earlier five-spice payment does not prepay the newly answered question');
const third = recordPromisedBribe(later, 'payer', 2, { recipient: 'recipient-a', amount: 1 });
assert.equal(third[0], second[0]); assert.equal(third[2].progress, 1); assert.equal(third[2].fulfilled, undefined);
assert.equal(original[0].progress, 0); assert.equal(first[0].progress, 2);
});
void test('overlapping Yes answers share a recipient payment while different recipients require separate spice', () => {
const records = [promise('a-five', 5, { progress: 2 }), promise('a-eight', 8, { progress: 3 }),
promise('a-below-ten', 10, { answer: false, progress: 4 }),
promise('b-four', 4, { recipient: 'recipient-b', progress: 1 })];
assert.deepEqual(bribePromiseRequirements(records, 'payer', 2), [
{ recipient: 'recipient-a', amount: 5 }, { recipient: 'recipient-b', amount: 3 },
], 'Use maximum additional Yes deficit per recipient, not the sum of overlapping questions');
const paidA = recordPromisedBribe(records, 'payer', 2, { recipient: 'recipient-a', amount: 5 });
assert.deepEqual(paidA.map(record => record.progress), [7, 8, 9, 1]);
assert.deepEqual(paidA.map(record => !!record.fulfilled), [true, true, false, false]);
assert.deepEqual(bribePromiseRequirements(paidA, 'payer', 2), [{ recipient: 'recipient-b', amount: 3 }]);
const paidB = recordPromisedBribe(paidA, 'payer', 2, { recipient: 'recipient-b', amount: 3 });
assert.deepEqual(bribePromiseRequirements(paidB, 'payer', 2), []);
assert.equal(paidB[2].fulfilled, undefined, 'The smaller No recipient total remains constrained to turn end');
});
void test('a recipient No cap rejects incompatible joint Yes while independent recipients and No-only answers remain possible', () => {
const yes = promise('a-needs-five-more', 8, { progress: 3 });
assert.equal(bribePromiseRequirements([yes, promise('a-only-four-more', 9, { answer: false, progress: 4 })], 'payer', 2), null);
assert.deepEqual(bribePromiseRequirements([yes,
promise('a-can-pay-five', 10, { answer: false, progress: 4 }),
promise('b-below-one', 1, { recipient: 'recipient-b', answer: false }),
], 'payer', 2), [{ recipient: 'recipient-a', amount: 5 }]);
assert.deepEqual(bribePromiseRequirements([promise('only-no', 3, { answer: false, progress: 2 })], 'payer', 2), []);
assert.deepEqual(bribePromiseRequirements([], 'payer', 2), []);
});
void test('No prevents threshold evasion by split payments and rejected payment cannot partly fulfill another answer', () => {
const original = [promise('yes-seven', 7), promise('no-seven', 7, { answer: false })];
const first = recordPromisedBribe(original, 'payer', 2, { recipient: 'recipient-a', amount: 3 });
const second = recordPromisedBribe(first, 'payer', 2, { recipient: 'recipient-a', amount: 3 });
assert.deepEqual(second.map(record => record.progress), [6, 6]);
const before = structuredClone(second); second.forEach(Object.freeze); Object.freeze(second);
assert.throws(() => recordPromisedBribe(second, 'payer', 2, { recipient: 'recipient-a', amount: 1 }), BribePromiseError);
assert.deepEqual(second, before); assert.equal(second[0].fulfilled, undefined);
assert.equal(original[0].progress, 0); assert.equal(original[1].progress, 0);
});
void test('terminal, other-player, other-turn and other-recipient records preserve their exact references', () => {
const records = [promise('live', 4), promise('different-asker', 6, { asker: 'recipient-b', answer: false }),
promise('fulfilled', 1, { progress: 1, fulfilled: true }), promise('released', 1, { released: true }),
promise('other-player', 1, { player: 'other-payer' }), promise('other-turn', 1, { turn: 1 }),
promise('other-recipient', 1, { recipient: 'recipient-b' })];
records.forEach(Object.freeze); Object.freeze(records);
assert.deepEqual(liveBribePromises(records, 'payer', 2), [records[0], records[1], records[6]]);
const paid = recordPromisedBribe(records, 'payer', 2, { recipient: 'recipient-a', amount: 4 });
assert.equal(paid[0].fulfilled, true); assert.equal(paid[1].progress, 4); assert.equal(paid[1].fulfilled, undefined);
for (let index = 2; index < records.length; index++) assert.equal(paid[index], records[index]);
const unrelated = recordPromisedBribe(records, 'absent', 2, { recipient: 'recipient-a', amount: 1 });
records.forEach((record, index) => assert.equal(unrelated[index], record));
});
void test('safe currency arithmetic preserves the inclusive maximum and refuses overflowing records or combined requirements', () => {
const maximum = Number.MAX_SAFE_INTEGER;
const lastOne = promise('largest-progress', maximum, { progress: maximum - 1 });
assert.deepEqual(bribePromiseRequirements([lastOne], 'payer', 2), [{ recipient: 'recipient-a', amount: 1 }]);
assert.equal(recordPromisedBribe([lastOne], 'payer', 2, { recipient: 'recipient-a', amount: 1 })[0].progress, maximum);
const before = structuredClone(lastOne);
assert.throws(() => recordPromisedBribe([lastOne], 'payer', 2, { recipient: 'recipient-a', amount: 2 }), BribePromiseError);
assert.deepEqual(lastOne, before);
assert.equal(bribePromiseRequirements([promise('a-maximum', maximum),
promise('b-one', 1, { recipient: 'recipient-b' })], 'payer', 2), null);
assert.deepEqual(bribePromiseRequirements([promise('a-maximum-minus-one', maximum - 1),
promise('b-one', 1, { recipient: 'recipient-b' })], 'payer', 2), [
{ recipient: 'recipient-a', amount: maximum - 1 }, { recipient: 'recipient-b', amount: 1 },
]);
assert.equal(bribePromiseRequirements([lastOne,
promise('no-maximum', maximum, { answer: false, progress: maximum - 1 })], 'payer', 2), null);
assert.throws(() => recordPromisedBribe([promise('no-maximum', maximum, { answer: false, progress: maximum - 1 })],
'payer', 2, { recipient: 'recipient-a', amount: 1 }), BribePromiseError);
});
void test('actual payment inputs and malformed live progress fail without creating requirements or altering records', () => {
const records = [promise('original', 3)]; const before = structuredClone(records);
for (const amount of [0, -1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])
assert.throws(() => recordPromisedBribe(records, 'payer', 2, { recipient: 'recipient-a', amount }), BribePromiseError);
for (const recipient of ['', ' ', ' recipient-a', 'recipient-a '])
assert.throws(() => recordPromisedBribe(records, 'payer', 2, { recipient, amount: 1 }), BribePromiseError);
for (const progress of [-1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const malformed = [{ ...records[0], progress }];
assert.throws(() => bribePromiseRequirements(malformed, 'payer', 2), BribePromiseError);
assert.throws(() => recordPromisedBribe(malformed, 'payer', 2, { recipient: 'recipient-a', amount: 1 }), BribePromiseError);
}
assert.deepEqual(records, before);
});
void test('record validation binds events, actual payer and recipient seats and consistent cumulative terminal states', () => {
const valid = [promise('live-yes', 3, { progress: 2 }),
promise('fulfilled-yes', 3, { progress: 3, fulfilled: true }),
promise('released-yes', 3, { progress: 1, released: true }),
promise('live-no', 3, { progress: 2, answer: false }),
promise('completed-no', 3, { progress: 2, answer: false, fulfilled: true }),
promise('released-no', 3, { progress: 2, answer: false, released: true }),
promise('pay-the-asker', 1, { recipient: 'asker' })];
const before = structuredClone(valid);
assert.doesNotThrow(() => validateBribePromises(valid, seats)); assert.deepEqual(valid, before);
assert.doesNotThrow(() => validateBribePromises(JSON.parse(JSON.stringify(valid)) as BribePromise[], seats));
assert.doesNotThrow(() => validateBribePromises([], seats));
const base = promise('event', 3);
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
...[{ event: '' }, { event: ' ' }, { player: 'outsider' }, { asker: 'outsider' }, { asker: 'payer' },
{ recipient: 'outsider' }, { recipient: 'payer' }, { recipient: ' recipient-a' },
{ answer: 'yes' }, { progress: -1 }, { progress: 0.5 }, { progress: Number.NaN },
{ progress: Number.MAX_SAFE_INTEGER + 1 }, { fulfilled: 1 }, { released: 'true' },
{ fulfilled: true, progress: 2 }, { fulfilled: false, progress: 3 }, { progress: 3 },
{ fulfilled: true, released: true, progress: 3 }, { answer: false, progress: 3 },
{ answer: false, progress: 3, fulfilled: true }, { answer: false, progress: 3, released: true },
{ turn: 0 }, { minimum: 0 }].map(extra => [{ ...base, ...extra }])];
for (const records of invalid)
assert.throws(() => validateBribePromises(records as BribePromise[], seats), BribePromiseError);
assert.deepEqual(valid, before);
});
+109
View File
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actTruthBid, createTruthBidFixture, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer,
type TruthBidStep,
} from './fixture-truth-bid';
export type TruthBribeStep = TruthBidStep;
export type TruthBribeTransition = { before: Game; steps: TruthBribeStep[]; after: Game };
export const actTruthBribe = actTruthBid;
export const truthBribeClean = truthBidClean;
export const truthBribeInventory = truthBidInventory;
export const truthBribePlayer = truthBidPlayer;
export const truthBribeBalances = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
export const truthBribeDeferred = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.bribes]));
export function nextTruthBribe(game: Game): TruthBribeStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const view = viewGame(game, player.id);
if (view.bribePromiseGuide) return { actor: player.id, action: view.bribePromiseGuide.action };
if (view.bidPromiseGuide) return { actor: player.id, action: view.bidPromiseGuide.action };
if (view.revivalPromiseGuide?.phase === game.phase) return { actor: player.id, action: view.revivalPromiseGuide.action };
}
return nextTruthBid(game);
}
export function advanceTruthBribe(game: Game, until: (state: Game) => boolean, actions: TruthBribeStep[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine bribe source ended before its native control');
const next = nextTruthBribe(game); assert.ok(next, 'The original Truthtrance question and answer belong to their callers');
game = actTruthBribe(game, next.actor, next.action, actions);
}
throw Error('The genuine bribe source did not reach its finite boundary');
}
export function settleTruthBribe(game: Game, actions: TruthBribeStep[] = []): Game {
return advanceTruthBribe(game, truthBribeClean, actions);
}
export function finishTruthBribe(game: Game, actions: TruthBribeStep[] = []): Game {
const turn = game.turn;
return advanceTruthBribe(game, state => state.turn === turn + 1 && state.phase === 0 && truthBribeClean(state), actions);
}
/** The original public H/A/E source supplies both physical Truthtrances and
* ten actually available Emperor spice. No optional modules or alliances are
* introduced. Phase7 is earned through real quiet phase actions and original
* Collection, never a clock/wallet/army/hand/question/promise installation. */
export function createTruthBribeFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; recipient?: 'harkonnen' | 'atreides'; minimum?: number; phase?: 3 | 7;
} = {}) {
const base = createTruthBidFixture({ advanced: options.advanced, window: options.window ?? 'waiting' });
const recipient = options.recipient ?? base.asker, minimum = options.minimum ?? 3, phase = options.phase ?? 3;
let game = structuredClone(base.game); const actions = structuredClone(base.sourceActions);
if (phase === 7) game = advanceTruthBribe(game, state => state.phase === 7 && truthBribeClean(state), actions);
assert.equal(game.phase, phase); assert.equal(truthBribePlayer(game, base.target).spice, 10);
assert.equal(truthBribePlayer(game, recipient).ally, null);
assert.deepEqual(truthBribeInventory(game), base.originalCards);
const question = { kind: 'bribe' as const, target: base.target, turn: game.turn, recipient, minimum };
return { ...base, game, sourceGame: structuredClone(game), base, recipient, minimum, phase, payer: base.target,
question, questionAction: { type: 'truthAsk', question } as Action,
bribeAction: { type: 'bribe', target: recipient, amount: minimum } as Action,
originalBalances: truthBribeBalances(game), originalDeferred: truthBribeDeferred(game),
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Genuine public original H/A/E base33 setup and ordinary auction; H holds both original Truthtrances, E its original Shield and10spice. Only inherited unopened deal/lot/Spice positions and scalar lotteries are selected. Phase7, if requested, follows actual native controls/Collection. Ordinary bribes debit the payer now and credit the named nonally only at actual Mentat. No played state or promise is installed.' };
}
export type TruthBribeFixture = ReturnType<typeof createTruthBribeFixture>;
type TruthBribeSource = { game: Game; asker: string; target: string; truths: string[] };
export function declareTruthBribe(f: TruthBribeSource, state = f.game, options: { copies?: 1 | 2 } = {}) {
let game = state; const before = structuredClone(game), actions: TruthBribeStep[] = [];
const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) };
game = actTruthBribe(game, f.asker, declaration, actions);
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthBribe(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, declaration, actions, actionCount: actions.length };
}
export function askTruthBribe(game: Game, asker: string, target: string, recipient: string, minimum = 3,
actions: TruthBribeStep[] = []): Game {
return actTruthBribe(game, asker, { type: 'truthAsk', question: { kind: 'bribe', target, turn: game.turn, recipient, minimum } }, actions);
}
export function answerTruthBribe(game: Game, target: string, answer: 'yes' | 'no', actions: TruthBribeStep[] = []): Game {
return actTruthBribe(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthBribe(f: TruthBribeFixture, answer: 'yes' | 'no' = 'yes', minimum = f.minimum,
recipient = f.recipient, state = f.game) {
const declared = declareTruthBribe(f, state), actions = [...declared.actions];
const asked = askTruthBribe(declared.game, f.asker, f.target, recipient, minimum, actions);
const game = answerTruthBribe(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
/** Isolate deferred payment from ordinary Collection first, then enter the
* actual Mentat that credits it. A final real turn boundary permits callers to
* inspect the old No's expiration without installing a new clock. */
export function completeTruthBribe(f: TruthBribeFixture, state = f.game) {
let game = state; const actions: TruthBribeStep[] = [];
const transition = (until: (state: Game) => boolean): TruthBribeTransition => {
const before = structuredClone(game), from = actions.length;
game = advanceTruthBribe(game, until, actions);
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
const collection = transition(state => state.phase === 7 && truthBribeClean(state));
const mentat = transition(state => state.phase === 8 && truthBribeClean(state));
const nextTurn = transition(state => state.turn === f.game.turn + 1 && state.phase === 0 && truthBribeClean(state));
assert.deepEqual(truthBribeInventory(game), f.originalCards);
return { game, collection, mentat, nextTurn, actions, actionCount: actions.length };
}
+209
View File
@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { BribeClaimFields, bribeClaimInputError } from '../components/bribe-claim-fields';
import { BribePromises, BribePromiseGuideButton } from '../components/bribe-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { BribeClaim } from '../game/bribe-promises';
import { createTechTokens } from '../game/tech-tokens';
import { createTruthBribeFixture, bindTruthBribe, actTruthBribe, settleTruthBribe, truthBribeInventory } from './fixture-truth-bribe';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { Truthtrance } = await import('../components/truthtrance');
aliases.deregister();
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
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 field(props: Parameters<typeof BribeClaimFields>[0], id: string) {
const found = elements(BribeClaimFields(props)).find(element => element.props.id === id && typeof element.props.onChange === 'function');
assert.ok(found); return found;
}
function minimum(props: Parameters<typeof BribeClaimFields>[0], value: number) {
(field(props, 'truth-bribe-minimum').props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)(
{ currentTarget: { valueAsNumber: value } });
}
function recipient(props: Parameters<typeof BribeClaimFields>[0], value: string) {
(field(props, 'truth-bribe-recipient').props.onChange as (event: { currentTarget: { value: string } }) => void)(
{ currentTarget: { 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(BribePromises, { game, busy, send() {} }));
/** Controlled personalized quotes supplement the native producer below. They
* are UI projections, rather than installed authoritative payments or promises. */
function projected(phase = 3): GameView {
const source = createGame('TRUTHBRIBECONTROLS', newPlayer('p', 'Respondent', 'emperor'));
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.bribePromises = [{ event: 'controlled-bribe-event', player: 'p', asker: 'a', turn: 2,
recipient: 'r', minimum: 3, answer: true, progress: 1 }];
game.bribePromiseGuide = { action: { type: 'bribe', target: 'r', amount: 2, bribePromiseEvent: 'controlled-bribe-event' } };
return game;
}
void test('bribe claim fields keep the turn fixed and accept only current recipients and positive safe whole minima', () => {
const value: BribeClaim = { turn: 2, recipient: 'r', minimum: 3 }, changes: BribeClaim[] = [];
const recipients = [{ id: 'r', name: 'Recipient' }, { id: 'a', name: 'Asker' }];
const props = { value, recipients, onChange(next: BribeClaim) { changes.push(next); } };
const html = renderToStaticMarkup(createElement(BribeClaimFields, props));
assert.match(html, /Turn 2/); assert.match(html, /Recipient/); assert.match(html, /min="1"/);
assert.match(html, /max="9007199254740991"/); assert.match(html, /smaller payments add together/);
assert.match(html, /Earlier payments, gifts and ally aid do not count/);
minimum(props, 1); minimum(props, Number.MAX_SAFE_INTEGER); recipient(props, 'a');
assert.deepEqual(changes, [{ ...value, minimum: 1 }, { ...value, minimum: Number.MAX_SAFE_INTEGER }, { ...value, recipient: 'a' }]);
for (const next of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
assert.ok(bribeClaimInputError({ ...value, minimum: next }, recipients)); minimum(props, next);
}
recipient(props, 'missing'); recipient(props, ''); assert.equal(changes.length, 3);
minimum({ ...props, disabled: true }, 2); recipient({ ...props, disabled: true }, 'a');
minimum({ ...props, recipients: [] }, 2); assert.equal(changes.length, 3);
assert.ok(bribeClaimInputError(value, []));
assert.match(renderToStaticMarkup(createElement(BribeClaimFields, { ...props, recipients: [] })), /role="alert"/);
});
void test('own bribe promises persist with named cumulative progress, delayed spending and complete/released status', () => {
const game = projected();
game.bribePromises.push(
{ event: 'no-event', player: 'p', asker: 'a', turn: 2, recipient: 'r', minimum: 5, answer: false, progress: 1 },
{ event: 'fulfilled-event', player: 'p', asker: 'a', turn: 2, recipient: 'r', minimum: 1, answer: true, progress: 1, fulfilled: true },
{ event: 'released-event', player: 'p', asker: 'a', turn: 2, recipient: 'a', minimum: 9, answer: true, progress: 0, released: true },
{ event: 'foreign-event', player: 'a', asker: 'p', turn: 2, recipient: 'r', minimum: 99, answer: true, progress: 0 },
);
const before = structuredClone(game);
for (let render = 0; render < 2; render++) {
const html = markup(game);
assert.match(html, /Your bribe promises/); assert.match(html, /Recorded future ordinary payments to Recipient: 1 spice/);
assert.match(html, /No forbids reaching that minimum during the rest of this turn/);
assert.match(html, /Smaller payments to the named recipient add together/);
assert.match(html, /Prior payments, gifts and ally aid do not count/);
assert.match(html, /The sender pays immediately/); assert.match(html, /recipient can spend the payment only at Mentat Pause/);
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"/);
assert.doesNotMatch(html, /at least 99|foreign-event|controlled-bribe-event/);
}
assert.deepEqual(game, before);
assert.equal(BribePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('actual guide callbacks forward exact ordinary bribes out of turn throughout their supported phases', () => {
for (const phase of [0, 3, 4, 7]) {
const game = projected(phase), sent: Action[] = [];
if (phase === 4) {
game.advanced = true;
game.ready = ['p'];
game.revivalOrder = { version: 1, turn: 2, original: ['a', 'p', 'r'], remaining: ['a', 'r'], completed: ['p'], started: false };
}
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);
assert.equal(game.active, 'a'); assert.equal(game.auction!.active, 'a');
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
}
});
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'; },
game => { game.phase = 8; }, game => { game.phase = -1; },
game => { game.bribePromiseGuide = { action: { ...game.bribePromiseGuide!.action } }; },
game => { game.bribePromiseGuide = null; }, game => { game.bribePromiseGuide!.action.amount = 3; },
game => { game.bribePromises[0].event = 'expired'; }, game => { game.bribePromises[0].player = 'a'; },
game => { game.bribePromises[0].fulfilled = true; }, game => { game.bribePromises[0].released = true; },
game => { game.bribePromises = []; }, 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.bribeOptions.targets.filter(target => target.id !== 'r'); },
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 props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
}
for (const amount of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const game = projected(); game.bribePromiseGuide!.action.amount = amount;
const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []);
}
for (const target of ['p', 'missing']) {
const game = projected(); game.bribePromiseGuide!.action.target = target;
game.bribeOptions.targets.push({ id: target, maximum: 8, limitation: null });
const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []);
}
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy bribe guide cannot dispatch'); } };
const button = guide(props); props.busy = true; press(button);
});
void test('promise guidance uses projected payment bounds without inspecting private supplies, hands or auction faces', () => {
const game = projected(), sent: Action[] = [];
for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'bribes', 'tanks', 'reserves'])
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]);
});
void test('genuine Basic and Advanced out-of-turn bribe callbacks record future payment before the recipient can spend it', () => {
for (const advanced of [false, true]) {
const f = createTruthBribeFixture({ advanced, window: 'waiting' }), bound = bindTruthBribe(f, 'yes', 3);
const respondent = viewGame(bound.asked, f.target);
assert.ok(respondent.truthBribeAnswers?.includes('yes'));
assert.equal(viewGame(bound.asked, f.asker).truthBribeAnswers, null);
const answers = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} }));
assert.match(answers, /Answer Yes/); assert.match(answers, /Answer No/);
const game = viewGame(bound.game, f.target), sent: Action[] = [], next = game.bribePromiseGuide;
assert.ok(next); assert.equal(game.auction?.active, f.asker); assert.notEqual(game.auction?.active, f.target);
assert.match(markup(game), /Pay 3 spice to/);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [next.action]);
assert.ok(next.action.type === 'bribe' && typeof next.action.amount === 'number');
const amount = next.action.amount;
const sender = bound.game.players.find(player => player.id === f.target); assert.ok(sender);
const recipient = bound.game.players.find(player => player.id === next.action.target); assert.ok(recipient);
const after = settleTruthBribe(actTruthBribe(bound.game, f.target, sent[0]));
assert.equal(after.players.find(player => player.id === sender.id)!.spice, sender.spice - amount);
assert.equal(after.players.find(player => player.id === recipient.id)!.spice, recipient.spice);
assert.equal(after.players.find(player => player.id === recipient.id)!.bribes, recipient.bribes + amount);
const promise = after.bribePromises!.find(promise => promise.event === next.action.bribePromiseEvent);
assert.ok(promise); assert.equal(promise.progress, 3); assert.equal(promise.fulfilled, true);
assert.deepEqual(truthBribeInventory(after), f.originalCards);
assert.match(markup(viewGame(after, f.target)), /Fulfilled: this commitment is complete/);
assert.equal(BribePromises({ game: viewGame(after, f.asker), busy: false, send() {} }), null);
}
});
+229
View File
@@ -0,0 +1,229 @@
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 {
actTruthBribe as act, advanceTruthBribe as advance, answerTruthBribe as answer,
askTruthBribe as ask, bindTruthBribe as bind, completeTruthBribe as complete,
createTruthBribeFixture as fixture, declareTruthBribe as declare, settleTruthBribe as settle,
truthBribeBalances as balances, truthBribeClean as clean, truthBribeInventory as inventory, truthBribePlayer as player,
} from './fixture-truth-bribe';
import { createTruthRevivalFixture } from './fixture-truth-revival';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
const promise = (game: Game, target: string, recipient: string) =>
game.bribePromises!.find(promise => promise.player === target && promise.recipient === recipient)!;
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} Truth bribe Yes debits now and credits its exact nonally at actual Mentat`, () => {
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 before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before);
assert.equal(bound.game.auction!.active, f.asker); assert.notEqual(bound.game.auction!.active, f.target);
assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(promise(bound.game, f.target, f.recipient).progress, 0);
const guide = viewGame(bound.game, f.target).bribePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'bribe'); assert.equal(guide.action.target, f.recipient); assert.equal(guide.action.amount, 3);
const paid = settle(act(bound.game, f.target, guide.action));
assert.equal(player(paid, f.target).spice, 7); assert.equal(player(paid, f.recipient).spice, player(before, f.recipient).spice);
assert.equal(player(paid, f.recipient).bribes, 3);
assert.equal(promise(paid, f.target, f.recipient).progress, 3); assert.equal(promise(paid, f.target, f.recipient).fulfilled, true);
const result = complete(f, paid);
assert.equal(player(result.mentat.before, f.recipient).bribes, 3);
assert.equal(player(result.mentat.after, f.recipient).bribes, 0);
assert.equal(player(result.mentat.after, f.recipient).spice - player(result.mentat.before, f.recipient).spice, 3);
assert.equal(player(result.collection.after, f.recipient).spice - player(paid, f.recipient).spice, advanced ? 2 : 0);
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0);
assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('native split bribes fulfill Yes cumulatively only through future matching payments', () => {
const f = fixture(); let game = bind(f).game;
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.equal(promise(game, f.target, f.recipient).progress, 1); assert.ok(!promise(game, f.target, f.recipient).fulfilled);
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 2 });
assert.equal(promise(game, f.target, f.recipient).progress, 3); assert.equal(promise(game, f.target, f.recipient).fulfilled, true);
assert.equal(player(game, f.target).spice, 7); assert.equal(player(game, f.recipient).bribes, 3);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native No aggregates matching bribes below its cap until the actual next turn', () => {
const f = fixture(); let game = bind(f, 'no').game;
for (let index = 0; index < 2; index++) game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.equal(promise(game, f.target, f.recipient).progress, 2);
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
game = complete(f, game).game;
assert.equal(game.turn, 2); assert.equal(player(game, f.recipient).bribes, 0);
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 3 });
assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.recipient).bribes, 3);
assert.equal(promise(game, f.target, f.recipient).progress, 2); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native prior bribes never advance a later Truth promise for the same recipient', () => {
const f = fixture(); let game = act(f.game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
game = bind(f, 'yes', 3, f.recipient, game).game;
assert.equal(promise(game, f.target, f.recipient).progress, 0);
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.equal(promise(game, f.target, f.recipient).progress, 1);
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 2 });
assert.equal(promise(game, f.target, f.recipient).progress, 3); assert.equal(player(game, f.recipient).bribes, 4);
assert.equal(player(game, f.target).spice, 6); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native other-recipient payment does not count and cannot spend the last matching Yes funding', () => {
const f = fixture(); let game = bind(f).game;
game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 7 });
assert.equal(promise(game, f.target, f.recipient).progress, 0); assert.equal(player(game, f.target).spice, 3);
assert.equal(player(game, f.opponent).bribes, 7); assert.equal(player(game, f.recipient).bribes, 0);
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 1 });
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide);
game = act(game, f.target, guide.action); assert.equal(player(game, f.target).spice, 0);
assert.equal(promise(game, f.target, f.recipient).progress, 3); assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truth copies bind compatible Yes and No payments to the exact same recipient', () => {
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, f.recipient, 3); game = answer(game, f.target, 'yes');
game = ask(game, f.asker, f.target, f.recipient, 5); game = answer(game, f.target, 'no');
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action);
assert.deepEqual(game.bribePromises!.map(promise => promise.progress), [3, 3]);
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 2 });
for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truth copies cannot answer Yes inside an existing matching No cap', () => {
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, f.recipient, 3); game = answer(game, f.target, 'no');
game = ask(game, f.asker, f.target, f.recipient, 3);
assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
assert.equal(game.bribePromises!.length, 2); assert.deepEqual(inventory(game), f.originalCards);
});
void test('two native recipient Yes promises reserve their joint actual purse rather than the same spice twice', () => {
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, f.recipient, 3); game = answer(game, f.target, 'yes');
game = ask(game, f.asker, f.target, f.opponent, 3); game = answer(game, f.target, 'yes');
rejected(game, f.target, { type: 'bribe', target: f.recipient, amount: 8 });
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 7 });
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); assert.equal(guide.action.target, f.opponent);
game = act(game, f.target, guide.action);
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.recipient).bribes, 7);
assert.equal(player(game, f.opponent).bribes, 3); assert.ok(game.bribePromises!.every(promise => promise.fulfilled));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native current winning-bid liability limits a bribe witness and the original invoice remains payable', () => {
const f = fixture({ window: 'active' }); let game = act(f.game, f.target, { type: 'bid', amount: 7 });
game = bind(f, 'yes', 3, f.recipient, game).game;
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 3);
game = act(game, f.target, guide.action); assert.equal(player(game, f.target).spice, 7);
game = advance(game, state => clean(state) && state.auction?.index === 1);
assert.equal(player(game, f.target).spice, 0); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.equal(player(game, f.recipient).bribes, 3); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native already committed auction spice cannot supply an unfunded three-spice bribe answer', () => {
const f = fixture({ window: 'active' }); let game = act(f.game, f.target, { type: 'bid', amount: 8 });
game = declare(f, game).game; game = ask(game, f.asker, f.target, f.recipient, 3);
assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(player(game, f.target).spice, 10); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native last Collection readiness cannot erase an unpaid Bribe Yes before actual Mentat', () => {
const f = fixture({ phase: 7 }); let game = act(f.game, f.asker, { type: 'ready' });
game = act(game, f.opponent, { type: 'ready' }); assert.equal(game.phase, 7);
game = bind(f, 'yes', 3, f.recipient, game).game;
rejected(game, f.target, { type: 'ready' });
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action);
const before = player(game, f.recipient).spice; game = act(game, f.target, { type: 'ready' });
assert.equal(game.phase, 8); assert.equal(player(game, f.recipient).spice, before + 3);
assert.equal(player(game, f.recipient).bribes, 0); assert.deepEqual(inventory(game), f.originalCards);
});
void test('a real already-ready Collection payer reopens readiness when accepting a new Bribe Yes', () => {
const f = fixture({ phase: 7 });
let game = act(f.game, f.target, { type: 'ready' });
assert.equal(game.phase, 7); assert.ok(game.ready.includes(f.target));
game = bind(f, 'yes', 3, f.recipient, game).game;
assert.equal(game.phase, 7); assert.ok(!game.ready.includes(f.target));
rejected(game, f.target, { type: 'ready' });
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide);
game = act(game, f.target, guide.action);
assert.equal(player(game, f.recipient).bribes, 3);
const result = complete(f, game);
assert.equal(player(result.mentat.after, f.recipient).spice - player(result.mentat.before, f.recipient).spice, 3);
assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('native Truth bribe owner current-turn and guided event checks retain out-of-turn legal payment', () => {
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, recipient: f.target } });
let game = ask(declared.game, f.asker, f.target, f.recipient);
assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['yes', 'no']);
assert.equal(viewGame(game, f.asker).truthBribeAnswers, null);
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(guide.action.bribePromiseEvent);
rejected(game, f.asker, guide.action); const before = balances(game);
game = act(game, f.target, guide.action);
assert.equal(player(game, f.target).spice, before[f.target] - 3);
rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards);
});
void test('two genuine Truth answers can fund an actual bid and exact-recipient bribe together', () => {
const f = fixture({ window: 'active' }), declared = declare(f, f.game, { copies: 2 });
let game = act(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target,
turn: f.game.turn, lot: f.game.auction!.index + 1, minimum: 2 } });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = ask(game, f.asker, f.target, f.recipient, 3); game = answer(game, f.target, 'yes');
const bribe = viewGame(game, f.target).bribePromiseGuide; assert.ok(bribe); game = act(game, f.target, bribe.action);
const bid = viewGame(game, f.target).bidPromiseGuide; assert.ok(bid); game = act(game, f.target, bid.action);
game = advance(game, state => clean(state) && state.auction?.index === 1);
assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.recipient).bribes, 3);
assert.equal(promise(game, f.target, f.recipient).fulfilled, true);
assert.ok(game.bidPromises!.every(promise => promise.fulfilled || promise.released)); assert.deepEqual(inventory(game), f.originalCards);
});
void test('an existing native Bid Yes cannot reuse its reserved purse for an incompatible Bribe Yes', () => {
const f = fixture({ window: 'active' }), declared = declare(f, f.game, { copies: 2 });
let game = act(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target,
turn: f.game.turn, lot: f.game.auction!.index + 1, minimum: 8 } });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = ask(game, f.asker, f.target, f.recipient, 3);
assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
});
void test('an existing native Shipment Yes cannot reuse its paid route funding for a Bribe Yes', () => {
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
let game = act(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'shipment', target: f.target,
territory: 'arrakeen', minimum: 8 } });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = ask(game, f.asker, f.target, f.recipient, 3);
assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
});
void test('an existing earned ordinary Revival Yes cannot reuse its paid return funding for a Bribe Yes', () => {
const f = createTruthRevivalFixture(), declared = declare(f, f.game, { copies: 2 });
let game = act(declared.game, f.asker, { type: 'truthAsk', question: f.question });
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = ask(game, f.asker, f.target, f.asker, 1);
assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal profiles submit a legal exact-recipient Bribe Yes payment out of turn', () => {
const f = fixture(), game = bind(f).game;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'bribe' && action.target === f.recipient && action.amount === 3);
assert.ok(action, difficulty); const paid = act(structuredClone(game), f.target, action);
assert.equal(player(paid, f.target).spice, 7); assert.equal(player(paid, f.recipient).bribes, 3);
assert.equal(promise(paid, f.target, f.recipient).fulfilled, true); assert.deepEqual(inventory(paid), f.originalCards);
}
});