Add current-lot Truthtrance bid commitments

This commit is contained in:
Quintine committed 2026-10-26 12:44:00 +11:00
1 parent 4ab20a2838
commit bd6e5bbf7f
18 files changed
+1346 -27

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
BidPromiseError,
bidClaimText,
bidPromiseModeSupported,
bidPromiseRange,
bidRequired,
liveBidPromises,
parseBidClaim,
recordPromisedBid,
validateBidPromises,
type BidOpportunity,
type BidPromise,
} from '../game/bid-promises';
const seats = ['respondent', 'asker', 'rival'] as const;
function opportunity(extra: Partial<BidOpportunity> = {}): BidOpportunity {
return { turn: 2, lot: 3, source: 'original-physical-lot', bid: 4,
bidder: 'rival', passed: [], eligible: seats, ...extra };
}
function promise(event: string, minimum: number, answer = true,
extra: Partial<BidPromise> = {}): BidPromise {
return { event, turn: 2, lot: 3, source: 'original-physical-lot',
player: 'respondent', asker: 'asker', answer, minimum, ...extra };
}
void test('bid claims normalize one-based safe-integer turn, lot and minimum without adding a purchase or cumulative amount', () => {
const input = { turn: 2, lot: 3, minimum: 6 };
const parsed = parseBidClaim(input);
assert.deepEqual(parsed, input); assert.notEqual(parsed, input);
assert.deepEqual(parseBidClaim({ turn: 1, lot: 1, minimum: 1 }), { turn: 1, lot: 1, minimum: 1 });
assert.deepEqual(parseBidClaim({ turn: Number.MAX_SAFE_INTEGER, lot: Number.MAX_SAFE_INTEGER,
minimum: Number.MAX_SAFE_INTEGER }), { turn: Number.MAX_SAFE_INTEGER, lot: Number.MAX_SAFE_INTEGER,
minimum: Number.MAX_SAFE_INTEGER });
for (const field of ['turn', 'lot', 'minimum'] as const)
for (const value of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '6', null, undefined])
assert.throws(() => parseBidClaim({ ...input, [field]: value }), BidPromiseError, `${field}/${String(value)}`);
for (const value of [null, undefined, [], '6', 6]) assert.throws(() => parseBidClaim(value), BidPromiseError);
const text = bidClaimText(parsed);
assert.match(text, /place a bid of at least 6 spice/); assert.match(text, /from now until ordinary lot 3 closes/);
assert.doesNotMatch(text, /win|purchase|sum|original-physical-lot/);
});
void test('bid commitment scope retains original bare Basic and Advanced native rosters without admitting optional modules', () => {
const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'],
players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) };
for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false },
{ ...classic, expansions: ['ix', 'choam', 'ecaz'] },
{ ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] },
]) assert.equal(bidPromiseModeSupported(game), true);
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
assert.equal(bidPromiseModeSupported({ ...native, [option]: true }), false, option);
for (const game of [
{ ...native, expansions: ['ix', 'ix'] }, { ...native, expansions: ['ix', 'choam'] },
{ ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
{ ...classic, players: [{ faction: 'atreides' }] },
{ ...classic, players: [...native.players, { faction: 'atreides' }] },
{ ...classic, advanced: 'basic' as unknown as boolean },
]) assert.equal(bidPromiseModeSupported(game), false);
});
void test('one new accepted bid fulfills Yes while smaller bids, an old high bid and a purchase do not substitute', () => {
const original = [promise('at-least-six', 6)];
let records = recordPromisedBid(original, 'respondent', opportunity({ bid: 2 }), 3);
records = recordPromisedBid(records, 'respondent', opportunity({ bid: 4 }), 5);
assert.equal(records[0].fulfilled, undefined, 'Accepted bids 3 and 5 are not a cumulative bid of 8');
assert.equal(bidRequired(records, 'respondent', opportunity()), true);
assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity({ bid: 6 }), 10), { minimum: 7, maximum: 10 });
const alreadyHighest = opportunity({ bid: 9, bidder: 'respondent' });
assert.equal(bidPromiseRange(records, 'respondent', alreadyHighest, 10), null);
assert.equal(bidRequired(records, 'respondent', alreadyHighest), true, 'A pre-existing winning bid does not answer a new promise');
assert.equal(records[0].fulfilled, undefined, 'Holding the high bid or later paying its invoice is not a new accepted declaration');
const accepted = recordPromisedBid(records, 'respondent', opportunity({ bid: 6 }), 7);
assert.equal(accepted[0].fulfilled, true, 'A qualifying accepted bid is sufficient without winning the card');
assert.equal(bidRequired(accepted, 'respondent', opportunity({ bid: 8, bidder: 'rival' })), false);
assert.equal(original[0].fulfilled, undefined);
});
void test('joint Yes lower bounds and No caps preserve every qualifying single-bid amount', () => {
const records = [promise('yes-six', 6), promise('yes-seven', 7),
promise('no-ten', 10, false), promise('no-eight', 8, false)];
const source = opportunity();
assert.deepEqual(bidPromiseRange(records, 'respondent', source, 20), { minimum: 7, maximum: 7 });
assert.deepEqual(bidPromiseRange(records.slice(0, 1).concat(records.slice(2)), 'respondent', source, 20),
{ minimum: 6, maximum: 7 });
assert.deepEqual(bidPromiseRange(records, 'respondent', source, 7), { minimum: 7, maximum: 7 });
assert.equal(bidPromiseRange(records, 'respondent', source, 6), null, 'Funding cannot be invented to meet Yes');
assert.equal(bidPromiseRange(records, 'respondent', opportunity({ bid: 7 }), 20), null,
'A later competing bid can remove the entire permissible interval');
});
void test('conflicting promises have no completion while a No-only cap never requires placing a bid', () => {
const conflict = [promise('yes-eight', 8), promise('no-eight', 8, false)];
assert.equal(bidPromiseRange(conflict, 'respondent', opportunity(), 20), null);
assert.equal(bidRequired(conflict, 'respondent', opportunity()), true);
const no = [promise('stay-below-six', 6, false), promise('stay-below-nine', 9, false)];
assert.deepEqual(bidPromiseRange(no, 'respondent', opportunity(), 20), { minimum: 5, maximum: 5 });
assert.equal(bidRequired(no, 'respondent', opportunity()), false);
assert.equal(bidPromiseRange(no, 'respondent', opportunity({ bid: 5 }), 20), null);
assert.equal(bidRequired(no, 'respondent', opportunity({ bid: 5 })), false,
'An unavailable permitted bid does not turn a No answer into a required action');
});
void test('passed, current-highest and ineligible players have no future own declaration route in this opportunity', () => {
const records = [promise('yes-six', 6)];
for (const source of [opportunity({ passed: ['respondent'] }),
opportunity({ bidder: 'respondent', bid: 6 }), opportunity({ eligible: ['asker', 'rival'] })]) {
assert.equal(bidPromiseRange(records, 'respondent', source, 20), null);
assert.equal(records[0].fulfilled, undefined, 'Eligibility and current high bids cannot fulfill a promise');
}
assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity({ passed: ['asker'] }), 20),
{ minimum: 6, maximum: 20 });
});
void test('live obligations retain their original turn, physical lot and respondent while independent askers combine', () => {
const own = promise('own', 6), secondAsker = promise('second-asker', 8, false, { asker: 'rival' });
const records = [own, secondAsker,
promise('other-turn', 99, true, { turn: 1 }),
promise('other-physical-lot', 99, true, { source: 'another-original-lot', lot: 4 }),
promise('other-respondent', 99, true, { player: 'rival' }),
promise('already-fulfilled', 99, true, { fulfilled: true }),
promise('already-released', 99, true, { released: true })];
assert.deepEqual(liveBidPromises(records, 'respondent', opportunity()), [own, secondAsker]);
assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity(), 20), { minimum: 6, maximum: 7 });
const accepted = recordPromisedBid(records, 'respondent', opportunity(), 6);
assert.equal(accepted[0].fulfilled, true); assert.equal(accepted[1], secondAsker);
for (let index = 2; index < records.length; index++) assert.equal(accepted[index], records[index]);
assert.deepEqual(liveBidPromises(records, 'absent', opportunity()), []);
});
void test('maximum safe bids keep exact inclusive bounds and never overflow into an invented next bid', () => {
const maximum = Number.MAX_SAFE_INTEGER;
const source = opportunity({ bid: maximum - 1 });
const yes = [promise('largest-yes', maximum)];
assert.deepEqual(bidPromiseRange(yes, 'respondent', source, maximum), { minimum: maximum, maximum });
assert.equal(recordPromisedBid(yes, 'respondent', source, maximum)[0].fulfilled, true);
assert.equal(bidPromiseRange(yes, 'respondent', opportunity({ bid: maximum }), maximum), null);
assert.equal(bidPromiseRange([promise('largest-no', maximum, false)], 'respondent', source, maximum), null);
for (const invalid of [0, -1, 1.5, Number.NaN, Infinity, maximum + 1])
assert.equal(bidPromiseRange([], 'respondent', opportunity(), invalid), null);
for (const invalid of [-1, 1.5, Number.NaN, Infinity, maximum + 1])
assert.equal(bidPromiseRange([], 'respondent', opportunity({ bid: invalid }), maximum), null);
});
void test('a rejected No bid is immutable and fulfilled Yes leaves No binding for later accepted declarations', () => {
const yes = Object.freeze(promise('yes-six', 6)), no = Object.freeze(promise('no-eight', 8, false));
const records = Object.freeze([yes, no]); const before = structuredClone(records);
assert.throws(() => recordPromisedBid(records, 'respondent', opportunity(), 8), BidPromiseError);
assert.deepEqual(records, before);
const accepted = recordPromisedBid(records, 'respondent', opportunity(), 7);
assert.notEqual(accepted, records); assert.notEqual(accepted[0], yes); assert.equal(accepted[0].fulfilled, true);
assert.equal(accepted[1], no); assert.equal(accepted[1].fulfilled, undefined);
assert.equal(bidRequired(accepted, 'respondent', opportunity()), false);
assert.deepEqual(liveBidPromises(accepted, 'respondent', opportunity()), [no]);
assert.throws(() => recordPromisedBid(accepted, 'respondent', opportunity({ bid: 7 }), 8), BidPromiseError);
const repeated = recordPromisedBid(accepted, 'respondent', opportunity(), 7);
assert.equal(repeated[0], accepted[0]); assert.equal(repeated[1], no);
assert.deepEqual(records, before);
});
void test('persisted bid records require distinct events, physical sources and two actual participants with valid status', () => {
const valid = [promise('live', 6), promise('fulfilled', 7, true, { fulfilled: true }),
promise('released', 8, false, { released: true })];
const before = structuredClone(valid);
assert.doesNotThrow(() => validateBidPromises(valid, seats)); assert.deepEqual(valid, before);
assert.doesNotThrow(() => validateBidPromises([], seats));
const invalid: unknown[] = [
null, {}, [null], [valid[0], { ...valid[1], event: 'live' }],
[{ ...valid[0], event: '' }], [{ ...valid[0], source: '' }],
[{ ...valid[0], player: 'outsider' }], [{ ...valid[0], asker: 'outsider' }],
[{ ...valid[0], asker: 'respondent' }], [{ ...valid[0], answer: 'yes' }],
[{ ...valid[0], fulfilled: 'true' }], [{ ...valid[0], released: 1 }],
[{ ...valid[0], fulfilled: true, released: true }],
[{ ...valid[0], turn: 0 }], [{ ...valid[0], lot: 0 }], [{ ...valid[0], minimum: 0 }],
];
for (const records of invalid)
assert.throws(() => validateBidPromises(records as BidPromise[], seats), BidPromiseError);
assert.deepEqual(valid, before);
});
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
export type TruthBidStep = TruthMovementStep;
export const actTruthBid = actTruthMovement;
export const truthBidPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const truthBidClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard;
export function truthBidInventory(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Each original Treachery Card keeps one physical slot');
return ids;
}
export function nextTruthBid(game: Game): TruthBidStep | null {
if (game.truthtrance) return null;
if (truthBidClean(game) && game.phase === 3 && game.auction) {
const actor = game.auction.active, guide = viewGame(game, actor).bidPromiseGuide;
return { actor, action: guide?.action ?? { type: 'passBid' } };
}
if (!game.phaseOpening && !game.response && game.decision?.kind === 'auctionPayment' && game.auction &&
game.auction.bid > truthBidPlayer(game, game.decision.player).spice)
return null; // Caller owns the actual unfunded Karama payment choice.
return nextStrongholdFactionsNativeStep(game);
}
export function advanceTruthBid(game: Game, until: (state: Game) => boolean, actions: TruthBidStep[] = []): Game {
for (let count = 0; count < 900; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original bid source ended before its owned control');
const next = nextTruthBid(game); assert.ok(next, 'The original Truthtrance or unfunded Karama choice belongs to its caller');
game = actTruthBid(game, next.actor, next.action, actions);
}
throw Error('The original bid source did not reach its finite boundary');
}
export function continueTruthBidLot(game: Game, actions: TruthBidStep[] = []): Game {
assert.ok(game.auction); const turn = game.turn, lot = game.auction.index + 1;
return advanceTruthBid(game, state => state.turn !== turn || state.phase !== 3 ||
!state.auction || state.auction.index + 1 !== lot, actions);
}
export function finishTruthBid(game: Game, actions: TruthBidStep[] = []): Game {
return advanceTruthBid(game, state => state.phase === 4 && truthBidClean(state), actions);
}
/** Public original H/A/E base33, Basic or Advanced preview. Only original
* unopened starting-deal/lot/Spice positions and scalar lotteries are selected.
* Harkonnen really receives both original consecutive Truthtrances, Atreides
* Baliset and Emperor Shield (or original Karama). No dealt hand, purse, force,
* auction, question, answer or promise is installed. */
export function createTruthBidFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean;
} = {}) {
const advanced = options.advanced ?? false, window = options.window ?? 'waiting';
const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
let game = createGame('TRUTHBIDSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced);
joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthBidStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthBid(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthBid(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(asker, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game); assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0));
const originalCards = truthBidInventory(game); assert.equal(originalCards.length, 33);
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
const filler = game.deck.find(card => card.name === 'Baliset');
const targetCard = game.deck.find(card => options.targetKarama ? card.effect === 'karama' : card.kind === 'shield');
const lotCards = [game.deck.find(card => card.kind === 'projectile'), game.deck.find(card => card.kind === 'poison'),
game.deck.find(card => card.kind === 'snooper')];
assert.ok(filler && targetCard && lotCards.every(Boolean));
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id];
const prefix = [...deal, ...lotCards.map(card => card!.id)];
const dealOrder = prefix.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(truthBidPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
assert.deepEqual(truthBidPlayer(game, opponent).hand.map(card => card.id), [filler.id]);
assert.deepEqual(truthBidPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
});
reach(state => state.turn === 1 && state.phase === 3 && !!state.auction && truthBidClean(state));
const beforePasses = structuredClone(game);
if (window === 'active') reach(state => state.auction?.active === target && truthBidClean(state));
assert.equal(game.auction!.index, 0); assert.equal(game.auction!.bid, 0);
assert.equal(game.auction!.active, window === 'active' ? target : asker);
assert.equal(truthBidPlayer(game, target).spice, 10); assert.equal(truthBidPlayer(game, opponent).spice, 10);
assert.equal(viewGame(game, target).bidPromiseLot?.lot, 1);
assert.deepEqual(truthBidInventory(game), originalCards);
const question = { kind: 'bid' as const, target, turn: game.turn, lot: game.auction!.index + 1, minimum: 4 };
return { game, sourceGame: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup, beforePasses,
advanced, window, asker, opponent, target, actor: asker, truth: truths[0].id, card: truths[0].id,
truths: truths.map(card => card.id), targetCard: targetCard.id, karama: options.targetKarama ? targetCard.id : null,
question, declarationAction: { type: 'card', card: truths[0].id } as Action,
questionAction: { type: 'truthAsk', question } as Action,
lotCard: game.auction!.cards[game.auction!.index].id, lotCards: lotCards.map(card => card!.id),
deal, dealOrder, spiceOrder, originalSpice, originalCards, originalInventory: originalCards,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], sourceDisclosure: 'Genuine public H/A/E base33 setup. Only original unopened four-card starting deal, three ordinary lot positions, two ordinary Spice positions and scalar lotteries are selected. Harkonnen owns both physical Truthtrances; active Emperor additionally follows real H/A passes. No played hand, wallet, force, lot, Truthtrance or promise is installed.' };
}
export type TruthBidFixture = ReturnType<typeof createTruthBidFixture>;
export function declareTruthBid(f: TruthBidFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
let game = state; const before = structuredClone(game), actions: TruthBidStep[] = [];
const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) };
game = actTruthBid(game, f.asker, declaration, actions);
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthBid(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, declaration, actions, actionCount: actions.length };
}
export function askTruthBid(game: Game, asker: string, target: string, minimum = 4, actions: TruthBidStep[] = []): Game {
assert.ok(game.auction);
return actTruthBid(game, asker, { type: 'truthAsk', question: {
kind: 'bid', target, turn: game.turn, lot: game.auction.index + 1, minimum,
} }, actions);
}
export function answerTruthBid(game: Game, target: string, answer: 'yes' | 'no', actions: TruthBidStep[] = []): Game {
return actTruthBid(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthBid(f: TruthBidFixture, answer: 'yes' | 'no' = 'yes', minimum = 4, state = f.game) {
const declared = declareTruthBid(f, state), actions = [...declared.actions];
const asked = askTruthBid(declared.game, f.asker, f.target, minimum, actions);
const game = answerTruthBid(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { BidClaimFields, bidClaimInputError } from '../components/bid-claim-fields';
import { BidPromises, BidPromiseGuideButton } from '../components/bid-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { BidClaim } from '../game/bid-promises';
import { createTechTokens } from '../game/tech-tokens';
import { createTruthBidFixture, bindTruthBid, actTruthBid, truthBidInventory } from './fixture-truth-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 { Truthtrance } = await import('../components/truthtrance');
aliases.deregister();
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
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 edit(props: Parameters<typeof BidClaimFields>[0], minimum: number) {
const input = elements(BidClaimFields(props)).find(element => element.props.id === 'truth-bid-minimum' && typeof element.props.onChange === 'function');
assert.ok(input);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: minimum } });
}
function guide(props: Parameters<typeof BidPromiseGuideButton>[0]) {
const button = BidPromiseGuideButton(props); assert.ok(button);
return button;
}
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(BidPromises, { game, busy, send() {} }));
/** Controlled personalized projections exercise UI guards only. The native
* producer below supplies earned questions, answers and ordinary lot actions. */
function projected(action: 'bid' | 'passBid' = 'bid'): GameView {
const source = createGame('TRUTHBIDCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.phase = 3; game.turn = 2;
game.auction = { bid: 1, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
game.bidPromiseLot = { turn: 2, lot: 1 };
game.bidPromises = [{ event: 'controlled-bid-event', player: 'p', asker: 'a', turn: 2, lot: 1,
minimum: 4, answer: action === 'bid' }];
game.bidPromiseGuide = { action: action === 'bid'
? { type: 'bid', amount: 4, allyPayment: 1, bidPromiseEvent: 'controlled-bid-event' }
: { type: 'passBid', bidPromiseEvent: 'controlled-bid-event' } };
return game;
}
void test('bid claim fields keep the displayed turn and lot fixed and accept only parser-valid whole minimum edits', () => {
const value: BidClaim = { turn: 2, lot: 3, minimum: 4 }, changes: BidClaim[] = [];
const props = { value, onChange(next: BidClaim) { changes.push(next); } };
const html = renderToStaticMarkup(createElement(BidClaimFields, props));
assert.match(html, /Turn 2 · Ordinary lot 3/); assert.match(html, /Minimum bid in spice/);
assert.match(html, /min="1"/); assert.match(html, /max="9007199254740991"/);
assert.match(html, /If you win, the original payment rules still apply/);
assert.match(html, /optional modules and native special auctions are excluded/);
assert.equal(elements(BidClaimFields(props)).filter(element => typeof element.props.onChange === 'function').length, 1);
for (const minimum of [1, 7, Number.MAX_SAFE_INTEGER]) edit(props, minimum);
assert.deepEqual(changes, [1, 7, Number.MAX_SAFE_INTEGER].map(minimum => ({ ...value, minimum })));
for (const minimum of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
assert.ok(bidClaimInputError({ ...value, minimum })); edit(props, minimum);
}
assert.equal(changes.length, 3);
edit({ ...props, disabled: true }, 8); assert.equal(changes.length, 3);
edit({ ...props, value: { ...value, lot: 0 } }, 8); assert.equal(changes.length, 3);
assert.match(renderToStaticMarkup(createElement(BidClaimFields, { ...props, value: { ...value, minimum: 0 } })), /role="alert"/);
});
void test('own bid commitments remain visible with exact Yes/No meaning, completion and release statuses', () => {
const game = projected();
game.bidPromises.push(
{ event: 'no-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 8, answer: false },
{ event: 'fulfilled-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 2, answer: true, fulfilled: true },
{ event: 'released-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 12, answer: true, released: true },
{ event: 'foreign-event', player: 'a', asker: 'p', turn: 2, lot: 1, minimum: 99, answer: true },
);
game.auction!.active = 'a'; game.bidPromiseGuide = null;
const before = structuredClone(game);
for (let render = 0; render < 2; render++) {
const html = markup(game);
assert.match(html, /Your bid promises/); assert.match(html, /ordinary lot 1/);
assert.match(html, /Yes requires one qualifying bid/); assert.match(html, /No forbids every later bid at or above/);
assert.match(html, /If you win, the original payment rules still apply/);
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
assert.match(html, /Your next bid opportunity is still ahead/);
assert.doesNotMatch(html, /99 spice|foreign-event|controlled-bid-event/);
}
assert.deepEqual(game, before);
assert.equal(BidPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('the actual guide callback forwards bid or pass with the authoritative funding and event fields unchanged', () => {
for (const type of ['bid', 'passBid'] as const) {
const game = projected(type), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); press(button);
assert.deepEqual(sent, [game.bidPromiseGuide!.action]); assert.equal(sent[0], game.bidPromiseGuide!.action);
assert.equal(button.props.disabled, false);
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
}
});
void test('busy and stale guide callbacks cannot cross current owner, lot, event, stage or continuation boundaries', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.auction!.active = 'a'; },
game => { game.turn = 3; }, game => { game.phase = 5; }, game => { game.status = 'setup'; },
game => { game.bidPromiseLot = { turn: 2, lot: 2 }; }, game => { game.bidPromiseLot = null; },
game => { game.bidPromiseGuide = { action: { ...game.bidPromiseGuide!.action } }; },
game => { game.bidPromiseGuide = null; }, game => { game.bidPromiseGuide!.action.amount = 5; },
game => { game.bidPromises[0].event = 'another-event'; }, game => { game.bidPromises[0].player = 'a'; },
game => { game.bidPromises[0].fulfilled = true; }, game => { game.bidPromises[0].released = true; },
game => { game.bidPromises = []; }, game => { game.auction = null; },
game => { game.phaseOpening = { passed: [] }; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
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(); },
game => { game.automaticContinuationPending = true; },
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
}
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
const button = guide(props); props.busy = true; press(button);
});
void test('bid controls use the public lot and own guide without reading card faces, source fingerprints or rival holdings', () => {
const game = projected(), sent: Action[] = [];
for (const player of game.players) {
for (const key of ['hand', 'traitors', 'faceDancers']) Object.defineProperty(player, key, {
get() { throw Error(`Bid controls cannot inspect ${key}`); }, configurable: true,
});
}
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No ordinary-lot face inspection'); } });
Object.defineProperty(game.bidPromises[0], 'source', { get() { throw Error('No internal bid source inspection'); } });
assert.match(markup(game), /Bid 4 spice while honoring your answers/);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [game.bidPromiseGuide!.action]);
});
void test('source-earned Basic and Advanced current-lot answers connect persistent guidance to the actual qualifying bid callback', () => {
for (const advanced of [false, true]) {
const f = createTruthBidFixture({ advanced, window: 'active' }), bound = bindTruthBid(f, 'yes', 4);
const respondent = viewGame(bound.asked, f.target);
assert.ok(respondent.truthBidAnswers?.includes('yes'));
assert.equal(viewGame(bound.asked, f.asker).truthBidAnswers, null);
const answerHtml = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} }));
assert.match(answerHtml, /Answer Yes/); assert.match(answerHtml, /Answer No/);
const game = viewGame(bound.game, f.target), sent: Action[] = [];
assert.deepEqual(game.bidPromiseLot, { turn: bound.game.turn, lot: 1 });
assert.match(markup(game), /Your bid promises/); assert.match(markup(game), /Bid 4 spice/);
const next = game.bidPromiseGuide; assert.ok(next);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [next.action]);
const after = actTruthBid(bound.game, f.target, sent[0]);
const event = next.action.bidPromiseEvent; assert.equal(typeof event, 'string');
assert.equal(after.bidPromises?.find(promise => promise.event === event)?.fulfilled, true);
assert.equal(after.players.find(player => player.id === f.target)!.spice, 10,
'The qualifying bid fulfills Yes before any later winning invoice is paid.');
assert.deepEqual(truthBidInventory(after), f.originalCards);
assert.equal(BidPromises({ game: viewGame(after, f.asker), busy: false, send() {} }), null);
}
});
+215
View File
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
actTruthBid as act, advanceTruthBid as advance, answerTruthBid as answer,
askTruthBid as ask, bindTruthBid as bind, continueTruthBidLot as closeLot,
createTruthBidFixture as fixture, declareTruthBid as declare, finishTruthBid as finish,
truthBidClean as clean, truthBidInventory as inventory, truthBidPlayer as player,
} from './fixture-truth-bid';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function current(game: Game, actor: string): Game {
return advance(game, state => clean(state) && state.auction?.active === actor);
}
void test('two original Truth cards keep a prior shipment commitment funded when answering a later bid question', () => {
const f = fixture();
let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: {
kind: 'shipment', target: f.target, claim: { territory: 'arrakeen', minimum: 9 },
} });
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
game = answer(game, f.target, 'yes');
assert.equal(game.truthtrance?.stage, 'ask');
game = ask(game, f.asker, f.target, 4);
assert.deepEqual(viewGame(game, f.target).truthBidAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
game = answer(game, f.target, 'no');
game = current(game, f.target);
game = act(game, f.target, { type: 'bid', amount: 1, allyPayment: 0 });
game = closeLot(game);
assert.equal(player(game, f.target).spice, 9);
game = finish(game);
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
game = act(game, f.target, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 9, elite: 0, allyPayment: 0 });
game = advance(game, clean);
assert.equal(player(game, f.target).forces['arrakeen:10'], 9);
assert.ok(game.shipmentPromises?.some(promise => promise.player === f.target && promise.fulfilled));
assert.ok(game.bidPromises?.every(promise => promise.fulfilled));
assert.deepEqual(inventory(game), f.originalCards);
});
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} current-lot Truth Yes requires a real new bid and preserves original payment`, () => {
const f = fixture({ advanced, window: 'active' });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.game.expansions, []); assert.deepEqual(f.staging, []);
assert.equal(f.originalCards.length, 33); assert.equal(f.truths.length, 2);
assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
const before = structuredClone(f.game), bound = bind(f);
assert.deepEqual(f.game, before);
assert.equal(bound.game.truthtrance, null);
assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(player(bound.game, f.asker).hand.filter(card => card.effect === 'truthtrance').length, 1);
rejected(bound.game, f.target, { type: 'passBid' });
rejected(bound.game, f.target, { type: 'bid', amount: 3 });
const guide = viewGame(bound.game, f.target).bidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4);
const bid = act(bound.game, f.target, guide.action);
assert.equal(bid.bidPromises!.find(promise => promise.player === f.target)!.fulfilled, true);
assert.equal(player(bid, f.target).spice, 10, 'Declaring the bid does not pay its later invoice');
const paid = closeLot(bid);
assert.equal(player(paid, f.target).spice, 6);
assert.equal(player(paid, f.target).hand.filter(card => card.id === f.lotCard).length, 1);
assert.ok((paid.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
assert.deepEqual(inventory(paid), f.originalCards);
const done = finish(paid); assert.equal(done.phase, 4); assert.deepEqual(inventory(done), f.originalCards);
});
for (const lowerBid of [false, true]) void test(`native current-lot Truth No permits ${lowerBid ? 'a lower bid' : 'a pass'} and expires before the next ordinary lot`, () => {
const f = fixture({ window: lowerBid ? 'active' : 'waiting' });
let source = f.game;
if (!lowerBid) {
source = act(source, f.asker, { type: 'bid', amount: 1 });
source = act(source, f.opponent, { type: 'passBid' });
}
const bound = bind(f, 'no', 4, source);
rejected(bound.game, f.target, { type: 'bid', amount: 4 });
const chosen = act(bound.game, f.target, lowerBid ? { type: 'bid', amount: 2 } : { type: 'passBid' });
const next = chosen.auction?.index === 0 ? closeLot(chosen) : chosen;
assert.equal(next.auction!.index, 1);
assert.ok((next.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
const ready = current(next, f.target);
const accepted = act(ready, f.target, { type: 'bid', amount: 4 });
assert.equal(accepted.auction!.bid, 4); assert.equal(accepted.auction!.bidder, f.target);
assert.deepEqual(inventory(accepted), f.originalCards);
});
void test('native waiting bidder Yes is released by a real opposing ten-spice bid, with ordinary Emperor income later', () => {
const f = fixture(), bound = bind(f);
assert.equal(bound.game.auction!.active, f.asker);
assert.equal(viewGame(bound.game, f.target).bidPromiseGuide, null);
let game = act(bound.game, f.asker, { type: 'passBid' });
assert.equal(game.auction!.active, f.opponent);
game = act(game, f.opponent, { type: 'bid', amount: 10 });
assert.equal(game.auction!.active, f.target);
assert.equal(game.bidPromises!.find(promise => promise.player === f.target)!.released, true);
game = act(game, f.target, { type: 'passBid' }); game = closeLot(game);
assert.equal(player(game, f.opponent).spice, 0);
assert.equal(player(game, f.target).spice, 20, 'Original Emperor auction income remains separate from its released declaration promise');
assert.ok(player(game, f.opponent).hand.some(card => card.id === f.lotCard));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native bidder cannot bribe away the last Yes funding but may keep its real four-spice witness', () => {
const f = fixture(), bound = bind(f);
rejected(bound.game, f.target, { type: 'bribe', target: f.asker, amount: 7 });
let game = act(bound.game, f.target, { type: 'bribe', target: f.asker, amount: 6 });
assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.asker).bribes, 6);
game = current(game, f.target);
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
game = act(game, f.target, guide.action); game = closeLot(game);
assert.equal(player(game, f.target).spice, 0); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native earlier qualifying bid does not fulfill a Truth promise answered after a real outbid', () => {
const f = fixture(); let game = f.game;
for (const [actor, amount] of [[f.asker, 1], [f.opponent, 2], [f.target, 4], [f.asker, 5], [f.opponent, 6]] as const)
game = act(game, actor, { type: 'bid', amount });
assert.equal(game.auction!.active, f.target);
game = bind(f, 'yes', 4, game).game;
assert.equal(game.bidPromises!.find(promise => promise.player === f.target)!.fulfilled ?? false, false);
rejected(game, f.target, { type: 'passBid' });
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 7);
game = act(game, f.target, guide.action); assert.equal(game.bidPromises![0].fulfilled, true);
game = closeLot(game); assert.equal(player(game, f.target).spice, 3);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native bid declaration fulfills Yes even when a different real bidder later wins and pays', () => {
const f = fixture(); let game = act(f.game, f.asker, { type: 'bid', amount: 1 });
game = act(game, f.opponent, { type: 'bid', amount: 2 });
game = bind(f, 'yes', 4, game).game;
game = act(game, f.target, { type: 'bid', amount: 4 }); assert.equal(game.bidPromises![0].fulfilled, true);
game = act(game, f.asker, { type: 'bid', amount: 5 });
game = act(game, f.opponent, { type: 'passBid' });
game = act(game, f.target, { type: 'passBid' });
game = advance(game, clean);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.lotCard));
assert.ok(!player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.equal(player(game, f.asker).spice, 5); assert.equal(player(game, f.target).spice, 15);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original queued Truthtrances combine No six with Yes four and each discard only once', () => {
const f = fixture({ window: 'active' }), declared = declare(f, f.game, { copies: 2 });
let game = ask(declared.game, f.asker, f.target, 6); game = answer(game, f.target, 'no');
assert.equal(game.truthtrance!.stage, 'ask');
game = ask(game, f.asker, f.target, 4); game = answer(game, f.target, 'yes');
assert.equal(game.bidPromises!.length, 2);
for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1);
rejected(game, f.target, { type: 'passBid' }); rejected(game, f.target, { type: 'bid', amount: 6 });
game = act(game, f.target, { type: 'bid', amount: 5 }); game = closeLot(game);
assert.equal(player(game, f.target).spice, 5);
assert.ok((game.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native question ownership, exact current-lot binding and stale guided callbacks retain original action boundaries', () => {
const f = fixture({ window: 'active' }), declared = declare(f), question = f.question;
rejected(declared.game, f.opponent, { type: 'truthAsk', question });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...question, lot: question.lot + 1 } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...question, turn: question.turn + 1 } });
let game = ask(declared.game, f.asker, f.target);
assert.deepEqual(viewGame(game, f.target).truthBidAnswers, ['yes', 'no']);
assert.equal(viewGame(game, f.opponent).truthBidAnswers, null);
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' });
game = answer(game, f.target, 'yes');
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
assert.ok(guide.action.bidPromiseEvent);
assert.ok(viewGame(game, f.target).bidPromises.every(promise => !Object.hasOwn(promise, 'source')));
game = act(game, f.target, guide.action); game = closeLot(game); game = current(game, f.target);
rejected(game, f.target, guide.action);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native already-passed and current-highest bidders cannot supply a new Truth bid opportunity', () => {
const passed = fixture({ window: 'active' }), first = declare(passed);
assert.ok(passed.game.auction!.passed.includes(passed.opponent));
rejected(first.game, passed.asker, { type: 'truthAsk', question: { ...passed.question, target: passed.opponent } });
const highest = fixture(); let game = highest.game;
for (const [actor, amount] of [[highest.asker, 1], [highest.opponent, 2], [highest.target, 3]] as const)
game = act(game, actor, { type: 'bid', amount });
assert.equal(game.auction!.bidder, highest.target);
game = declare(highest, game).game;
rejected(game, highest.asker, { type: 'truthAsk', question: highest.question });
assert.deepEqual(inventory(game), highest.originalCards);
});
void test('native actual Karama funds the promised overbid and its original free purchase consumes one card', () => {
const f = fixture({ window: 'active', targetKarama: true }); let game = bind(f, 'yes', 11).game;
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 11);
game = act(game, f.target, guide.action);
game = advance(game, state => !state.response && state.decision?.kind === 'auctionPayment');
assert.equal(game.auction!.bid, 11); assert.equal(player(game, f.target).spice, 10);
game = act(game, f.target, { type: 'decision', karama: true, card: f.karama });
assert.equal(player(game, f.target).spice, 10); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies offer a legal native bid for the current answered Yes', () => {
const f = fixture({ window: 'active' }), bound = bind(f).game;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(bound, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const offered = botActions(view).find(action => action.type === 'bid' && Number(action.amount) >= 4); assert.ok(offered, difficulty);
const accepted = act(structuredClone(bound), f.target, offered);
assert.equal(accepted.bidPromises![0].fulfilled, true); assert.deepEqual(inventory(accepted), f.originalCards);
}
});