Combine current facts with native bribe payments
This commit is contained in:
1 parent
b63a60da64
commit
6b2dfb84a2
23 files changed
+1445
-170
No files matched your search
@@ -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() {} })));
|
||||
|
||||
@@ -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'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"/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user