Combine current facts with native bribe payments

This commit is contained in:
Quintine committed 2026-10-26 16:39:30 +11:00
1 parent b63a60da64
commit 6b2dfb84a2
23 files changed
+1445 -170

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact } from '../game/truthtrance';
import {
actTruthBribeExpression, advanceTruthBribeExpression, completeTruthBribeExpression,
createTruthBribeExpressionFixture, declareTruthBribeExpression, finishTruthBribeExpression,
nextTruthBribeExpression, settleTruthBribeExpression, truthBribeExpressionBalances,
truthBribeExpressionClean, truthBribeExpressionDeferred, truthBribeExpressionInventory,
truthBribeExpressionPlayer, type TruthBribeExpressionStep, type TruthBribeExpressionTransition,
} from './fixture-truth-bribe-expression';
export type TruthMixedBribeExpression = MixedActionExpression<{ recipient: string; minimum: number }>;
export type TruthMixedBribeStep = TruthBribeExpressionStep;
export type TruthMixedBribeTransition = TruthBribeExpressionTransition;
export const actTruthMixedBribe = actTruthBribeExpression;
export const advanceTruthMixedBribe = advanceTruthBribeExpression;
export const finishTruthMixedBribe = finishTruthBribeExpression;
export const nextTruthMixedBribe = nextTruthBribeExpression;
export const settleTruthMixedBribe = settleTruthBribeExpression;
export const truthMixedBribeBalances = truthBribeExpressionBalances;
export const truthMixedBribeClean = truthBribeExpressionClean;
export const truthMixedBribeDeferred = truthBribeExpressionDeferred;
export const truthMixedBribeInventory = truthBribeExpressionInventory;
export const truthMixedBribePlayer = truthBribeExpressionPlayer;
export const truthMixedBribeFact = (fact: TruthFact): TruthMixedBribeExpression => ({ kind: 'fact', fact });
export const truthMixedBribePayment = (recipient: string, minimum = 3): TruthMixedBribeExpression =>
({ kind: 'action', action: { recipient, minimum } });
export const truthMixedBribeGroup = (kind: 'and' | 'or', ...terms: TruthMixedBribeExpression[]): TruthMixedBribeExpression =>
({ kind, terms });
/** Original H/A/E base33 Basic20/Advanced23 prefix, or its actual Collection/
* Mentat continuation. Only the raw question changes. E retains its original
* Shield and ten spice; no played clock, purse, hand, answer or promise is set. */
export function createTruthMixedBribeFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; phase?: 3 | 7 | 8; mixed?: TruthMixedBribeExpression;
} = {}) {
const base = createTruthBribeExpressionFixture(options);
const shield = base.setup.deck.find(card => card.kind === 'shield');
const hajr = base.setup.deck.find(card => card.effect === 'hajr');
assert.ok(shield && hajr, 'The unopened base33 inventory supplies printed current-fact identities');
const shieldFact: TruthFact = { kind: 'hand', name: shield.name };
const hajrFact: TruthFact = { kind: 'hand', name: hajr.name };
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 };
const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
const mixed = structuredClone(options.mixed ?? truthMixedBribeGroup('and', truthMixedBribeFact(spiceFact),
truthMixedBribePayment(base.asker, 3), truthMixedBribePayment(base.opponent, 2)));
const question = { kind: 'mixedBribe' as const, target: base.target, turn: base.game.turn, mixed };
return { ...base, bribeSource: base, mixed, question, shieldFact, hajrFact, spiceFact, forecastFact,
questionAction: { type: 'truthAsk', question } satisfies Action,
sourceCounts: { inherited: base.sourceActionCount, basicBidding: 20, advancedBidding: 23,
basicMentat: 32, advancedMentat: 35 },
sourceDisclosure: `${base.sourceDisclosure} This thin mixed adapter changes only the original raw question: current facts and future ordinary cumulative payments share one AND/OR tree. No frozen answer, compiled residual, promise or progress is installed.` };
}
export type TruthMixedBribeFixture = ReturnType<typeof createTruthMixedBribeFixture>;
export function declareTruthMixedBribe(f: TruthMixedBribeFixture, source = f.game, options: { copies?: 1 | 2 } = {}) {
return declareTruthBribeExpression(f.bribeSource, source, options);
}
export function askTruthMixedBribe(game: Game, asker: string, target: string, mixed: TruthMixedBribeExpression,
actions: TruthMixedBribeStep[] = []): Game {
return actTruthMixedBribe(game, asker, { type: 'truthAsk', question: {
kind: 'mixedBribe', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthMixedBribe(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthMixedBribeStep[] = []): Game {
return actTruthMixedBribe(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthMixedBribe(f: TruthMixedBribeFixture, answer: 'yes' | 'no' = 'yes',
mixed = f.mixed, source = f.game) {
const declared = declareTruthMixedBribe(f, source), actions = [...declared.actions];
const asked = askTruthMixedBribe(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthMixedBribe(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
export function completeTruthMixedBribe(f: TruthMixedBribeFixture, source = f.game) {
return completeTruthBribeExpression(f.bribeSource, source);
}
/** The public original question and whole answer only. This accessor never
* reads private frozen clauses or the internal native residual binding. */
export function truthMixedBribePromise(game: Game, target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
const row = viewGame(game, target).mixedBribePromises.find(row => row.player === target);
assert.ok(row, 'The respondent retains the original mixed question and whole-answer status');
return row;
}
@@ -168,7 +168,8 @@ void test('one shared guide handles scalar and whole promises without duplicatin
const game = projected(); assert.match(markup(game), /data-testid="bribe-promise-guide"/);
game.bribePromises = [{ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, recipient: 'r', minimum: 1,
answer: true, progress: 1, fulfilled: true }];
assert.doesNotMatch(markup(game), /data-testid="bribe-promise-guide"/);
// Historical scalar records do not own a newly live expression guide.
assert.match(markup(game), /data-testid="bribe-promise-guide"/);
const both = renderToStaticMarkup(createElement('div', null,
createElement(BribePromises, { game, busy: false, send() {} }),
createElement(BribeExpressionPromises, { game, busy: false, send() {} })));
+292
View File
@@ -0,0 +1,292 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MixedActionFields, type MixedActionConfig } from '../components/mixed-action-fields';
import { MixedBribeFields, mixedBribeInputError } from '../components/mixed-bribe-fields';
import { MixedBribePromises } from '../components/mixed-bribe-promises';
import { MixedMovementFields } from '../components/mixed-movement-fields';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { BribePromises, BribePromiseGuideButton } from '../components/bribe-promises';
import { BribeExpressionPromises } from '../components/bribe-expression-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { BribePaymentClaim } from '../game/mixed-bribe-question';
import type { TruthFact } from '../game/truthtrance';
import { createTechTokens } from '../game/tech-tokens';
type Expression = MixedActionExpression<BribePaymentClaim>;
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
const payment = (minimum = 1, recipient = 'r'): Expression => ({ kind: 'action', action: { recipient, minimum } });
const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [payment(3), payment(2, 'a')] }] });
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function find(tree: ReactNode, key: string, value: string): Element {
const found = elements(tree).find(element => element.props[key] === value);
assert.ok(found, `${key}=${value}`); return found;
}
const control = (tree: ReactNode, key: string) => find(tree, 'data-mixed-bribe-control', key);
const click = (button: Element) => (button.props.onClick as () => void)();
function select(tree: ReactNode, path: string, field: string, value: string) {
const input = find(tree, 'id', `truth-mixed-bribe-${path}-${field}`);
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
const input = find(tree, 'id', `truth-mixed-bribe-${path}-minimum`);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
}
function editFact(tree: ReactNode, path: string, next: TruthFact) {
const scope = find(tree, 'data-mixed-bribe-fact-path', path);
const fields = elements(scope).find(element => element.type === TruthFactFields);
assert.ok(fields, `Current fact fields ${path}`);
(fields.props.onChange as (fact: TruthFact) => void)(next);
}
/** Controlled personalized projections exercise UI ownership and callbacks.
* Native answer-time snapshots, funding and real payments have separate proof. */
function projected(phase = 3, advanced = false): GameView {
const source = createGame('TRUTHMIXEDBRIBECONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
joinGame(source, newPlayer('r', 'Recipient', 'harkonnen'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'a';
game.auction = { bid: 0, bidder: null, active: 'a', remaining: 2, card: null, inspectionBlocked: null };
game.bribeOptions = { available: 8, incoming: 0, blocked: null,
targets: [{ id: 'r', maximum: 6, limitation: null }, { id: 'a', maximum: 8, limitation: null }] };
game.bribePromises = []; game.bribeExpressionPromises = [];
game.mixedBribePromises = [{ event: 'controlled-mixed-bribe-event', player: 'p', asker: 'a', turn: 2,
mixed: claim(), answer: true, progress: [{ recipient: 'r', amount: 1 }], fulfilled: false, released: false }];
game.bribePromiseGuide = { action: { type: 'bribe', target: 'r', amount: 2, bribePromiseEvent: 'controlled-mixed-bribe-event' } };
return game;
}
function editor(initial: Expression, disabled = false, game = projected()) {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => MixedBribeFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(MixedBribeFields, { value, game, target: 'p', disabled, onChange() {} })),
error: () => mixedBribeInputError(value, game, 'p'),
};
}
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) => renderToStaticMarkup(createElement(MixedBribePromises, { game, busy: false, send() {} }));
void test('shared mixed editor adapts actions and keeps movement wrapper identifiers and fact controls stable', () => {
type Probe = { token: string };
let value: MixedActionExpression<Probe> = { kind: 'fact', fact: { kind: 'hand', name: 'Shield' } };
const game = projected(); let parsed = 0;
const config: MixedActionConfig<Probe> = {
prefix: 'mixed-probe', legend: 'Probe claim', actionLabel: 'probe condition', actionTiming: 'Future probe',
actionHelp: 'Probe mechanics', fallbackError: 'Choose a probe', maxLeaves: 16, maxDepth: 4,
newAction: () => ({ token: 'exact-token' }),
parseAction: input => { parsed++; return input as Probe; },
renderAction: props => <label htmlFor={`${props.id}-token`}>{props.value.token}</label>,
};
const tree = () => MixedActionFields({ value, game, target: 'p', disabled: false, config, onChange(next) { value = next; } });
click(find(tree(), 'data-mixed-probe-control', 'or-1'));
assert.deepEqual(value, { kind: 'or', terms: [fact(), { kind: 'action', action: { token: 'exact-token' } }] });
find(tree(), 'htmlFor', 'truth-mixed-probe-1.2-token'); assert.ok(parsed > 0);
let movementChanges = 0;
const movement = MixedMovementFields({ value: { kind: 'and', terms: [{ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } },
{ kind: 'action', action: { territory: 'carthag', minimum: 2 } }] }, game, target: 'p', disabled: false,
onChange() { movementChanges++; } });
find(movement, 'id', 'truth-mixed-movement-1.2-territory');
find(movement, 'data-mixed-movement-fact-path', '1.1');
click(find(movement, 'data-mixed-movement-control', 'nested-or-1')); assert.equal(movementChanges, 1);
});
void test('mixed bribe editor preserves sixteen clauses, public recipients, unique IDs and safe integer minima without a spice cap', () => {
const e = editor(fact()); click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'recipient', 'r');
for (let count = 3; count <= 16; count++) {
click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count);
select(e.tree(), `1.${count}`, 'recipient', count % 2 ? 'r' : 'a');
}
assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); assert.equal(e.error(), null);
minimum(e.tree(), '1.16', Number.MAX_SAFE_INTEGER); assert.equal(e.error(), null);
const html = e.html(), ids = [...html.matchAll(/ id="([^"]+)"/g)].map(match => match[1]);
assert.equal(new Set(ids).size, ids.length); assert.match(html, /16 \/ 16 conditions/);
assert.equal((html.match(/Minimum future ordinary bribe payments in spice/g) ?? []).length, 15);
assert.match(html, /max="9007199254740991"/);
const before = structuredClone(e.value), changes = e.changes;
for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-or-1.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
const game = projected(); game.players.find(player => player.id === 'p')!.ally = 'r';
const allied = editor({ kind: 'and', terms: [fact(), payment()] }, false, game);
assert.ok(allied.error()); assert.doesNotMatch(allied.html(), /<option value="[pr]">/);
select(allied.tree(), '1.2', 'recipient', 'a'); assert.equal(allied.error(), null);
select(allied.tree(), '1.2', 'recipient', 'unlisted'); assert.equal(allied.changes, 1);
});
void test('nested mixed and fact groups share depth four and retain branches through meaning, timing and singleton collapse', () => {
const e = editor({ kind: 'and', terms: [fact(), payment(3)] });
click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and');
click(control(e.tree(), 'nested-or-1')); select(e.tree(), '1.3.2', 'recipient', 'a');
click(control(e.tree(), 'fact-or-1.1')); editFact(e.tree(), '1.1.2', { kind: 'stormForecast', compare: 'gte', value: 4 });
select(e.tree(), '1.1', 'fact-meaning', 'and'); click(control(e.tree(), 'fact-add-1.1'));
editFact(e.tree(), '1.1.3', { kind: 'spice', compare: 'gte', value: 7 }); assert.equal(e.error(), null);
click(control(e.tree(), 'fact-remove-1.1.3')); click(control(e.tree(), 'fact-remove-1.1.2'));
click(control(e.tree(), 'remove-1.2.2')); click(control(e.tree(), 'remove-1.3'));
assert.deepEqual(e.value, { kind: 'and', terms: [fact(), payment(3)] });
select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
select(e.tree(), '1.2', 'timing', 'action'); select(e.tree(), '1.2', 'recipient', 'r'); assert.equal(e.error(), null);
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, payment()); assert.ok(e.error());
let nested = fact();
for (let depth = 0; depth < 3; depth++) nested = { kind: 'and', terms: [nested, payment()] };
const deep = editor(nested); click(control(deep.tree(), 'fact-or-1.1.1.1'));
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
for (const key of ['and-1', 'or-1.1.1.1', 'fact-and-1.1.1.1.1', 'fact-or-1.1.1.1']) {
assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
}
assert.equal(deep.changes, 1);
});
void test('invalid action and fact drafts stay editable and disabled callbacks preserve the original tree', () => {
for (const invalid of [NaN, -1, 0, 0.5, Number.MAX_SAFE_INTEGER + 1, Infinity]) {
const e = editor({ kind: 'and', terms: [fact(), payment()] });
minimum(e.tree(), '1.2', invalid); select(e.tree(), '1.2', 'recipient', ''); assert.ok(e.error());
click(control(e.tree(), 'or-1.2')); assert.ok(e.value.kind === 'and');
assert.deepEqual(e.value.terms[1], { kind: 'or', terms: [payment(invalid, ''), fact()] });
assert.match(e.html(), /role="alert"/);
minimum(e.tree(), '1.2.1', 2); select(e.tree(), '1.2.1', 'recipient', 'a'); assert.equal(e.error(), null);
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: NaN }); assert.ok(e.error());
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 2 }); assert.equal(e.error(), null);
}
const frozen = editor({ kind: 'and', terms: [fact(), payment()] }, true), before = structuredClone(frozen.value);
for (const key of ['and-1', 'add-action-1', 'nested-or-1', 'remove-1.2', 'fact-or-1.1']) click(control(frozen.tree(), key));
minimum(frozen.tree(), '1.2', 3); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.1', 'timing', 'action');
editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
});
void test('current fact eligibility and aggregate editor guidance never inspect hidden custody or the held auction card', () => {
const game = projected(), value: Expression = { kind: 'and', terms: [
fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), payment(2),
] };
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'bribes'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden lot inspection'); } });
const html = renderToStaticMarkup(createElement(MixedBribeFields, { value, game, target: 'p', disabled: false, onChange() {} }));
assert.match(html, /Current facts are evaluated when the player answers/); assert.match(html, /Only the aggregate answer is shared/);
assert.match(html, /unknown current fact can still allow a definite whole answer/);
assert.match(html, /If no such value is available, the answer is I don’t know/);
assert.equal(mixedBribeInputError(value, game, 'p'), null);
assert.ok(mixedBribeInputError({ ...value, binding: true }, game, 'p'));
assert.ok(mixedBribeInputError({ kind: 'and', terms: [fact(), { kind: 'constant', value: true }, payment()] }, game, 'p'));
const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.equal(sent[0], game.bribePromiseGuide!.action);
});
void test('own mixed panel shows raw whole question, aggregate answer and cumulative progress without private compiled fields', () => {
const game = projected(); game.bribePromiseGuide = null;
game.mixedBribePromises.push(
{ ...game.mixedBribePromises[0], event: 'no-event', answer: false },
{ ...game.mixedBribePromises[0], event: 'fulfilled-event', fulfilled: true },
{ ...game.mixedBribePromises[0], event: 'released-event', released: true },
{ ...game.mixedBribePromises[0], event: 'foreign-event', player: 'a', mixed: { kind: 'and', terms: [
fact({ kind: 'hand', name: 'Poison Blade' }), payment(999, 'foreign-recipient'),
] } },
);
for (const promise of game.mixedBribePromises) for (const key of ['binding', 'bindingflag', 'residual', 'factAnswers', 'compiled', 'frozenFact'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
assert.match(html, /you hold Shield/); assert.match(html, /You answered Yes/); assert.match(html, /You answered No/);
assert.match(html, / OR /); assert.match(html, / AND /); assert.match(html, /Recorded future ordinary payments: Recipient: 1 spice; Asker: 0 spice/);
assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
assert.match(html, /Individual fact answers and any reduced future branch remain private/);
assert.match(html, /No negates the whole expression, not each condition separately/);
assert.match(html, /Smaller payments to the same recipient add together after the answer/);
assert.match(html, /Prior payments, gifts and ally aid do not count/);
assert.doesNotMatch(html, /Poison Blade|999|foreign-recipient|controlled-mixed-bribe-event|fulfilled-event|factAnswers/);
assert.equal(MixedBribePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('mixed receipts and exact native payment guide work through phases zero to eight in Basic and Advanced', () => {
for (const advanced of [false, true]) for (let phase = 0; phase <= 8; phase++) {
const game = projected(phase, advanced), sent: Action[] = [];
game.ready = ['p'];
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.bribePromiseGuide!.action]); assert.equal(sent[0], game.bribePromiseGuide!.action);
const html = markup(game); assert.match(html, /data-testid="bribe-promise-guide"/);
assert.match(html, /No stays binding until the actual turn ends; opening collection does not end it/);
if (phase === 8) assert.match(html, /Late payments still honor this turn’s answers; their recipients collect at next turn’s Mentat opening/);
else assert.match(html, /Your next Mentat opening is still ahead. Accepted payments count now and their recipients collect at that opening/);
}
const game = projected(8); game.mixedBribePromises[0].fulfilled = true;
assert.doesNotMatch(markup(game), /No stays binding until the actual turn ends|data-testid="bribe-promise-guide"/);
assert.match(markup(game), /Payments made during Mentat Pause wait until the start of next turn&#x27;s Mentat Pause/);
assert.match(markup(game), /Collection does not count the payment a second time/);
});
void test('mixed stale native guide rejects changed event, owner, funding, phase, quiet window and room state', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 8; }, game => { game.status = 'setup'; },
game => { game.bribePromiseGuide = { action: { ...game.bribePromiseGuide!.action } }; },
game => { game.bribePromiseGuide = null; }, game => { game.bribePromiseGuide!.action.amount = 3; },
game => { game.bribePromiseGuide!.action.target = 'a'; }, game => { game.bribePromiseGuide!.action.bribePromiseEvent = 'other'; },
game => { game.mixedBribePromises[0].event = 'expired'; }, game => { game.mixedBribePromises[0].player = 'a'; },
game => { game.mixedBribePromises[0].turn = 1; }, game => { game.mixedBribePromises[0].fulfilled = true; },
game => { game.mixedBribePromises[0].released = true; }, game => { game.mixedBribePromises = []; },
game => { game.players = game.players.filter(player => player.id !== 'r'); },
game => { game.players.find(player => player.id === 'p')!.ally = 'r'; },
game => { game.bribeOptions.blocked = 'Finish a decision first.'; }, game => { game.bribeOptions.blocked = ''; },
game => { game.bribeOptions.available = 1; }, game => { game.bribeOptions.available = NaN; },
game => { game.bribeOptions.targets[0].maximum = 1; }, game => { game.bribeOptions.targets[0].maximum = NaN; },
game => { game.bribeOptions.targets = []; }, game => { game.phaseOpening = { passed: [] }; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.techTokens = createTechTokens(); },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
const busy = { game: projected(), busy: false, send() { assert.fail('Busy guide cannot send'); } };
const oldBusy = guide(busy); busy.busy = true; press(oldBusy);
const replaced = { game: projected(), busy: false, send() { assert.fail('Replaced view cannot send'); } };
const oldReplaced = guide(replaced); replaced.game = { ...replaced.game }; press(oldReplaced);
for (const event of ['', 'other-event']) {
const game = projected(); game.bribePromiseGuide!.action.bribePromiseEvent = event;
const button = guide({ game, busy: false, send() { assert.fail('Unknown event cannot send'); } });
assert.equal(button.props.disabled, true); press(button);
}
});
void test('one live panel owns the common exact guide across scalar, pure, mixed and fulfilled historical records', () => {
const game = projected();
const both = () => renderToStaticMarkup(createElement('div', null,
createElement(BribePromises, { game, busy: false, send() {} }),
createElement(BribeExpressionPromises, { game, busy: false, send() {} }),
createElement(MixedBribePromises, { game, busy: false, send() {} })));
const one = () => assert.equal((both().match(/data-testid="bribe-promise-guide"/g) ?? []).length, 1);
assert.match(markup(game), /data-testid="bribe-promise-guide"/); one();
game.bribePromises.push({ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, recipient: 'r', minimum: 1,
answer: true, progress: 1, fulfilled: true });
game.bribeExpressionPromises.push({ event: 'pure-history', player: 'p', asker: 'a', turn: 1,
claim: { kind: 'payment', recipient: 'r', minimum: 1 }, answer: true, progress: [{ recipient: 'r', amount: 1 }], fulfilled: true });
assert.match(markup(game), /data-testid="bribe-promise-guide"/); one();
game.bribeExpressionPromises.push({ event: 'live-pure', player: 'p', asker: 'a', turn: 2,
claim: { kind: 'payment', recipient: 'r', minimum: 3 }, answer: true, progress: [] });
assert.doesNotMatch(markup(game), /data-testid="bribe-promise-guide"/); one();
game.bribePromises.push({ event: 'live-scalar', player: 'p', asker: 'a', turn: 2, recipient: 'r', minimum: 3,
answer: true, progress: 0 });
assert.doesNotMatch(renderToStaticMarkup(createElement(BribeExpressionPromises, { game, busy: false, send() {} })), /data-testid="bribe-promise-guide"/);
one(); game.bribePromises[1].fulfilled = true; one();
game.bribeExpressionPromises[1].fulfilled = true; assert.match(markup(game), /data-testid="bribe-promise-guide"/); one();
game.mixedBribePromises[0].fulfilled = true;
assert.doesNotMatch(both(), /data-testid="bribe-promise-guide"/);
});
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { truthFactAnswer, type TruthFact } from '../game/truthtrance';
import {
actTruthLeaderRevival as act, createTruthLeaderRevivalFixture as fixture,
declareTruthLeaderRevival as declare, finishTruthLeaderRevival as finish,
settleTruthLeaderRevival as settle, truthLeaderRevivalInventory as inventory,
truthLeaderRevivalPlayer as player,
} from './fixture-truth-leader-revival';
import {
answerTruthMixedBribe as answer, askTruthMixedBribe as ask, truthMixedBribeFact as fact,
truthMixedBribeGroup as group, truthMixedBribePayment as payment, truthMixedBribePromise as promise,
} from './fixture-truth-mixed-bribe';
for (const first of ['bribe', 'revival'] as const) void test(`native mixed current spice and payments retain actual native leader funding with ${first} accepted first`, () => {
// This original H/A/T Ix47 source really pays for a shipment, loses Hidar
// to the original projectile, and reaches next-turn native Revival with4.
// No purse, death, hand, clock or commitment is installed by this supplement.
const f = fixture(); assert.equal(f.originalCards.length, 47); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.game.phase, 4); assert.equal(f.game.turn, 2);
assert.equal(player(f.game, f.target).spice, 4); assert.equal(player(f.game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 4 };
const mixed = group('and', fact(spiceFact), payment(f.asker, 1), payment(f.opponent, 1));
assert.equal(truthFactAnswer(player(f.game, f.target), spiceFact), 'yes');
let game = declare(f, f.game, { copies: 2 }).game;
const bribe = () => { game = answer(ask(game, f.asker, f.target, mixed), f.target, 'yes'); };
const revival = () => {
game = act(game, f.asker, { type: 'truthAsk', question: f.question });
assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['yes', 'no']);
game = answer(game, f.target, 'yes');
};
if (first === 'bribe') { bribe(); revival(); } else { revival(); bribe(); }
const before = structuredClone(game);
// Actual free force revival can prepare one spice, still insufficient for
// H3 together with the owed A1 payment and this named leader's native2.
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 })); assert.deepEqual(game, before);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
assert.equal(player(game, f.target).spice, 3); assert.equal(truthFactAnswer(player(game, f.target), spiceFact), 'no');
assert.deepEqual(promise(game, f.target).progress, [{ recipient: f.asker, amount: 1 }]);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!game.leaderRevivalPromises![0].fulfilled);
game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 1 });
assert.equal(player(game, f.target).spice, 2); assert.equal(promise(game, f.target).fulfilled, true);
const guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'reviveLeader'); assert.equal(guide.action.leader, f.leader);
game = settle(act(game, f.target, guide.action)); assert.equal(player(game, f.target).spice, 0);
assert.equal(game.leaderRevivalPromises![0].fulfilled, true); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
assert.equal(player(game, f.asker).bribes, 1); assert.equal(player(game, f.opponent).bribes, 1);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
+277
View File
@@ -0,0 +1,277 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
import { enterMentatBribeWithUnpaidPromise } from './fixture-mentat-bribes';
import {
actTruthMixedBribe as act, answerTruthMixedBribe as answer, askTruthMixedBribe as ask,
bindTruthMixedBribe as bind, completeTruthMixedBribe as complete, createTruthMixedBribeFixture as fixture,
declareTruthMixedBribe as declare, finishTruthMixedBribe as finish, truthMixedBribeBalances as balances,
truthMixedBribeFact as fact, truthMixedBribeGroup as group, truthMixedBribeInventory as inventory,
truthMixedBribePayment as payment, truthMixedBribePlayer as player, truthMixedBribePromise as promise,
} from './fixture-truth-mixed-bribe';
const progress = (game: Game, recipient: string) => promise(game).progress.find(row => row.recipient === recipient)?.amount ?? 0;
const pay = (game: Game, actor: string, recipient: string, amount: number) => act(game, actor, { type: 'bribe', target: recipient, amount });
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function truthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} mixed spice fact freezes before actual cumulative payments debit its original purse`, () => {
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.deepEqual(f.game.expansions, []);
assert.equal(f.sourceActionCount, advanced ? 23 : 20); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
const source = structuredClone(f.game), before = balances(f.game); let game = bind(f).game;
assert.deepEqual(f.game, source); assert.deepEqual(promise(game).mixed, f.mixed); assert.deepEqual(promise(game).progress, []);
assert.equal(game.auction!.active, f.asker); assert.notEqual(game.auction!.active, f.target); truthOnce(game, f.truth);
game = pay(game, f.target, f.asker, 1);
assert.equal(progress(game, f.asker), 1); assert.ok(!promise(game).fulfilled);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
game = pay(game, f.target, f.asker, 2); assert.equal(progress(game, f.asker), 3); assert.ok(!promise(game).fulfilled);
game = pay(game, f.target, f.opponent, 2); assert.equal(progress(game, f.opponent), 2);
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false); assert.equal(player(game, f.target).spice, 5);
for (const recipient of [f.asker, f.opponent]) assert.equal(player(game, recipient).spice, before[recipient]);
assert.equal(player(game, f.asker).bribes, 3); assert.equal(player(game, f.opponent).bribes, 2);
const result = complete(f, game);
for (const [recipient, amount] of [[f.asker, 3], [f.opponent, 2]] as const) {
assert.equal(player(result.mentat.before, recipient).bribes, amount);
assert.equal(player(result.mentat.after, recipient).spice - player(result.mentat.before, recipient).spice, amount);
assert.equal(player(result.mentat.after, recipient).bribes, 0); assert.equal(player(result.game, recipient).bribes, 0);
assert.equal(progress(result.game, recipient), amount);
}
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0); truthOnce(result.game, f.truth);
assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('native true held Shield OR future payment gives immediate Yes without a residual binding', () => {
const f = fixture(), mixed = group('or', fact(f.shieldFact), payment(f.asker)), declared = declare(f);
assert.equal(truthFactAnswer(player(f.game, f.target), f.shieldFact), 'yes');
const asked = ask(declared.game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBribeAnswers, ['yes']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' });
let game = answer(asked, f.target, 'yes'); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(promise(game).progress, []); assert.deepEqual(promise(game).mixed, mixed);
assert.equal(game.bribeExpressionPromises?.length ?? 0, 0); assert.equal(viewGame(game, f.target).bribePromiseGuide, null);
game = finish(game); assert.equal(player(game, f.target).spice, 10); truthOnce(game, f.truth);
assert.ok(player(game, f.target).hand.some(card => card.id === f.targetCard)); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false held Hajr AND future payment gives immediate No while the actual payment remains legal', () => {
const f = fixture(), mixed = group('and', fact(f.hajrFact), payment(f.asker)), declared = declare(f);
assert.equal(truthFactAnswer(player(f.game, f.target), f.hajrFact), 'no');
const asked = ask(declared.game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBribeAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'no'); assert.equal(promise(game).fulfilled, true);
assert.equal(game.bribeExpressionPromises?.length ?? 0, 0);
game = pay(game, f.target, f.asker, 3); assert.equal(player(game, f.target).spice, 7);
assert.equal(player(game, f.asker).bribes, 3); assert.deepEqual(promise(game).progress, []);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native false Hajr OR future payment Yes requires a real debit rather than the frozen false fact', () => {
const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.hajrFact), payment(f.asker))).game;
assert.ok(!promise(game).fulfilled); assert.deepEqual(promise(game).progress, []);
game = pay(game, f.target, f.asker, 2); assert.ok(!promise(game).fulfilled);
game = pay(game, f.target, f.asker, 1); assert.equal(promise(game).fulfilled, true);
assert.equal(progress(game, f.asker), 3); assert.equal(player(game, f.target).spice, 7);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native true Shield AND future payment No caps the real cumulative amount through turn end', () => {
const f = fixture(); let game = bind(f, 'no', group('and', fact(f.shieldFact), payment(f.asker))).game;
assert.ok(!promise(game).fulfilled); rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
game = pay(game, f.target, f.asker, 2); assert.equal(progress(game, f.asker), 2);
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
game = finish(game); assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, 8);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native unknown non-Fremen forecast uses the conservative whole ${answerValue} payment constraint`, () => {
const f = fixture(), p = player(f.game, f.target);
assert.equal(p.faction, 'emperor'); assert.equal(truthKnowledgeOf(f.game, p).stormForecast, null);
assert.equal(truthFactAnswer(p, f.forecastFact, truthKnowledgeOf(f.game, p)), 'unknown');
const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), payment(f.asker));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBribeAnswers, [answerValue]);
rejected(asked, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
let game = answer(asked, f.target, answerValue); assert.ok(!promise(game).fulfilled);
if (answerValue === 'yes') {
game = pay(game, f.target, f.asker, 3); assert.equal(promise(game).fulfilled, true); assert.equal(progress(game, f.asker), 3);
} else {
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
game = pay(game, f.target, f.asker, 2); assert.ok(!promise(game).fulfilled);
}
game = finish(game); assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed OR keeps the actual alternate recipient available instead of preselecting a branch', () => {
const f = fixture(), mixed = group('or', group('and', fact(f.shieldFact), payment(f.asker, 3)), payment(f.opponent, 2));
let game = bind(f, 'yes', mixed).game;
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); assert.ok(typeof guide.action.target === 'string');
const recipient = guide.action.target === f.asker ? f.opponent : f.asker, amount = recipient === f.asker ? 3 : 2;
assert.notEqual(recipient, guide.action.target); game = pay(game, f.target, recipient, amount);
assert.equal(promise(game).fulfilled, true); assert.equal(progress(game, recipient), amount);
assert.equal(progress(game, guide.action.target), 0); assert.equal(player(game, f.target).spice, 10 - amount);
rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native repeated mixed recipient thresholds use one cumulative actual total', () => {
const f = fixture(), mixed = group('and', fact(f.shieldFact), payment(f.asker, 3), payment(f.asker, 5));
let game = bind(f, 'yes', mixed).game;
game = pay(game, f.target, f.asker, 3); assert.equal(progress(game, f.asker), 3); assert.ok(!promise(game).fulfilled);
game = pay(game, f.target, f.asker, 2); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(promise(game).progress, [{ recipient: f.asker, amount: 5 }]);
assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.asker).bribes, 5);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native earlier payments remain in the receipt but start outside newly answered mixed progress', () => {
const f = fixture(), mixed = group('and', fact(f.shieldFact), payment(f.asker, 3), payment(f.opponent, 2));
let game = pay(f.game, f.target, f.asker, 3); game = bind(f, 'yes', mixed, game).game;
assert.deepEqual(promise(game).progress, []); assert.equal(player(game, f.asker).bribes, 3);
game = pay(game, f.target, f.opponent, 2); assert.ok(!promise(game).fulfilled);
game = pay(game, f.target, f.asker, 3); assert.equal(promise(game).fulfilled, true);
assert.equal(progress(game, f.asker), 3); assert.equal(player(game, f.asker).bribes, 6); assert.equal(player(game, f.target).spice, 2);
const result = complete(f, game);
assert.equal(player(result.mentat.after, f.asker).spice - player(result.mentat.before, f.asker).spice, 6);
assert.equal(progress(result.game, f.asker), 3); assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('two original Truths retain scalar H7 funding while the mixed OR shares the actual ten spice', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target, turn: game.turn,
recipient: f.asker, minimum: 7 } });
game = answer(game, f.target, 'yes');
game = ask(game, f.asker, f.target, group('and', fact(f.spiceFact), group('or', payment(f.asker, 7), payment(f.opponent, 4))));
assert.deepEqual(viewGame(game, f.target).truthMixedBribeAnswers, ['yes']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = answer(game, f.target, 'yes');
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 });
game = pay(game, f.target, f.opponent, 3); assert.ok(!promise(game).fulfilled);
game = pay(game, f.target, f.asker, 7); assert.equal(promise(game).fulfilled, true);
assert.equal(game.bribePromises![0].progress, 7); assert.equal(game.bribePromises![0].fulfilled, true);
assert.equal(progress(game, f.opponent), 3); assert.equal(progress(game, f.asker), 7); assert.equal(player(game, f.target).spice, 0);
for (const truth of f.truths) truthOnce(game, truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truths retain scalar No H5 while a mixed Yes follows the actual alternate A2 guide', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target, turn: game.turn,
recipient: f.asker, minimum: 5 } });
game = answer(game, f.target, 'no');
game = answer(ask(game, f.asker, f.target, group('and', fact(f.shieldFact),
group('or', payment(f.asker, 5), payment(f.opponent, 2)))), f.target, 'yes');
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide);
assert.equal(guide.action.target, f.opponent); assert.equal(guide.action.amount, 2);
assert.equal(guide.action.bribePromiseEvent, promise(game).event);
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 5 }); game = act(game, f.target, guide.action);
assert.equal(game.bribePromises![0].progress, 0); assert.ok(!game.bribePromises![0].fulfilled);
assert.equal(progress(game, f.opponent), 2); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 8); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed projection retains only owned raw question and aggregate with no frozen residual clauses', () => {
const f = fixture(), game = bind(f).game;
for (const actor of [f.target, f.asker, f.opponent]) {
const view = viewGame(game, actor); assert.ok(!('mixedBribeQuestions' in view)); assert.deepEqual(view.bribeExpressionPromises, []);
assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
assert.equal(view.truthMixedBribeAnswers, null);
if (actor !== f.target) {
assert.deepEqual(view.mixedBribePromises, []); assert.equal(view.bribePromiseGuide, null);
} else {
const row = promise(game), required = ['answer', 'asker', 'event', 'mixed', 'player', 'progress', 'turn'];
const allowed = [...required, 'fulfilled', 'released'];
assert.ok(required.every(key => Object.keys(row).includes(key))); assert.ok(Object.keys(row).every(key => allowed.includes(key)));
assert.deepEqual(row.mixed, f.mixed); assert.deepEqual(row.progress, []);
assert.equal(row.asker, f.asker); assert.equal(row.player, f.target); assert.equal(row.turn, game.turn);
assert.ok(view.bribePromiseGuide?.action.bribePromiseEvent); assert.ok(!JSON.stringify(row).includes('"claim"'));
}
}
assert.equal(game.bribeExpressionPromises?.length, 1); assert.equal(game.bribeExpressionPromises![0].event, promise(game).event);
});
void test('native exact mixed payment question and guide keep target turn event and JSON wire authoritative', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthMixedBribeTargets.includes(f.target));
rejected(declared.game, f.target, 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,
mixed: group('and', fact(f.shieldFact), payment(f.target, 1)) } });
const asked = ask(declared.game, f.asker, f.target, group('and', fact(f.shieldFact), payment(f.asker, 3)));
assert.equal(viewGame(asked, f.asker).truthMixedBribeAnswers, null);
assert.deepEqual(viewGame(asked, f.target).truthMixedBribeAnswers, ['yes', 'no']);
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' }); let game = answer(asked, f.target, 'yes');
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); assert.ok(typeof guide.action.amount === 'number');
assert.equal(guide.action.bribePromiseEvent, promise(game).event); rejected(game, f.opponent, guide.action);
rejected(game, f.target, { ...guide.action, bribePromiseEvent: 'stale-mixed-bribe-event' });
rejected(game, f.target, { ...guide.action, amount: guide.action.amount + 1 });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
assert.equal(promise(game).fulfilled, true); rejected(game, f.target, wire); rejected(finish(game), f.target, wire);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} Mentat mixed Yes reopens its ready payer and pays at the actual next opening`, () => {
const f = fixture({ advanced, phase: 8 }); assert.equal(f.sourceActionCount, advanced ? 35 : 32);
assert.equal(f.sourceActionCount, f.sourceActions.length); let game = act(f.game, f.target, { type: 'ready' });
game = act(game, f.asker, { type: 'ready' }); assert.equal(game.phase, 8);
assert.ok(game.ready.includes(f.target)); assert.ok(game.ready.includes(f.asker));
game = bind(f, 'yes', f.mixed, game).game;
assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.asker)); assert.ok(!game.ready.includes(f.opponent));
rejected(game, f.target, { type: 'ready' }); const before = balances(game);
game = pay(game, f.target, f.asker, 3); rejected(game, f.target, { type: 'ready' });
game = pay(game, f.target, f.opponent, 2); game = act(game, f.target, { type: 'ready' });
assert.equal(promise(game).fulfilled, true); assert.ok(game.ready.includes(f.target));
for (const recipient of [f.asker, f.opponent]) assert.equal(player(game, recipient).spice, before[recipient]);
const result = complete(f, game); assert.equal(result.nextTurn.after.turn, 2); assert.equal(result.nextTurn.after.phase, 0);
for (const [recipient, amount] of [[f.asker, 3], [f.opponent, 2]] as const) {
for (const state of [result.nextTurn.after, result.collection.after, result.mentat.before]) {
assert.equal(player(state, recipient).bribes, amount); assert.equal(player(state, f.target).spice, 5);
}
assert.equal(player(result.mentat.after, recipient).spice - player(result.mentat.before, recipient).spice, amount);
assert.equal(player(result.mentat.after, recipient).bribes, 0);
}
truthOnce(result.game, f.truth); assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('native mixed No remains binding across actual opening receipts and late payment until turn end', () => {
const f = fixture({ phase: 7 }); let game = bind(f, 'no').game;
game = pay(game, f.target, f.asker, 3); const before = player(game, f.asker).spice;
game = enterMentatBribeWithUnpaidPromise(game); assert.equal(game.phase, 8);
assert.equal(player(game, f.asker).spice, before + 3); assert.equal(player(game, f.asker).bribes, 0);
assert.equal(progress(game, f.asker), 3); game = pay(game, f.target, f.opponent, 1);
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 1 }); assert.ok(!promise(game).fulfilled);
game = finish(game); assert.equal(game.turn, 2); assert.equal(promise(game).fulfilled, true);
game = pay(game, f.target, f.opponent, 2); assert.equal(progress(game, f.opponent), 1);
assert.equal(player(game, f.opponent).bribes, 3); assert.equal(player(game, f.target).spice, 4);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies actually answer mixed Yes and submit its exact ordinary payment guides', () => {
const f = fixture({ phase: 8 }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked);
const answering = viewGame(game, f.target); answering.players.find(seat => seat.id === f.target)!.bot = difficulty;
const answerAction = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
assert.equal(answerAction.answer, 'yes', difficulty); assert.ok(answering.truthMixedBribeAnswers?.includes('yes'));
game = act(game, f.target, answerAction);
for (let count = 0; count < 8 && !promise(game).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const guide = view.bribePromiseGuide; assert.ok(guide, difficulty);
const action = botActions(view).find(action => action.type === 'bribe' && action.bribePromiseEvent === guide.action.bribePromiseEvent);
assert.ok(action, difficulty); assert.deepEqual(action, guide.action); game = act(game, f.target, action);
}
assert.equal(promise(game).fulfilled, true, difficulty); assert.equal(player(game, f.target).spice, 5);
assert.equal(player(game, f.asker).bribes, 3); assert.equal(player(game, f.opponent).bribes, 2);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});