Combine current facts with native bid declarations

This commit is contained in:
Quintine committed 2026-10-26 17:05:16 +11:00
1 parent 957d5a172f
commit 0d979e4f8b
20 files changed
+1214 -7

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { BidActionClaim } from '../game/mixed-bid-question';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact } from '../game/truthtrance';
import {
actTruthBid, advanceTruthBid, answerTruthBid, continueTruthBidLot, createTruthBidFixture,
declareTruthBid, finishTruthBid, nextTruthBid, truthBidClean, truthBidInventory,
truthBidPlayer, type TruthBidStep,
} from './fixture-truth-bid';
export type TruthMixedBidExpression = MixedActionExpression<BidActionClaim>;
export type TruthMixedBidStep = TruthBidStep;
export const actTruthMixedBid = actTruthBid;
export const advanceTruthMixedBid = advanceTruthBid;
export const answerTruthMixedBid = answerTruthBid;
export const continueTruthMixedBidLot = continueTruthBidLot;
export const finishTruthMixedBid = finishTruthBid;
export const nextTruthMixedBid = nextTruthBid;
export const truthMixedBidClean = truthBidClean;
export const truthMixedBidInventory = truthBidInventory;
export const truthMixedBidPlayer = truthBidPlayer;
export const truthMixedBidFact = (fact: TruthFact): TruthMixedBidExpression => ({ kind: 'fact', fact });
export const truthMixedBidMinimum = (minimum = 4): TruthMixedBidExpression => ({ kind: 'action', action: { minimum } });
export const truthMixedBidGroup = (kind: 'and' | 'or', ...terms: TruthMixedBidExpression[]): TruthMixedBidExpression =>
({ kind, terms });
/** Original public H/A/E base33, Basic22/Advanced25 active or Basic20/Advanced23
* waiting. This thin adapter changes only the raw question. It installs no
* played hand, purse, auction clock, response, frozen fact or residual binding. */
export function createTruthMixedBidFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean; mixed?: TruthMixedBidExpression;
} = {}) {
const { mixed: suppliedMixed, ...sourceOptions } = options;
const base = createTruthBidFixture({ ...sourceOptions, window: options.window ?? 'active' });
const shield = base.setup.deck.find(card => card.kind === 'shield');
const hajr = base.setup.deck.find(card => card.effect === 'hajr');
assert.ok(shield && hajr, 'The original unopened base33 supplies printed current-fact identities');
const shieldFact: TruthFact = { kind: 'hand', name: shield.name };
const hajrFact: TruthFact = { kind: 'hand', name: hajr.name };
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 };
const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
const mixed = structuredClone(suppliedMixed ?? truthMixedBidGroup('and', truthMixedBidFact(spiceFact),
truthMixedBidMinimum()));
const question = { kind: 'mixedBid' as const, target: base.target, turn: base.game.turn,
lot: base.game.auction!.index + 1, mixed };
return { ...base, bidSource: base, mixed, question, shieldFact, hajrFact, spiceFact, forecastFact,
questionAction: { type: 'truthAsk', question } satisfies Action,
sourceCounts: { inherited: base.sourceActions.length, basicActive: 22, advancedActive: 25,
basicWaiting: 20, advancedWaiting: 23 },
sourceDisclosure: `${base.sourceDisclosure} This thin mixed adapter changes only the original raw question: current facts and future accepted minimum bids share one AND/OR tree in the original current lot. No compiled threshold or answer-time fact is installed.` };
}
export type TruthMixedBidFixture = ReturnType<typeof createTruthMixedBidFixture>;
export function declareTruthMixedBid(f: TruthMixedBidFixture, source = f.game, options: { copies?: 1 | 2 } = {}) {
return declareTruthBid(f.bidSource, source, options);
}
export function askTruthMixedBid(game: Game, asker: string, target: string, mixed: TruthMixedBidExpression,
actions: TruthMixedBidStep[] = []): Game {
assert.ok(game.auction);
return actTruthMixedBid(game, asker, { type: 'truthAsk', question: {
kind: 'mixedBid', target, turn: game.turn, lot: game.auction.index + 1, mixed: structuredClone(mixed),
} }, actions);
}
export function bindTruthMixedBid(f: TruthMixedBidFixture, answer: 'yes' | 'no' = 'yes',
mixed = f.mixed, source = f.game) {
const declared = declareTruthMixedBid(f, source), actions = [...declared.actions];
const asked = askTruthMixedBid(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthMixedBid(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
/** Own original question and whole answer only; no frozen fact, reduced minimum
* or physical-lot source is read through this accessor. */
export function truthMixedBidPromise(game: Game,
target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
const row = viewGame(game, target).mixedBidPromises.find(row => row.player === target);
assert.ok(row, 'The respondent retains the original mixed question and aggregate status');
return row;
}
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mixedBidMinimum, parseBidActionClaim, type BidActionClaim } from '../game/mixed-bid-question';
import { compileMixedActionExpression, type CompiledMixedAction, type MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact } from '../game/truthtrance';
type Compiled = CompiledMixedAction<BidActionClaim>;
const bid = (minimum: number): Compiled => ({ kind: 'action', action: { minimum } });
const group = (kind: 'and' | 'or', ...terms: Compiled[]): Compiled => ({ kind, terms });
function evaluate(node: Compiled, maximum: number): boolean {
if (node.kind === 'constant') return node.value;
if (node.kind === 'action') return maximum >= node.action.minimum;
return node.kind === 'and' ? node.terms.every(term => evaluate(term, maximum)) : node.terms.some(term => evaluate(term, maximum));
}
void test('strict bid leaves reject metadata and unsafe thresholds while retaining uncapped exact whole amounts', () => {
for (const minimum of [1, 4, 11, Number.MAX_SAFE_INTEGER]) assert.deepEqual(parseBidActionClaim({ minimum }), { minimum });
for (const value of [null, [], {}, { minimum: 0 }, { minimum: -1 }, { minimum: 1.5 }, { minimum: NaN },
{ minimum: Infinity }, { minimum: Number.MAX_SAFE_INTEGER + 1 }, { minimum: '4' },
{ minimum: 4, lot: 1 }, { minimum: 4, turn: 1 }, { minimum: 4, amount: 4 }]) assert.throws(() => parseBidActionClaim(value));
});
void test('same-lot reduction preserves every compound Yes and whole No over actual future declaration maxima', () => {
const trees = [group('and', bid(4), bid(7)), group('or', bid(4), bid(7)),
group('and', group('or', bid(3), bid(8)), group('or', bid(6), bid(9))),
group('or', group('and', bid(3), bid(8)), group('and', bid(6), bid(9)))];
for (const tree of trees) for (const bids of [[], [1], [3, 5], [2, 6, 8], [4, 7, 10], [11]]) {
const maximum = Math.max(0, ...bids), expected = evaluate(tree, maximum), actual = maximum >= mixedBidMinimum(tree);
assert.equal(actual, expected); assert.equal(!actual, !expected);
}
});
void test('compound thresholds never sum bids or imply a purchase or extra opportunity', () => {
const both = group('and', bid(4), bid(7));
assert.equal(mixedBidMinimum(both), 7); assert.equal(evaluate(both, Math.max(3, 4)), false);
assert.equal(evaluate(both, 7), true); assert.equal(evaluate(group('or', bid(4), bid(7)), 4), true);
assert.equal(mixedBidMinimum(group('or', bid(Number.MAX_SAFE_INTEGER), bid(11))), 11);
});
void test('frozen facts determine only the necessary exact native bid obligation', () => {
const fact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 };
const mixed: MixedActionExpression<BidActionClaim> = { kind: 'and', terms: [
{ kind: 'fact', fact }, { kind: 'or', terms: [{ kind: 'action', action: { minimum: 4 } }, { kind: 'action', action: { minimum: 7 } }] },
] };
const residual = compileMixedActionExpression(mixed, () => 'yes', true); assert.equal(mixedBidMinimum(residual), 4);
assert.deepEqual(compileMixedActionExpression(mixed, () => 'no', false), { kind: 'constant', value: false });
const raw = structuredClone(mixed); mixedBidMinimum(residual); assert.deepEqual(mixed, raw);
});
void test('unknown fact compilation keeps the conservative exact bid constraint for each definite answer', () => {
const fact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
for (const answer of [true, false]) {
const mixed: MixedActionExpression<BidActionClaim> = { kind: answer ? 'or' : 'and', terms: [
{ kind: 'fact', fact }, { kind: 'action', action: { minimum: 4 } },
] };
assert.equal(mixedBidMinimum(compileMixedActionExpression(mixed, () => 'unknown', answer)), 4);
}
});
void test('constant or empty groups cannot masquerade as a native threshold', () => {
for (const node of [{ kind: 'constant', value: true }, { kind: 'constant', value: false },
{ kind: 'and', terms: [] }, { kind: 'or', terms: [] }] as Compiled[]) assert.throws(() => mixedBidMinimum(node));
});
+274
View File
@@ -0,0 +1,274 @@
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 { MixedBidFields, mixedBidInputError } from '../components/mixed-bid-fields';
import { MixedBidPromises } from '../components/mixed-bid-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { BidPromiseGuideButton, BidPromises } from '../components/bid-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { BidActionClaim } from '../game/mixed-bid-question';
import { createTechTokens } from '../game/tech-tokens';
import type { TruthFact } from '../game/truthtrance';
type Expression = MixedActionExpression<BidActionClaim>;
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
const bid = (minimum = 1): Expression => ({ kind: 'action', action: { minimum } });
const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [bid(3), bid(7)] }] });
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function find(tree: ReactNode, key: string, value: string): Element {
const found = elements(tree).find(element => element.props[key] === value);
assert.ok(found, `${key}=${value}`); return found;
}
const control = (tree: ReactNode, key: string) => find(tree, 'data-mixed-bid-control', key);
const click = (button: Element) => (button.props.onClick as () => void)();
function select(tree: ReactNode, path: string, field: string, value: string) {
const input = find(tree, 'id', `truth-mixed-bid-${path}-${field}`);
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
const input = find(tree, 'id', `truth-mixed-bid-${path}-minimum`);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
}
function editFact(tree: ReactNode, path: string, next: TruthFact) {
const scope = find(tree, 'data-mixed-bid-fact-path', path);
const fields = elements(scope).find(element => element.type === TruthFactFields);
assert.ok(fields, `Current fact fields ${path}`);
(fields.props.onChange as (fact: TruthFact) => void)(next);
}
/** Controlled projections exercise fields and exact callback guards only.
* Source-earned Truthtrance, declarations and lot close have native proof. */
function projected(type: 'bid' | 'passBid' = 'bid', advanced = false): GameView {
const source = createGame('TRUTHMIXEDBIDCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
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: 3 };
game.bidPromises = [];
game.mixedBidPromises = [{ event: 'controlled-mixed-bid-event', player: 'p', asker: 'a', turn: 2, lot: 3,
mixed: claim(), answer: type === 'bid', fulfilled: false, released: false }];
game.bidPromiseGuide = { action: type === 'bid'
? { type, amount: 7, allyPayment: 1, bidPromiseEvent: 'controlled-mixed-bid-event' }
: { type, bidPromiseEvent: 'controlled-mixed-bid-event' } };
return game;
}
function editor(initial: Expression, disabled = false, game = projected(), target = 'p') {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => MixedBidFields({ value, game, target, disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(MixedBidFields, { value, game, target, disabled, onChange() {} })),
error: () => mixedBidInputError(value, game, target),
};
}
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) => renderToStaticMarkup(createElement(MixedBidPromises, { game, busy: false, send() {} }));
const pureMarkup = (game: GameView) => renderToStaticMarkup(createElement(BidPromises, { game, busy: false, send() {} }));
void test('mixed bid editor retains sixteen original conditions with only per-action minimum fields and fixed public turn and lot', () => {
const e = editor(fact()); click(control(e.tree(), 'or-1'));
for (let count = 3; count <= 16; count++) {
click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count);
}
minimum(e.tree(), '1.2', Number.MAX_SAFE_INTEGER);
const full = e.value; assert.ok(full.kind === 'or'); assert.equal(full.terms.length, 16);
assert.deepEqual(full.terms[0], fact()); assert.deepEqual(full.terms[1], bid(Number.MAX_SAFE_INTEGER));
assert.deepEqual(full.terms[15], bid(16)); assert.equal(e.error(), null);
const html = e.html(), ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
assert.match(html, /Turn 2 · Ordinary lot 3/); assert.match(html, /16 \/ 16 conditions/);
assert.equal((html.match(/Minimum bid in spice/g) ?? []).length, 15);
assert.match(html, /max="9007199254740991"/); assert.equal(new Set(ids).size, ids.length);
assert.ok(!elements(e.tree()).some(element => typeof element.props.id === 'string' && /-(turn|lot)$/.test(element.props.id)));
const before = structuredClone(full), changes = e.changes;
for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-and-1.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true, key); click(control(e.tree(), key));
}
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
});
void test('mixed bid and embedded fact groups preserve all branches through edits, collapse and the shared depth-four envelope', () => {
const e = editor({ kind: 'and', terms: [fact(), bid(3)] });
click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and');
click(control(e.tree(), 'nested-or-1')); click(control(e.tree(), 'nested-and-1'));
assert.ok(e.value.kind === 'and'); assert.equal(e.value.terms.length, 4);
click(control(e.tree(), 'fact-or-1.1')); editFact(e.tree(), '1.1.2', { kind: 'stormForecast', compare: 'gte', value: 4 });
select(e.tree(), '1.1', 'fact-meaning', 'and'); click(control(e.tree(), 'fact-add-1.1'));
editFact(e.tree(), '1.1.3', { kind: 'spice', compare: 'gte', value: 7 });
assert.match(e.html(), /Current-fact group 1\.1/); assert.equal(e.error(), null);
click(control(e.tree(), 'fact-remove-1.1.3')); click(control(e.tree(), 'fact-remove-1.1.2'));
click(control(e.tree(), 'remove-1.2.2')); click(control(e.tree(), 'remove-1.4')); click(control(e.tree(), 'remove-1.3'));
assert.deepEqual(e.value, { kind: 'and', terms: [fact(), bid(3)] });
select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
select(e.tree(), '1.2', 'timing', 'action'); assert.equal(e.error(), null);
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, bid()); assert.ok(e.error());
click(control(e.tree(), 'and-1')); assert.equal(e.error(), null);
let nested = fact();
for (let depth = 0; depth < 4; depth++) nested = { kind: 'and', terms: [nested, bid()] };
const deep = editor(nested); assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
for (const key of ['and-1', 'or-1.1.1.1.1', 'fact-and-1.1.1.1.1', 'nested-and-1.1.1.1']) {
assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
}
assert.equal(deep.changes, 0);
assert.ok(mixedBidInputError({ kind: 'and', terms: [nested, bid()] }, projected(), 'p'));
});
void test('mixed bid numeric drafts remain repairable while strict leaves reject metadata and busy callbacks preserve the original tree', () => {
for (const invalid of [NaN, -1, 0, 0.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const e = editor({ kind: 'and', terms: [fact(), bid()] });
minimum(e.tree(), '1.2', invalid); assert.ok(e.error()); click(control(e.tree(), 'or-1.2'));
const raw = e.value; assert.ok(raw.kind === 'and');
assert.deepEqual(raw.terms[1], { kind: 'or', terms: [bid(invalid), fact()] });
assert.match(e.html(), /role="alert"/); minimum(e.tree(), '1.2.1', 2); assert.equal(e.error(), null);
}
for (const action of [{ minimum: 1, turn: 2 }, { minimum: 1, lot: 3 }, { minimum: 1, source: 'hidden-lot' },
{ minimum: 1, answer: true }, { minimum: 1, fulfilled: true }, { kind: 'or', terms: [{ minimum: 1 }] }, { minimum: '2' }])
assert.ok(mixedBidInputError({ kind: 'and', terms: [fact(), { kind: 'action', action }] }, projected(), 'p'));
const frozen = editor({ kind: 'and', terms: [fact(), bid()] }, true), before = structuredClone(frozen.value);
for (const key of ['and-1', 'add-action-1', 'nested-or-1', 'remove-1.2', 'fact-or-1.1']) click(control(frozen.tree(), key));
minimum(frozen.tree(), '1.2', 3); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'timing', 'fact');
editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
});
void test('mixed bid authoring uses public current-lot scope and explains declaration, unknown and original payment semantics without inspecting holdings', () => {
const game = projected();
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'spice', 'forces', 'reserves', 'tanks', 'elites'])
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden auction face inspection'); } });
for (const key of ['deck', 'discard', 'spiceDeck']) Object.defineProperty(game, key, {
get() { throw Error(`No private ${key} inspection`); }, configurable: true,
});
const e = editor({ kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), bid(5)] }, false, game);
assert.equal(e.error(), null);
const html = e.html(); assert.match(html, /Turn 2 · Ordinary lot 3/);
assert.match(html, /Smaller bids do not add together/); assert.match(html, /even if another player wins/);
assert.match(html, /If you win, the original payment rules still apply/);
assert.match(html, /Existing funding, Karama and prepaid ally aid rules still apply/);
assert.match(html, /optional modules and native special auctions are excluded/);
assert.match(html, /conservative future commitment/); assert.match(html, /If no such value is available, the answer is I don’t know/);
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: NaN }); assert.ok(e.error());
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 2 }); assert.equal(e.error(), null);
});
void test('own mixed bid panel displays the original question and aggregate answer without progress, frozen facts or reduced thresholds', () => {
const game = projected(); game.bidPromiseGuide = null;
game.mixedBidPromises.push(
{ ...game.mixedBidPromises[0], event: 'no-event', answer: false },
{ ...game.mixedBidPromises[0], event: 'fulfilled-event', fulfilled: true },
{ ...game.mixedBidPromises[0], event: 'released-event', released: true },
{ ...game.mixedBidPromises[0], event: 'foreign-event', player: 'a', mixed: { kind: 'or', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), bid(99)] } },
);
for (const promise of game.mixedBidPromises) for (const key of ['binding', 'residual', 'factAnswers', 'frozenFacts', 'compiled', 'source', 'minimum', 'progress'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
assert.match(html, /Your mixed bid promises/); assert.match(html, /Turn 2, ordinary lot 3/);
assert.match(html, /You answered Yes/); assert.match(html, /You answered No/); assert.match(html, /you hold Shield/);
assert.match(html, / OR /); assert.match(html, / AND /); assert.match(html, /at least 3 spice/); assert.match(html, /at least 7 spice/);
assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
assert.match(html, /Individual fact answers and any reduced future threshold remain private/);
assert.match(html, /No negates the whole expression, not each condition separately/);
assert.match(html, /fulfilled immediately by one qualifying native declaration/);
assert.match(html, /binding No remains in force until this ordinary lot actually closes/);
assert.doesNotMatch(html, /Poison Blade|99 spice|controlled-mixed-bid-event|fulfilled-event|Future ordinary progress/);
assert.equal(MixedBidPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('shared Basic and Advanced guides forward the exact native mixed bid or pass packet including original funding and card fields', () => {
for (const advanced of [false, true]) for (const type of ['bid', 'passBid'] as const) {
const game = projected(type, advanced), sent: Action[] = [];
if (type === 'bid') game.bidPromiseGuide!.action = { ...game.bidPromiseGuide!.action,
karama: true, card: 'controlled-original-karama' };
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.bidPromiseGuide!.action]); assert.equal(sent[0], game.bidPromiseGuide!.action);
assert.match(markup(game), /data-testid="bid-promise-guide"/); assert.doesNotMatch(markup(game), /controlled-original-karama/);
if (type === 'bid') assert.deepEqual(sent[0], { type, amount: 7, allyPayment: 1, bidPromiseEvent: 'controlled-mixed-bid-event',
karama: true, card: 'controlled-original-karama' });
else assert.deepEqual(sent[0], { type, bidPromiseEvent: 'controlled-mixed-bid-event' });
}
});
void test('mixed bid guide callbacks reject busy, stale, changed lot, event, owner, funding and quiet-window changes', () => {
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: 4 }; }, game => { game.bidPromiseLot = { turn: 1, lot: 3 }; },
game => { game.bidPromiseLot = null; }, game => { game.auction = null; },
game => { game.bidPromiseGuide = { action: { ...game.bidPromiseGuide!.action } }; }, game => { game.bidPromiseGuide = null; },
game => { game.bidPromiseGuide!.action.amount = 8; }, game => { game.bidPromiseGuide!.action.allyPayment = 2; },
game => { game.bidPromiseGuide!.action.karama = true; }, game => { game.bidPromiseGuide!.action.type = 'ready'; },
game => { game.bidPromiseGuide!.action.bidPromiseEvent = 'later-event'; },
game => { game.mixedBidPromises[0].event = 'expired'; }, game => { game.mixedBidPromises[0].player = 'a'; },
game => { game.mixedBidPromises[0].turn = 1; }, game => { game.mixedBidPromises[0].lot = 4; },
game => { game.mixedBidPromises[0].fulfilled = true; }, game => { game.mixedBidPromises[0].released = true; },
game => { game.mixedBidPromises = []; },
game => { game.phaseOpening = { passed: [] }; }, game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.techTokens = createTechTokens(); },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
change(game); press(button); assert.deepEqual(sent, []);
}
const busy = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
const button = guide(busy); busy.busy = true; press(button);
const replaced = { game: projected(), busy: false, send() { assert.fail('A replaced view cannot dispatch'); } };
const previous = guide(replaced); replaced.game = { ...replaced.game }; press(previous);
});
void test('pure and mixed bid panels share exactly one current-lot guide while historical or foreign rows neither duplicate nor suppress it', () => {
const game = projected(), assertGuides = (pure: boolean, mixed: boolean) => {
assert.equal(pureMarkup(game).includes('data-testid="bid-promise-guide"'), pure);
assert.equal(markup(game).includes('data-testid="bid-promise-guide"'), mixed);
};
assertGuides(false, true);
game.bidPromises.push({ event: 'controlled-pure-event', player: 'p', asker: 'a', turn: 2, lot: 3, minimum: 4, answer: true });
assertGuides(true, false);
for (const change of [() => { game.bidPromises[0].fulfilled = true; },
() => { game.bidPromises[0].fulfilled = false; game.bidPromises[0].released = true; },
() => { game.bidPromises[0].released = false; game.bidPromises[0].turn = 1; },
() => { game.bidPromises[0].turn = 2; game.bidPromises[0].lot = 2; },
() => { game.bidPromises[0].lot = 3; game.bidPromises[0].player = 'a'; }]) {
change(); assertGuides(false, true);
}
game.bidPromises[0].player = 'p'; game.bidPromiseGuide!.action.bidPromiseEvent = 'controlled-pure-event';
assertGuides(true, false);
const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [game.bidPromiseGuide!.action]);
game.bidPromises = []; game.bidPromiseGuide!.action.bidPromiseEvent = 'controlled-mixed-bid-event';
game.auction!.active = 'a'; assert.match(markup(game), /Your next bid opportunity is still ahead/);
for (const change of [() => { game.mixedBidPromises[0].fulfilled = true; },
() => { game.mixedBidPromises[0].fulfilled = false; game.mixedBidPromises[0].released = true; },
() => { game.mixedBidPromises[0].released = false; game.mixedBidPromises[0].turn = 1; },
() => { game.mixedBidPromises[0].turn = 2; game.mixedBidPromises[0].lot = 2; }]) {
change(); assertGuides(false, false); assert.doesNotMatch(markup(game), /Your next bid opportunity is still ahead/);
}
});
void test('mixed bid panels and shared guide use public lot and own original question without hidden card, source, holdings or reduced-branch inspection', () => {
const game = projected(), sent: Action[] = [];
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'spice', 'forces', 'reserves', 'tanks', 'elites'])
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden ordinary-lot face inspection'); } });
for (const promise of game.mixedBidPromises) for (const key of ['source', 'minimum', 'residual', 'factAnswers', 'frozenFacts', 'compiled', 'progress'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
assert.match(markup(game), /Bid 7 spice while honoring your answers/);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [game.bidPromiseGuide!.action]);
});
+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import {
actTruthMixedBid as act, advanceTruthMixedBid as advance, answerTruthMixedBid as answer,
askTruthMixedBid as ask, bindTruthMixedBid as bind, continueTruthMixedBidLot as closeLot,
createTruthMixedBidFixture as fixture, declareTruthMixedBid as declare, finishTruthMixedBid as finish,
truthMixedBidClean as clean, truthMixedBidInventory as inventory, truthMixedBidPlayer as player,
truthMixedBidPromise as promise,
} from './fixture-truth-mixed-bid';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
void test('two original Truths combine scalar bid No six and mixed bid Yes four with one real five-spice declaration', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target,
turn: game.turn, lot: game.auction!.index + 1, minimum: 6 } });
game = answer(game, f.target, 'no'); assert.equal(game.truthtrance?.stage, 'ask');
game = ask(game, f.asker, f.target, f.mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedBidAnswers, ['yes', 'no']); game = answer(game, f.target, 'yes');
assert.equal(game.bidPromises?.length, 2); assert.equal(viewGame(game, f.target).bidPromises.length, 1);
assert.equal(viewGame(game, f.target).bidPromises[0].minimum, 6);
assert.notEqual(viewGame(game, f.target).bidPromises[0].event, promise(game).event);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).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 }); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 10); game = closeLot(game);
assert.equal(player(game, f.target).spice, 5); assert.equal(promise(game).released, false);
assert.ok(game.bidPromises?.every(row => row.fulfilled || row.released));
assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truths preserve a prior nine-force shipment when a later mixed bid four would spend its shared funding', () => {
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, f.mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedBidAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
rejected(game, f.target, { type: 'bid', amount: 4 }); game = act(game, f.target, { type: 'bid', amount: 1, allyPayment: 0 });
game = closeLot(game); assert.equal(player(game, f.target).spice, 9);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, true);
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.equal(player(game, f.target).spice, 0); assert.ok(game.shipmentPromises?.some(row => row.player === f.target && row.fulfilled));
assert.equal(promise(game).fulfilled, true);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed bid Yes retains four original spice while a real six-spice bribe is allowed', () => {
const f = fixture({ window: 'waiting' }); let game = bind(f).game;
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 7 });
game = act(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);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, false);
game = advance(game, state => state.auction?.active === f.target && clean(state));
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 4);
game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
game = closeLot(game); assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.asker).bribes, 6);
assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); assert.deepEqual(inventory(game), f.originalCards);
});
+247
View File
@@ -0,0 +1,247 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
import {
actTruthMixedBid as act, advanceTruthMixedBid as advance, answerTruthMixedBid as answer,
askTruthMixedBid as ask, bindTruthMixedBid as bind, continueTruthMixedBidLot as closeLot,
createTruthMixedBidFixture as fixture, declareTruthMixedBid as declare, finishTruthMixedBid as finish,
truthMixedBidClean as clean, truthMixedBidFact as fact, truthMixedBidGroup as group,
truthMixedBidInventory as inventory, truthMixedBidMinimum as bid, truthMixedBidPlayer as player,
truthMixedBidPromise as promise,
} from './fixture-truth-mixed-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);
}
function truthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} mixed bid freezes ten spice before the actual declaration and original four-spice purchase`, () => {
const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.sourceActionCount, advanced ? 25 : 22); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(f.staging, []); assert.deepEqual(f.game.expansions, []); assert.equal(f.originalCards.length, 33);
assert.equal(f.truths.length, 2); assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
const source = structuredClone(f.game); let game = bind(f).game;
assert.deepEqual(f.game, source); assert.equal(game.truthtrance, null); truthOnce(game, f.truth);
assert.deepEqual(promise(game).mixed, f.mixed); assert.equal(promise(game).fulfilled, false);
rejected(game, f.target, { type: 'passBid' }); rejected(game, f.target, { type: 'bid', amount: 3 });
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 4);
assert.equal(guide.action.bidPromiseEvent, promise(game).event);
game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 10, 'The accepted declaration fulfills before the original winning invoice');
game = closeLot(game); assert.equal(player(game, f.target).spice, 6);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.equal(player(game, f.target).hand.filter(card => card.id === f.lotCard).length, 1);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native true held Shield OR a future bid gives only Yes with no binding and leaves the actual bid legal', () => {
const f = fixture(), mixed = group('or', fact(f.shieldFact), bid());
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBidAnswers, ['yes']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' }); let game = answer(asked, f.target, 'yes');
assert.equal(promise(game).fulfilled, true); assert.equal(game.bidPromises?.length ?? 0, 0);
assert.equal(viewGame(game, f.target).bidPromiseGuide, null); assert.deepEqual(promise(game).mixed, mixed);
game = act(game, f.target, { type: 'bid', amount: 4 }); game = closeLot(game);
assert.equal(player(game, f.target).spice, 6); assert.equal(promise(game).fulfilled, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false held Hajr AND a future bid gives only No while the later qualifying declaration remains legal', () => {
const f = fixture(), mixed = group('and', fact(f.hajrFact), bid());
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBidAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); let game = answer(asked, f.target, 'no');
assert.equal(promise(game).fulfilled, true); assert.equal(game.bidPromises?.length ?? 0, 0);
game = act(game, f.target, { type: 'bid', amount: 4 }); game = closeLot(game);
assert.equal(player(game, f.target).spice, 6); assert.equal(promise(game).fulfilled, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false Hajr OR future bid Yes requires a new accepted bid', () => {
const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.hajrFact), bid())).game;
assert.equal(promise(game).fulfilled, false); rejected(game, f.target, { type: 'passBid' });
rejected(game, f.target, { type: 'bid', amount: 3 }); game = act(game, f.target, { type: 'bid', amount: 4 });
assert.equal(promise(game).fulfilled, true); game = closeLot(game); assert.equal(player(game, f.target).spice, 6);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native true Shield AND future bid No allows a lower bid and forbids every later qualifying declaration in that lot', () => {
const f = fixture({ window: 'waiting' }); let game = act(f.game, f.asker, { type: 'bid', amount: 1 });
game = act(game, f.opponent, { type: 'bid', amount: 2 });
game = bind(f, 'no', group('and', fact(f.shieldFact), bid(6)), game).game;
rejected(game, f.target, { type: 'bid', amount: 6 }); game = act(game, f.target, { type: 'bid', amount: 4 });
assert.equal(promise(game).fulfilled, false); game = act(game, f.asker, { type: 'bid', amount: 5 });
game = act(game, f.opponent, { type: 'passBid' }); rejected(game, f.target, { type: 'bid', amount: 6 });
game = act(game, f.target, { type: 'passBid' }); game = advance(game, clean);
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.equal(player(game, f.target).spice, 15); assert.equal(player(game, f.asker).spice, 5);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native whole nested mixed bid ${answerValue} collapses AND maxima and OR minima to the same threshold`, () => {
const f = fixture(), mixed = group('and', fact(f.shieldFact), group('or',
group('and', bid(4), bid(7)), group('and', bid(3), bid(5))));
let game = bind(f, answerValue, mixed).game; assert.deepEqual(promise(game).mixed, mixed);
const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
if (answerValue === 'yes') {
assert.equal(guide.action.amount, 5); rejected(game, f.target, { type: 'bid', amount: 4 });
game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
} else {
rejected(game, f.target, { type: 'bid', amount: 5 }); game = act(game, f.target, { type: 'bid', amount: 4 });
}
game = closeLot(game); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, answerValue === 'yes' ? 5 : 6); assert.deepEqual(inventory(game), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native unknown forecast uses the conservative whole ${answerValue} mixed bid constraint`, () => {
const f = fixture(), p = player(f.game, f.target); assert.equal(truthKnowledgeOf(f.game, p).stormForecast, null);
assert.equal(truthFactAnswer(p, f.forecastFact, truthKnowledgeOf(f.game, p)), 'unknown');
const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), bid());
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBidAnswers, [answerValue]);
rejected(asked, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
let game = answer(asked, f.target, answerValue); assert.equal(promise(game).fulfilled, false);
if (answerValue === 'yes') {
rejected(game, f.target, { type: 'passBid' }); game = act(game, f.target, { type: 'bid', amount: 4 });
assert.equal(promise(game).fulfilled, true); game = closeLot(game);
} else {
rejected(game, f.target, { type: 'bid', amount: 4 }); game = act(game, f.target, { type: 'bid', amount: 2 });
if (game.auction?.index === 0) game = closeLot(game);
}
assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native prior qualifying bid before a real outbid remains outside the newly answered mixed Yes', () => {
const f = fixture({ window: 'waiting' }); 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 });
game = bind(f, 'yes', f.mixed, game).game; assert.equal(promise(game).fulfilled, 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(promise(game).fulfilled, true);
game = closeLot(game); assert.equal(player(game, f.target).spice, 3); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed bid question and guide retain exact owner turn lot event and JSON boundaries', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthMixedBidTargets.includes(f.target));
rejected(declared.game, f.opponent, f.questionAction);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, lot: f.question.lot + 1 } });
let game = ask(declared.game, f.asker, f.target, f.mixed);
assert.deepEqual(viewGame(game, f.target).truthMixedBidAnswers, ['yes', 'no']);
assert.equal(viewGame(game, f.asker).truthMixedBidAnswers, null); assert.equal(viewGame(game, f.opponent).truthMixedBidAnswers, 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.equal(guide.action.bidPromiseEvent, promise(game).event); rejected(game, f.opponent, guide.action);
rejected(game, f.target, { ...guide.action, bidPromiseEvent: 'stale-mixed-bid-event' });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
assert.equal(promise(game).fulfilled, true); rejected(game, f.target, wire);
game = closeLot(game); game = current(game, f.target); rejected(game, f.target, wire);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed bid projection retains only owned raw aggregate and filters its native residual', () => {
const f = fixture(), game = bind(f).game;
for (const actor of [f.target, f.asker, f.opponent]) {
const view = viewGame(game, actor); assert.deepEqual(view.bidPromises, []); assert.equal(view.truthMixedBidAnswers, null);
assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
if (actor !== f.target) {
assert.deepEqual(view.mixedBidPromises, []); assert.equal(view.bidPromiseGuide, null);
} else {
const row = promise(game);
assert.deepEqual(Object.keys(row).sort(), ['answer', 'asker', 'event', 'fulfilled', 'lot', 'mixed', 'player', 'released', 'turn']);
assert.equal(row.player, f.target); assert.equal(row.asker, f.asker); assert.equal(row.turn, f.game.turn);
assert.equal(row.lot, f.question.lot); assert.equal(row.answer, true); assert.deepEqual(row.mixed, f.mixed);
assert.equal(row.fulfilled, false); assert.equal(row.released, false);
assert.equal(view.bidPromiseGuide?.action.bidPromiseEvent, row.event);
}
}
assert.equal(game.bidPromises?.length, 1); assert.equal(game.bidPromises![0].event, promise(game).event);
});
void test('native waiting mixed Yes releases after a real opposing ten-spice raise and retains original Emperor income', () => {
const f = fixture({ window: 'waiting' }); assert.equal(f.sourceActionCount, 20);
let game = bind(f).game; assert.equal(game.auction!.active, f.asker); assert.equal(viewGame(game, f.target).bidPromiseGuide, null);
game = act(game, f.asker, { type: 'passBid' }); game = act(game, f.opponent, { type: 'bid', amount: 10 });
assert.equal(game.auction!.active, f.target); assert.equal(promise(game).released, true); assert.equal(promise(game).fulfilled, false);
game = act(game, f.target, { type: 'passBid' }); game = closeLot(game);
assert.equal(player(game, f.target).spice, 20); assert.equal(player(game, f.opponent).spice, 0);
assert.ok(player(game, f.opponent).hand.some(card => card.id === f.lotCard)); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed No allows an actual lower purchase and does not reactivate in the next ordinary lot', () => {
const f = fixture(); let game = bind(f, 'no').game;
rejected(game, f.target, { type: 'bid', amount: 4 }); game = act(game, f.target, { type: 'bid', amount: 2 });
if (game.auction?.index === 0) game = closeLot(game);
assert.equal(game.auction!.index, 1); assert.equal(player(game, f.target).spice, 8);
assert.equal(promise(game).fulfilled, true); assert.equal(viewGame(game, f.target).bidPromiseGuide, null);
game = current(game, f.target); game = act(game, f.target, { type: 'bid', amount: 4 });
assert.equal(game.auction!.bid, 4); assert.equal(game.auction!.bidder, f.target); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native accepted mixed Yes is fulfilled even when a later opposing bidder wins and pays', () => {
const f = fixture({ window: 'waiting' }); let game = act(f.game, f.asker, { type: 'bid', amount: 1 });
game = act(game, f.opponent, { type: 'bid', amount: 2 }); game = bind(f, 'yes', f.mixed, game).game;
game = act(game, f.target, { type: 'bid', amount: 4 }); assert.equal(promise(game).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.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
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('native original Karama funds mixed eleven-spice Yes and settles its original free-purchase invoice', () => {
const f = fixture({ targetKarama: true }); let game = bind(f, 'yes', group('and', fact(f.spiceFact), bid(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); assert.equal(promise(game).fulfilled, true);
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.equal(promise(game).fulfilled, true);
assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); truthOnce(game, f.karama!);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed action targets exclude already-passed and current-highest bidders', () => {
const passed = fixture(), declared = declare(passed);
assert.ok(passed.game.auction!.passed.includes(passed.opponent));
assert.ok(!viewGame(declared.game, passed.asker).truthMixedBidTargets.includes(passed.opponent));
rejected(declared.game, passed.asker, { type: 'truthAsk', question: { ...passed.question, target: passed.opponent } });
const highest = fixture({ window: 'waiting' }); 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 });
game = declare(highest, game).game; assert.ok(!viewGame(game, highest.asker).truthMixedBidTargets.includes(highest.target));
rejected(game, highest.asker, highest.questionAction); assert.deepEqual(inventory(game), highest.originalCards);
});
void test('all four minimal policies answer and execute an exact legal mixed bid Yes guide', () => {
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const answering = viewGame(game, f.target);
answering.players.find(seat => seat.id === f.target)!.bot = difficulty;
const answerAction = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
assert.equal(answerAction.answer, 'yes', difficulty); game = act(game, f.target, answerAction);
const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const guide = view.bidPromiseGuide; assert.ok(guide, difficulty);
const offered = botActions(view).find(action => action.type === 'bid' && action.bidPromiseEvent === guide.action.bidPromiseEvent);
assert.ok(offered, difficulty); assert.deepEqual(offered, guide.action); game = act(game, f.target, offered);
assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, 10);
game = closeLot(game); assert.equal(player(game, f.target).spice, 6); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
}
});