Add cumulative Truthtrance bribe-payment commitments
This commit is contained in:
1 parent
7fc21113e3
commit
335b4ce638
18 files changed
+1453
-21
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user