Combine current facts with native bid declarations
This commit is contained in:
1 parent
957d5a172f
commit
0d979e4f8b
20 files changed
+1214
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user