Support Truthtrance in Richese Once Around bidding
This commit is contained in:
1 parent
65a1936b94
commit
090d764f6f
18 files changed
+1003
-7
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user