Support Truthtrance in Richese Once Around bidding

This commit is contained in:
Quintine committed 2026-10-27 17:56:20 +11:00
1 parent 65a1936b94
commit 090d764f6f
18 files changed
+1003 -7

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actTruthtranceRichesePromises as act,
advanceTruthtranceRichesePromises as advance,
createTruthtranceRichesePromisesFixture,
truthtranceRichesePromisesClean as clean,
truthtranceRichesePromisesInventory as inventory,
truthtranceRichesePromisesPlayer as player,
} from './fixture-truthtrance-richese-promises';
export { act, advance, clean, inventory, player };
/** Actual public R/A/E Basic or Advanced setup. The parent fixture selects only
* unopened original deal/Spice positions and native scalar lotteries. Use its
* genuine Charity prefix, then declare and start the original cache lot with
* native controls. No dealt card, wallet, lot or promise is installed. */
export function createTruthRicheseBidFixture(advanced = false) {
const original = createTruthtranceRichesePromisesFixture({ advanced });
const asker = original.asker, target = original.emperor, owner = original.richese;
let game: Game | undefined;
for (const direction of ['clockwise', 'counterclockwise'] as const) {
const candidate = advance(structuredClone(original.earlySource), state => clean(state) &&
!!state.richeseAuction && state.richeseAuction.method === 'onceAround' && !state.richeseAuction.outcome,
[], { position: 'first', method: 'onceAround', direction });
if (candidate.richeseAuction?.active === asker && candidate.richeseAuction.order.includes(target)) {
game = candidate;
break;
}
}
assert.ok(game, 'One native physical direction opens with the Truth holder before Emperor');
const lot = game.richeseAuction!;
assert.equal(lot.source, 'cache'); assert.equal(lot.bid, 0);
assert.equal(lot.active, asker); assert.equal(lot.order.at(-1), owner);
assert.ok(lot.eligible.includes(target));
assert.ok(player(game, asker).hand.some(card => card.id === original.truth));
assert.deepEqual(inventory(game), original.originalCards);
return { original, game, asker, target, owner, lot: lot.event,
card: lot.cardId, truth: original.truth, originalCards: original.originalCards,
staging: original.staging };
}
export type TruthRicheseBidFixture = ReturnType<typeof createTruthRicheseBidFixture>;
export function declareTruthRicheseBid(f: TruthRicheseBidFixture, source = f.game) {
let game = act(source, f.asker, { type: 'card', card: f.truth });
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
return game;
}
export function askTruthRicheseBid(f: TruthRicheseBidFixture, target = f.target, minimum = 4, source = f.game) {
const declared = declareTruthRicheseBid(f, source);
return act(declared, f.asker, { type: 'truthAsk', question: {
kind: 'richeseBid', target, turn: source.turn, event: f.lot, minimum,
} });
}
export function bindTruthRicheseBid(f: TruthRicheseBidFixture, answer: 'yes' | 'no' = 'yes', minimum = 4,
source = f.game, target = f.target) {
const asked = askTruthRicheseBid(f, target, minimum, source);
assert.ok(viewGame(asked, target).truthRicheseBidAnswers?.includes(answer));
const game = act(asked, target, { type: 'truthAnswer', answer });
return { game, asked };
}
export function rejectTruthRicheseBid(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'Rejected native action must not mutate original state');
}
@@ -0,0 +1,88 @@
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 { RicheseBidPromiseFields, richeseBidMinimumError } from '../components/richese-bid-promise-fields';
import { RicheseBidPromiseGuide, RicheseBidPromisePanel, type RicheseBidPromisePanelProps } from '../components/richese-bid-promise-panel';
import type { Action } from '../game/engine';
type ElementProps = { children?: ReactNode; [key: string]: unknown };
function elements(node: ReactNode): ReactElement<ElementProps>[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function edit(props: Parameters<typeof RicheseBidPromiseFields>[0], minimum: number) {
const input = elements(RicheseBidPromiseFields(props)).find(element =>
element.props.id === 'truth-richese-bid-minimum' && typeof element.props.onChange === 'function');
assert.ok(input);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: minimum } });
}
function projected(type: 'bid' | 'pass' = 'bid'): { props: RicheseBidPromisePanelProps; sent: Action[] } {
const sent: Action[] = [];
const props: RicheseBidPromisePanelProps = {
promises: [{ id: 'receipt-1', turn: 2, event: 'lot-1', minimum: 4, answer: type === 'bid' }],
guide: { action: { type: 'richeseBid', event: 'lot-1', richeseBidPromiseId: 'receipt-1',
amount: type === 'bid' ? 4 : null, allyPayment: 0 } },
game: { turn: 2, richeseAuction: { event: 'lot-1' } }, busy: false,
act(action) { sent.push(action); },
};
return { props, sent };
}
void test('Richese Once Around condition accepts only positive safe integer minima', () => {
const changes: number[] = [], props = { minimum: 4, disabled: false, onMinimum: (value: number) => changes.push(value) };
const html = renderToStaticMarkup(createElement(RicheseBidPromiseFields, props));
assert.match(html, /Once Around/);
assert.match(html, /Earlier declarations do not count/);
for (const value of [1, 7, Number.MAX_SAFE_INTEGER]) edit(props, value);
assert.deepEqual(changes, [1, 7, Number.MAX_SAFE_INTEGER]);
for (const value of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
assert.ok(richeseBidMinimumError(value)); edit(props, value);
}
edit({ ...props, disabled: true }, 8);
assert.equal(changes.length, 3);
});
void test('owned receipt panel explains binding, fulfilled and released answers', () => {
const { props } = projected();
props.promises.push(
{ id: 'no', event: 'lot-1', turn: 2, minimum: 8, answer: false },
{ id: 'done', event: 'lot-1', turn: 2, minimum: 2, answer: true, fulfilled: true },
{ id: 'released', event: 'lot-1', turn: 2, minimum: 12, answer: true, released: true },
);
const html = renderToStaticMarkup(createElement(RicheseBidPromisePanel, props));
assert.match(html, /Your Once Around bid promises/);
assert.match(html, /No forbids a qualifying bid/);
assert.match(html, /Fulfilled: this commitment is complete/);
assert.match(html, /Released: this commitment is no longer possible/);
assert.doesNotMatch(html, /receipt-1|lot-1/);
});
void test('guide sends the exact server action and rejects busy, stale or completed receipts', () => {
for (const type of ['bid', 'pass'] as const) {
const { props, sent } = projected(type);
const button = RicheseBidPromiseGuide(props); assert.ok(button);
button.props.onClick();
assert.deepEqual(sent, [props.guide!.action]);
assert.equal(sent[0], props.guide!.action);
const stale = RicheseBidPromiseGuide(props); assert.ok(stale);
props.game.richeseAuction!.event = 'later-lot'; stale.props.onClick();
assert.equal(sent.length, 1);
assert.equal(RicheseBidPromiseGuide(props), null);
}
const { props, sent } = projected();
const button = RicheseBidPromiseGuide(props); assert.ok(button);
props.busy = true; button.props.onClick();
assert.deepEqual(sent, []);
const busyButton = RicheseBidPromiseGuide(props); assert.ok(busyButton);
assert.equal(busyButton.props.disabled, true);
props.busy = false;
const completed = RicheseBidPromiseGuide(props); assert.ok(completed);
props.promises[0].fulfilled = true; completed.props.onClick();
assert.deepEqual(sent, []);
assert.equal(RicheseBidPromiseGuide(props), null);
props.promises[0].fulfilled = false;
props.game.turn = 3;
assert.equal(RicheseBidPromiseGuide(props), null);
});
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
RicheseBidPromiseError, finishRicheseBidLot, liveRicheseBidPromises,
parseRicheseBidClaim, reconcileRicheseBidPromises, recordRicheseBid,
richeseBidAllowedRange, richeseBidClaimText, richeseBidCompletionRange,
richeseBidRequired, validateRicheseBidPromises,
type RicheseBidContext, type RicheseBidOpportunity, type RicheseBidPromise,
} from '../game/richese-bid-promises';
const context: RicheseBidContext = { turn: 2, event: 'public-lot', player: 'target' };
const opportunity: RicheseBidOpportunity = { ...context, minimum: 4, maximum: 10 };
const seats = ['target', 'asker', 'other'];
const promise = (id: string, minimum: number, answer = true,
extra: Partial<RicheseBidPromise> = {}): RicheseBidPromise => ({
id, ...context, asker: 'asker', minimum, answer, ...extra,
});
void test('claims retain only a safe integer turn, public lot event and positive bid minimum', () => {
assert.deepEqual(parseRicheseBidClaim({ turn: 2, event: 'public-lot', minimum: 6, ignored: true }),
{ turn: 2, event: 'public-lot', minimum: 6 });
for (const value of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined])
for (const field of ['turn', 'minimum'])
assert.throws(() => parseRicheseBidClaim({ turn: 2, event: 'public-lot', minimum: 6, [field]: value }), RicheseBidPromiseError);
for (const event of ['', ' ', ' lot ', null, 1, undefined])
assert.throws(() => parseRicheseBidClaim({ turn: 2, event, minimum: 6 }), RicheseBidPromiseError);
for (const value of [null, undefined, [], 3, 'claim'])
assert.throws(() => parseRicheseBidClaim(value), RicheseBidPromiseError);
const text = richeseBidClaimText({ turn: 2, event: 'public-lot', minimum: 6 });
assert.match(text, /bid of at least 6 spice/);
assert.match(text, /Once Around/);
assert.doesNotMatch(text, /win|purchase|public-lot/);
});
void test('the engine-supplied interval combines every Yes threshold and No cap', () => {
const rows = [promise('yes-6', 6), promise('yes-7', 7), promise('no-9', 9, false), promise('no-8', 8, false)];
assert.deepEqual(richeseBidAllowedRange(rows, opportunity), { minimum: 4, maximum: 7 });
assert.deepEqual(richeseBidCompletionRange(rows, opportunity), { minimum: 7, maximum: 7 });
assert.equal(richeseBidRequired(rows, context), true);
assert.equal(richeseBidCompletionRange(rows, { ...opportunity, maximum: 6 }), null);
assert.equal(richeseBidCompletionRange(rows, { ...opportunity, minimum: 8 }), null);
assert.equal(richeseBidRequired([promise('no-only', 4, false)], context), false);
assert.equal(richeseBidAllowedRange([promise('no-only', 4, false)], opportunity), null);
});
void test('live answers bind the precise bidder, turn and public lot and preserve separate receipts', () => {
const original = [promise('yes', 6), promise('no', 9, false),
promise('other-player', 8, true, { player: 'other' }),
promise('other-turn', 8, true, { turn: 3 }),
promise('other-lot', 8, true, { event: 'new-lot' }),
promise('past', 8, true, { fulfilled: true })];
assert.deepEqual(liveRicheseBidPromises(original, context).map(row => row.id), ['yes', 'no']);
validateRicheseBidPromises(original, seats);
const parsed = JSON.parse(JSON.stringify(original)) as RicheseBidPromise[];
assert.deepEqual(liveRicheseBidPromises(parsed, context).map(row => row.id), ['yes', 'no']);
});
void test('an actual single qualifying bid fulfills Yes and No without requiring a win', () => {
const rows = [promise('yes', 6), promise('no', 9, false), promise('other', 2, true, { event: 'next' })];
const frozen = structuredClone(rows);
const result = recordRicheseBid(rows, context, 7);
assert.equal(result[0].fulfilled, true);
assert.equal(result[1].fulfilled, true);
assert.equal(result[2].fulfilled, undefined);
assert.deepEqual(rows, frozen);
assert.equal(richeseBidRequired(result, context), false);
for (const amount of [null, 4, 5, 9, 10, 0, -1, 1.5, Number.NaN, Infinity])
assert.throws(() => recordRicheseBid(rows, context, amount), RicheseBidPromiseError, String(amount));
});
void test('passing satisfies No but cannot evade a still-live Yes', () => {
const no = [promise('no', 6, false)];
assert.equal(recordRicheseBid(no, context, null)[0].fulfilled, true);
assert.equal(recordRicheseBid(no, context, 5)[0].fulfilled, true);
assert.throws(() => recordRicheseBid(no, context, 6), RicheseBidPromiseError);
const yes = [promise('yes', 6)];
assert.throws(() => recordRicheseBid(yes, context, null), RicheseBidPromiseError);
assert.throws(() => recordRicheseBid(yes, context, 5), RicheseBidPromiseError);
assert.equal(recordRicheseBid(yes, context, 6)[0].fulfilled, true);
});
void test('external funding or competing bid releases only individually impossible Yes', () => {
const rows = [promise('yes-8', 8), promise('yes-6', 6), promise('no-9', 9, false)];
const actual = { ...opportunity, maximum: 7 };
const reduced = reconcileRicheseBidPromises(rows, context, actual, 'external');
assert.equal(reduced[0].released, true);
assert.equal(reduced[1].released, undefined);
assert.equal(reduced[2].released, undefined);
assert.deepEqual(richeseBidCompletionRange(reduced, actual), { minimum: 6, maximum: 7 });
assert.throws(() => reconcileRicheseBidPromises(rows, context, actual, 'voluntary'), RicheseBidPromiseError);
assert.equal(rows[0].released, undefined);
const absent = reconcileRicheseBidPromises(rows, context, null, 'external');
assert.equal(absent[0].released, true);
assert.equal(absent[1].released, true);
assert.equal(absent[2].released, undefined);
assert.throws(() => reconcileRicheseBidPromises(rows, context, null, 'voluntary'), RicheseBidPromiseError);
});
void test('a rival bid beyond the No cap forces release, not an unlawful above-cap declaration', () => {
const rows = [promise('yes', 6), promise('no', 8, false)];
const rival = { ...opportunity, minimum: 8, maximum: 10 };
assert.equal(richeseBidCompletionRange(rows, rival), null);
const result = reconcileRicheseBidPromises(rows, context, rival, 'external');
assert.equal(result[0].released, true);
assert.equal(result[1].released, undefined);
assert.equal(recordRicheseBid(result, context, null)[1].fulfilled, true);
});
void test('lot close finishes No and externally releases unfulfilled Yes without affecting other lots', () => {
const rows = [promise('yes', 6), promise('no', 9, false), promise('next', 6, true, { event: 'next' })];
assert.throws(() => finishRicheseBidLot(rows, context, 'voluntary'), RicheseBidPromiseError);
const result = finishRicheseBidLot(rows, context, 'external');
assert.equal(result[0].released, true);
assert.equal(result[1].fulfilled, true);
assert.equal(result[2].released, undefined);
assert.equal(finishRicheseBidLot([rows[1]], context, 'voluntary')[0].fulfilled, true);
});
void test('invalid saved rows and mismatched opportunity contexts fail without mutating promises', () => {
const base = promise('id', 6);
for (const row of [
{ ...base, id: '' }, { ...base, id: ' id ' }, { ...base, asker: 'target' },
{ ...base, player: 'nobody' }, { ...base, answer: 'yes' },
{ ...base, fulfilled: true, released: true }, { ...base, fulfilled: 'yes' },
]) assert.throws(() => validateRicheseBidPromises([row as RicheseBidPromise], seats), RicheseBidPromiseError);
assert.throws(() => validateRicheseBidPromises([base, { ...base, minimum: 7 }], seats), RicheseBidPromiseError);
assert.throws(() => reconcileRicheseBidPromises([base], context, { ...opportunity, event: 'other' }, 'external'), RicheseBidPromiseError);
assert.throws(() => reconcileRicheseBidPromises([base], context, { ...opportunity, player: 'other' }, 'external'), RicheseBidPromiseError);
assert.throws(() => richeseBidAllowedRange([base], { ...opportunity, minimum: 0 }), RicheseBidPromiseError);
assert.throws(() => richeseBidAllowedRange([base], { ...opportunity, maximum: Infinity }), RicheseBidPromiseError);
assert.throws(() => richeseBidAllowedRange([base], { ...opportunity, maximum: 3 }), RicheseBidPromiseError);
assert.equal(base.fulfilled, undefined);
assert.equal(base.released, undefined);
});
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type Action, type GameView } from '../game/engine';
import { RicheseBidPromiseGuide } from '../components/richese-bid-promise-panel';
import { act, inventory, createTruthRicheseBidFixture, declareTruthRicheseBid,
askTruthRicheseBid } from './fixture-truth-richese-bid';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { GameTable } = await import('../components/game-table');
const { Truthtrance } = await import('../components/truthtrance');
const { RicheseAuctionLot } = await import('../components/richese-auctions');
aliases.deregister();
function table(game: GameView) {
return renderToStaticMarkup(createElement(GameTable, { game, busy: false, send: async () => {}, onExit() {} }));
}
function truth(game: GameView) {
return renderToStaticMarkup(createElement(Truthtrance, { game, busy: false, act() {} }));
}
function auction(game: GameView) {
return renderToStaticMarkup(createElement(RicheseAuctionLot, { game, busy: false, act() {} }));
}
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} real Once Around question, answers and guide connect to the original Richese table`, () => {
// Original public R/A/E setup, actual dealt Truth and native cache lot.
// The fixture selects only unopened original deal/Spice positions and scalar lotteries.
const f = createTruthRicheseBidFixture(advanced);
const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
assert.equal(source.richeseAuction?.active, f.target);
const declared = declareTruthRicheseBid(f, source);
const asker = viewGame(declared, f.asker);
assert.ok(asker.truthRicheseBidTargets.includes(f.target));
assert.match(truth(asker), /<option value="richeseBid">Bind a bid in this Once Around lot<\/option>/);
assert.match(table(asker), /Bind a bid in this Once Around lot/);
// Server rendering does not operate the stateful question selector; the
// original question is submitted through the real engine fixture.
const asked = askTruthRicheseBid(f, f.target, 4, source);
const respondent = viewGame(asked, f.target);
assert.equal(respondent.truthtrance?.question?.kind, 'richeseBid');
assert.ok(respondent.truthRicheseBidAnswers?.includes('yes'));
assert.ok(respondent.truthRicheseBidAnswers?.includes('no'));
assert.equal(viewGame(asked, f.asker).truthRicheseBidAnswers, null);
assert.match(truth(respondent), /Answer Yes/);
assert.match(truth(respondent), /Answer No/);
assert.match(table(respondent), /Answer Yes/);
assert.match(auction(respondent), /Richese auction/);
for (const answer of ['yes', 'no'] as const) {
let game = act(asked, f.target, { type: 'truthAnswer', answer });
const own = viewGame(game, f.target), sent: Action[] = [];
assert.ok(own.richeseBidPromises.some(promise => promise.player === f.target && promise.answer === (answer === 'yes')));
assert.ok(own.richeseBidPromiseGuide);
const html = table(own);
assert.match(html, /Your Once Around bid promises/);
assert.match(html, /data-testid="richese-bid-promise-guide"/);
assert.match(auction(own), /Richese auction/);
const button = RicheseBidPromiseGuide({ promises: own.richeseBidPromises, guide: own.richeseBidPromiseGuide,
game: own, busy: false, act(action) { sent.push(action); } });
assert.ok(button); assert.equal(button.props.disabled, false);
button.props.onClick();
assert.deepEqual(sent, [own.richeseBidPromiseGuide.action]);
assert.equal(sent[0].type, 'richeseBid');
assert.equal(sent[0].event, f.lot);
assert.equal(typeof sent[0].richeseBidPromiseId, 'string');
game = act(game, f.target, sent[0]);
assert.equal(game.richeseBidPromises!.find(promise => promise.id === sent[0].richeseBidPromiseId)?.fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
}
},
);
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
act, inventory, player, createTruthRicheseBidFixture, askTruthRicheseBid,
bindTruthRicheseBid, rejectTruthRicheseBid as reject,
} from './fixture-truth-richese-bid';
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} Once Around Yes requires a real qualifying funded bid`, () => {
const f = createTruthRicheseBidFixture(advanced);
const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
assert.equal(source.richeseAuction?.active, f.target);
const { game: bound } = bindTruthRicheseBid(f, 'yes', 4, source);
assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(bound.richeseAuction?.event, f.lot);
assert.equal(bound.richeseAuction?.cardId, f.card);
reject(bound, f.target, { type: 'richeseBid', event: f.lot, amount: null });
reject(bound, f.target, { type: 'richeseBid', event: f.lot, amount: 3, allyPayment: 0 });
const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'richeseBid'); assert.equal(guide.action.amount, 4);
const bid = act(bound, f.target, guide.action);
assert.ok(bid.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
assert.equal(player(bid, f.target).spice, 10, 'Declaring a bid does not pay the eventual invoice');
assert.deepEqual(inventory(bid), f.originalCards);
});
void test(`${mode} Once Around No caps a real bid and preserves the lot`, () => {
const f = createTruthRicheseBidFixture(advanced);
const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
assert.equal(source.richeseAuction?.active, f.target);
const { game: bound } = bindTruthRicheseBid(f, 'no', 4, source);
reject(bound, f.target, { type: 'richeseBid', event: f.lot, amount: 4, allyPayment: 0 });
const bid = act(bound, f.target, { type: 'richeseBid', event: f.lot, amount: 2, allyPayment: 0 });
assert.equal(bid.richeseAuction?.event, f.lot); assert.equal(bid.richeseAuction?.cardId, f.card);
assert.equal(bid.richeseAuction?.bid, 2); assert.equal(bid.richeseAuction?.bidder, f.target);
assert.deepEqual(inventory(bid), f.originalCards);
});
void test(`${mode} Richese cannot promise to bid when its final owner turn has no earlier sale`, () => {
const f = createTruthRicheseBidFixture(advanced);
const asked = askTruthRicheseBid(f, f.owner, 1);
assert.ok(!viewGame(asked, f.owner).truthRicheseBidAnswers?.includes('yes'));
reject(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(inventory(asked), f.originalCards);
});
void test(`${mode} an actual opposing high bid releases an impossible waiting Yes`, () => {
const f = createTruthRicheseBidFixture(advanced);
const { game: bound } = bindTruthRicheseBid(f, 'yes', 4);
assert.equal(bound.richeseAuction?.active, f.asker);
assert.equal(viewGame(bound, f.target).richeseBidPromiseGuide, null);
const raised = act(bound, f.asker, { type: 'richeseBid', event: f.lot, amount: 10, allyPayment: 0 });
assert.equal(raised.richeseAuction?.active, f.target);
assert.ok(raised.richeseBidPromises?.some(row => row.player === f.target && row.released));
const pass = act(raised, f.target, { type: 'richeseBid', event: f.lot, amount: null });
assert.deepEqual(inventory(pass), f.originalCards);
});
}
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} stale Once Around guided callbacks are rejected without changing the lot`, () => {
const f = createTruthRicheseBidFixture(advanced);
const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
const { game: bound } = bindTruthRicheseBid(f, 'yes', 4, source);
const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
reject(bound, f.target, { ...guide.action, richeseBidPromiseId: 'stale-promise' });
reject(bound, f.target, { ...guide.action, event: 'stale-lot' });
const accepted = act(bound, f.target, guide.action);
assert.ok(accepted.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
assert.deepEqual(inventory(accepted), f.originalCards);
});
void test(`${mode} four legal bot profiles answer and follow the actual Once Around guide`, () => {
const f = createTruthRicheseBidFixture(advanced);
const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
const asked = askTruthRicheseBid(f, f.target, 4, source);
for (const difficulty of DIFFICULTIES) {
const answerView = viewGame(asked, f.target);
answerView.players.find(row => row.id === f.target)!.bot = difficulty;
const answer = botActions(answerView)[0]; assert.ok(answer, difficulty);
assert.equal(answer.type, 'truthAnswer');
assert.ok(['yes', 'no'].includes(String(answer.answer)));
const bound = act(structuredClone(asked), f.target, answer);
const bidView = viewGame(bound, f.target);
bidView.players.find(row => row.id === f.target)!.bot = difficulty;
const guide = bidView.richeseBidPromiseGuide; assert.ok(guide, difficulty);
const chosen = botActions(bidView)[0]; assert.deepEqual(chosen, guide.action);
const completed = act(bound, f.target, chosen);
assert.ok(completed.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
assert.deepEqual(inventory(completed), f.originalCards);
}
});
}
// This Basic Richese fixture enables the Semuta preview, whose existing
// bribe promise profile does not support a Basic bribe question. The Advanced
// native fixture supports this independent-bribe admission path.
for (const advanced of [true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} a Once Around Yes excludes an independently unfunded later bribe Yes`, () => {
const f = createTruthRicheseBidFixture(advanced);
let source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
assert.equal(source.richeseAuction?.active, f.target);
assert.equal(player(source, f.target).spice, 10);
// Controlled physical supplement: move the second original Truthtrance
// from the unopened native main deck to the questioner's hand. No funds,
// promise, lot, phase or other card is installed; card custody is conserved.
source = structuredClone(source);
const at = source.deck.findIndex(card => card.effect === 'truthtrance' && card.id !== f.truth);
assert.ok(at >= 0);
const [second] = source.deck.splice(at, 1);
player(source, f.asker).hand.push(second);
assert.deepEqual(inventory(source), f.originalCards);
let game = bindTruthRicheseBid(f, 'yes', 8, source).game;
const prior = structuredClone(game.richeseBidPromises![0]);
game = act(game, f.asker, { type: 'card', card: second.id });
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'bribe', target: f.target, turn: game.turn, recipient: f.asker, minimum: 5,
} });
const answers = viewGame(game, f.target).truthBribeAnswers;
assert.ok(!answers?.includes('yes')); assert.ok(answers?.includes('no'));
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(game.richeseBidPromises![0], prior);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
assert.deepEqual(game.richeseBidPromises![0], prior);
const guide = viewGame(game, f.target).richeseBidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.amount, 8);
game = act(game, f.target, guide.action);
assert.ok(game.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
assert.deepEqual(inventory(game), f.originalCards);
});
}