Support Truthtrance in Richese Silent bidding

This commit is contained in:
Quintine committed 2026-10-27 18:01:55 +11:00
1 parent 090d764f6f
commit 3061dc5004
18 files changed
+442 -50

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
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, inventory, player };
/** Original public Basic/Advanced Richese/Atreides/Emperor setup, genuine
* Charity prefix and actual first Silent cache declaration. The parent fixture
* selects unopened deal and Spice positions and native scalar lotteries; no
* hand, spice, auction, answer or receipt is installed. */
export function createTruthRicheseSilentFixture(advanced = false) {
const original = createTruthtranceRichesePromisesFixture({ advanced });
const game = advance(structuredClone(original.earlySource), state => clean(state) &&
!!state.richeseAuction && state.richeseAuction.method === 'silent' && !state.richeseAuction.outcome,
[], { position: 'first', method: 'silent' });
const lot = game.richeseAuction!;
const asker = original.asker, target = original.emperor, owner = original.richese;
assert.equal(lot.source, 'cache'); assert.equal(lot.active, null);
assert.ok(lot.eligible.includes(asker) && lot.eligible.includes(target) && lot.eligible.includes(owner));
assert.deepEqual(lot.sealed, {});
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 TruthRicheseSilentFixture = ReturnType<typeof createTruthRicheseSilentFixture>;
export function askTruthRicheseSilent(f: TruthRicheseSilentFixture, minimum = 4, 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 seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
game = act(game, seat.id, { type:'truthPass' });
}
assert.equal(game.truthtrance?.stage, 'ask');
return act(game, f.asker, { type:'truthAsk', question:{kind:'richeseBid', target:f.target,
turn:source.turn, event:f.lot, minimum} });
}
export function bindTruthRicheseSilent(f: TruthRicheseSilentFixture, answer: 'yes'|'no', minimum = 4, source = f.game) {
const asked = askTruthRicheseSilent(f, minimum, source);
assert.ok(viewGame(asked, f.target).truthRicheseBidAnswers?.includes(answer));
return { asked, game:act(asked, f.target, {type:'truthAnswer', answer}) };
}
export function rejectTruthRicheseSilent(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before, 'A rejected action cannot mutate its input');
}
+31 -3
View File
@@ -18,13 +18,13 @@ function edit(props: Parameters<typeof RicheseBidPromiseFields>[0], minimum: num
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[] } {
function projected(type: 'bid' | 'pass' | 'zero' = '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,
amount: type === 'bid' ? 4 : type === 'zero' ? 0 : null, allyPayment: 0 } },
game: { turn: 2, richeseAuction: { event: 'lot-1', method: type === 'zero' ? 'silent' : 'onceAround' } }, busy: false,
act(action) { sent.push(action); },
};
return { props, sent };
@@ -86,3 +86,31 @@ void test('guide sends the exact server action and rejects busy, stale or comple
props.game.turn = 3;
assert.equal(RicheseBidPromiseGuide(props), null);
});
void test('Silent question and private receipt explain sealed submissions without showing lot or receipt identifiers', () => {
const fields = renderToStaticMarkup(createElement(RicheseBidPromiseFields, {
minimum: 4, disabled: false, method: 'silent', onMinimum() {},
}));
assert.match(fields, /Silent auction/);
assert.match(fields, /unsubmitted sealed bid/);
assert.match(fields, /submitted amounts remain private until bids are revealed/);
const { props } = projected('zero');
const panel = renderToStaticMarkup(createElement(RicheseBidPromisePanel, props));
assert.match(panel, /Your Silent bid promises/);
assert.match(panel, /Submit zero spice while honoring your answer/);
assert.match(panel, /Your submitted amount remains private until all bids are revealed/);
assert.doesNotMatch(panel, /receipt-1|lot-1/);
});
void test('Silent zero guide submits the exact quoted action and refuses a stale event', () => {
const { props, sent } = projected('zero');
const button = RicheseBidPromiseGuide(props); assert.ok(button);
button.props.onClick();
assert.deepEqual(sent, [props.guide!.action]);
assert.equal(sent[0].amount, 0);
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);
});
+1 -1
View File
@@ -28,7 +28,7 @@ void test('claims retain only a safe integer turn, public lot event and positive
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.match(text, /Richese auction/);
assert.doesNotMatch(text, /win|purchase|public-lot/);
});
+1 -1
View File
@@ -325,7 +325,7 @@ void test('restored full hands automatically submit zero or remove an unbid cach
assert.equal(g.players[0].spice, 22);
assert.equal(g.players[0].hand.length, 4);
assert.ok(
g.log.some((entry) => entry.text.includes('automatically submitted zero')),
g.log.some((entry) => entry.text.includes('automatically submitted a sealed bid')),
);
let unbid = cache();
for (const id of ['h', 'e', 'r']) unbid = bid(unbid, id, 0);
@@ -45,8 +45,8 @@ for (const advanced of [false, true]) void test(
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/);
assert.match(truth(asker), /<option value="richeseBid">Bind a bid in this Richese lot<\/option>/);
assert.match(table(asker), /Bind a bid in this Richese 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);
@@ -0,0 +1,102 @@
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 Game, type GameView } from '../game/engine';
import { RicheseBidPromiseGuide } from '../components/richese-bid-promise-panel';
import { act, inventory, createTruthRicheseSilentFixture, askTruthRicheseSilent } from './fixture-truth-richese-silent';
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() {} }));
}
function declare(game: Game, asker: string, truthCard: string): Game {
let current = act(game, asker, { type:'card', card:truthCard });
for (let count = 0; current.truthtrance?.stage === 'priority' && count < current.players.length; count++) {
const seat = current.players.find(row => !current.truthtrance!.passed.includes(row.id)); assert.ok(seat);
current = act(current, seat.id, {type:'truthPass'});
}
assert.equal(current.truthtrance?.stage, 'ask');
return current;
}
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} real Silent question, answer and guide connect to the original private auction`, () => {
// Native public R/A/E setup, originally dealt Truth card and actual Silent
// cache; the fixture selects unopened deal/Spice positions and lotteries.
const f = createTruthRicheseSilentFixture(advanced);
const declared = declare(f.game, f.asker, f.truth);
const asker = viewGame(declared, f.asker);
assert.equal(asker.richeseAuction?.method, 'silent');
assert.ok(asker.truthRicheseBidTargets.includes(f.target));
assert.match(truth(asker), /<option value="richeseBid">Bind a bid in this Richese lot<\/option>/);
assert.match(table(asker), /Bind a bid in this Richese lot/);
// Server rendering does not operate the stateful question selector or
// browser hydration; submit the original question through the engine.
const asked = askTruthRicheseSilent(f, 4);
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), /Silent 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(row => row.player === f.target && row.answer === (answer === 'yes')));
assert.ok(own.richeseBidPromiseGuide);
const html = table(own);
assert.match(html, /Your Silent bid promises/);
assert.match(html, /data-testid="richese-bid-promise-guide"/);
if (answer === 'no') assert.match(html, /Submit zero spice while honoring your answer/);
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(sent[0].amount, answer === 'yes' ? 4 : 0);
assert.equal(typeof sent[0].richeseBidPromiseId, 'string');
game = act(game, f.target, sent[0]);
assert.equal(game.richeseBidPromises!.find(row => row.id === sent[0].richeseBidPromiseId)?.fulfilled, true);
const submitted = viewGame(game, f.target), observer = viewGame(game, f.owner);
assert.equal(submitted.richeseAuction?.ownBid, answer === 'yes' ? 4 : 0);
assert.equal(observer.richeseAuction?.ownBid, null);
assert.equal(observer.richeseAuction?.revealedBids, null);
assert.ok(observer.richeseAuction?.submitted.includes(f.target));
assert.equal(Object.hasOwn(observer.richeseAuction!, 'sealed'), false);
assert.match(auction(submitted), /Your sealed bid:/);
assert.doesNotMatch(auction(observer), /Your sealed bid:/);
assert.doesNotMatch(table(observer), /Your sealed bid:/);
assert.deepEqual(inventory(game), f.originalCards);
}
},
);
+131
View File
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handLimit, normalizeAutomaticGame, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { act, inventory, player, createTruthRicheseSilentFixture, askTruthRicheseSilent,
bindTruthRicheseSilent, rejectTruthRicheseSilent as reject } from './fixture-truth-richese-silent';
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} Silent Yes requires a funded sealed bid and reveals only after all submissions`, () => {
const f = createTruthRicheseSilentFixture(advanced);
const {game:bound} = bindTruthRicheseSilent(f, 'yes', 4);
assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(bound.richeseAuction?.event, f.lot);
reject(bound, f.target, {type:'richeseBid', event:f.lot, amount:0, allyPayment:0});
reject(bound, f.target, {type:'richeseBid', event:f.lot, amount:3, allyPayment:0});
reject(bound, f.target, {type:'richeseBid', event:f.lot, amount:11, allyPayment:0});
const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.amount, 4);
let game = act(bound, f.target, guide.action);
assert.ok(game.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
assert.equal(player(game, f.target).spice, 10, 'A sealed submission is not settlement');
const own = viewGame(game, f.target).richeseAuction!, other = viewGame(game, f.owner).richeseAuction!;
assert.equal(own.ownBid, 4); assert.equal(other.ownBid, null);
assert.equal(own.revealedBids, null); assert.equal(other.revealedBids, null);
assert.ok(other.submitted.includes(f.target));
assert.ok(!game.log.some(entry => entry.text.includes('Silent bids revealed together')));
game = act(game, f.asker, {type:'richeseBid', event:f.lot, amount:2, allyPayment:0});
assert.equal(viewGame(game, f.owner).richeseAuction?.revealedBids, null);
game = act(game, f.owner, {type:'richeseBid', event:f.lot, amount:0, allyPayment:0});
assert.ok(game.log.some(entry => entry.text.includes('Silent bids revealed together')));
assert.ok(player(game, f.target).hand.some(card => card.id === f.card));
assert.equal(player(game, f.target).spice, 6, 'The winning invoice is paid once');
assert.deepEqual(inventory(game), f.originalCards);
});
void test(`${mode} Silent No permits zero or a smaller sealed bid and rejects its threshold`, () => {
const f = createTruthRicheseSilentFixture(advanced), {game:bound} = bindTruthRicheseSilent(f, 'no', 4);
reject(bound, f.target, {type:'richeseBid', event:f.lot, amount:4, allyPayment:0});
reject(bound, f.target, {type:'richeseBid', event:f.lot, amount:null, allyPayment:0});
const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.amount, 0, 'Silent decline is a physical sealed zero');
const zero = act(bound, f.target, guide.action);
assert.equal(viewGame(zero, f.target).richeseAuction?.ownBid, 0);
assert.equal(viewGame(zero, f.owner).richeseAuction?.ownBid, null);
assert.ok(zero.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
const low = act(bound, f.target, {type:'richeseBid', event:f.lot, amount:3, allyPayment:0});
assert.equal(viewGame(low, f.target).richeseAuction?.ownBid, 3);
assert.equal(viewGame(low, f.owner).richeseAuction?.revealedBids, null);
assert.ok(low.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
assert.deepEqual(inventory(zero), f.originalCards); assert.deepEqual(inventory(low), f.originalCards);
});
void test(`${mode} hidden opposing Silent bid does not alter the target's legal answer or guide`, () => {
const f = createTruthRicheseSilentFixture(advanced);
const source = act(f.game, f.asker, {type:'richeseBid',event:f.lot,amount:9,allyPayment:0});
assert.equal(viewGame(source, f.target).richeseAuction?.revealedBids, null);
assert.equal(viewGame(source, f.target).richeseAuction?.ownBid, null);
const asked = askTruthRicheseSilent(f, 4, source);
assert.ok(viewGame(asked, f.target).truthRicheseBidAnswers?.includes('yes'));
const bound = act(asked, f.target, {type:'truthAnswer', answer:'yes'});
const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.amount, 4);
const submitted = act(bound, f.target, guide.action);
assert.ok(submitted.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
assert.equal(viewGame(submitted, f.owner).richeseAuction?.revealedBids, null);
const settled = act(submitted, f.owner, {type:'richeseBid',event:f.lot,amount:0,allyPayment:0});
assert.ok(player(settled, f.asker).hand.some(card => card.id === f.card));
assert.equal(player(settled, f.target).spice, 10, 'Losing submissions spend no spice');
assert.deepEqual(inventory(settled), f.originalCards);
});
void test(`${mode} Silent funded answers, stale guide rejection and four legal bot profiles`, () => {
const f = createTruthRicheseSilentFixture(advanced);
const unaffordable = askTruthRicheseSilent(f, 11);
assert.ok(!viewGame(unaffordable, f.target).truthRicheseBidAnswers?.includes('yes'));
assert.ok(viewGame(unaffordable, f.target).truthRicheseBidAnswers?.includes('no'));
reject(unaffordable, f.target, {type:'truthAnswer',answer:'yes'});
const asked = askTruthRicheseSilent(f, 4);
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);
reject(bound, f.target, {...guide.action,richeseBidPromiseId:'stale-receipt'});
reject(bound, f.target, {...guide.action,event:'stale-lot'});
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));
reject(completed, f.target, chosen);
assert.deepEqual(inventory(completed), f.originalCards);
}
});
}
for (const advanced of [false, true]) for (const answer of ['yes','no'] as const) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} a full hand forces a sealed zero and resolves the prior Silent ${answer}`, () => {
const f = createTruthRicheseSilentFixture(advanced), {game:bound} = bindTruthRicheseSilent(f, answer, 4);
// Controlled external acquisition supplement: move only existing unopened
// main-deck cards to this respondent until the normal hand limit is met.
// Preserve every physical card, the live lot, funding, answer and phase.
const harmed = structuredClone(bound), respondent = player(harmed,f.target);
while (respondent.hand.length < handLimit(respondent)) {
const card = harmed.deck.shift(); assert.ok(card);
respondent.hand.push(card);
}
assert.deepEqual(inventory(harmed),f.originalCards);
assert.equal(Object.hasOwn(harmed.richeseAuction!.sealed,f.target),false);
const beforeSequence = harmed.log.at(-1)?.seq ?? 0;
const settled = normalizeAutomaticGame(harmed);
const receipt = settled.richeseBidPromises!.find(row => row.player === f.target); assert.ok(receipt);
if (answer === 'yes') { assert.equal(receipt.released,true); assert.ok(!receipt.fulfilled); }
else { assert.equal(receipt.fulfilled,true); assert.ok(!receipt.released); }
assert.equal(viewGame(settled,f.target).richeseAuction?.ownBid,0);
const observer = viewGame(settled,f.owner).richeseAuction!;
assert.equal(observer.ownBid,null); assert.equal(observer.revealedBids,null);
assert.ok(observer.submitted.includes(f.target));
const fresh = settled.log.filter(entry => entry.seq > beforeSequence);
assert.ok(fresh.some(entry => entry.text.includes('submitted a sealed bid')));
assert.ok(fresh.every(entry => !/revealed together|zero|0 spice|no bid/i.test(`${entry.text} ${entry.automatic?.name ?? ''}`)),
'Automatic public notices must not disclose the sealed zero before reveal');
assert.deepEqual(inventory(settled),f.originalCards);
});
}