Expose grouped Truthtrance current-fact questions

This commit is contained in:
Quintine committed 2026-10-26 08:50:28 +11:00
1 parent 2ea3c58f68
commit 8e2f2c6a95
14 files changed
+1187 -54

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import type { TruthAnswer, TruthFact, TruthQuestion } from '../game/truthtrance';
import {
createTruthMovementEarlyFixture,
actTruthMovementEarly,
truthMovementEarlyClean,
truthMovementEarlyPlayer,
truthMovementEarlyInventory,
type TruthMovementEarlyStep,
} from './fixture-truth-movement-early';
export type TruthFactExpressionStep = TruthMovementEarlyStep;
export type TruthFactExpressionScenario = {
id: string;
fact: TruthFact;
answer: 'yes' | 'no';
};
export const actTruthFactExpression = actTruthMovementEarly;
export const truthFactExpressionClean = truthMovementEarlyClean;
export const truthFactExpressionPlayer = truthMovementEarlyPlayer;
export const truthFactExpressionInventory = truthMovementEarlyInventory;
/** These predicates describe the genuine Emperor starting position below.
* Basic Emperor uses total physical forces; no elite split is invented. */
export function truthFactExpressionFacts(): { trueFacts: TruthFact[]; falseFacts: TruthFact[] } {
return {
trueFacts: [
{ kind: 'spice', compare: 'gte', value: 10 },
{ kind: 'handCount', name: 'Baliset', compare: 'gte', value: 1 },
{ kind: 'handCount', name: 'Shield', compare: 'eq', value: 0 },
{ kind: 'handInventory', category: 'all', compare: 'eq', value: 1 },
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'eq', value: 20 },
{ kind: 'forceCount', zone: { kind: 'tanks' }, counter: 'total', compare: 'eq', value: 0 },
{ kind: 'forceCount', zone: { kind: 'location', territory: 'arrakeen', sector: 10 },
counter: 'total', compare: 'eq', value: 0 },
{ kind: 'handInventory', category: 'worthless', compare: 'eq', value: 1 },
],
falseFacts: [
{ kind: 'spice', compare: 'gte', value: 11 },
{ kind: 'handCount', name: 'Baliset', compare: 'gte', value: 2 },
{ kind: 'handCount', name: 'Shield', compare: 'gte', value: 1 },
{ kind: 'handInventory', category: 'all', compare: 'eq', value: 0 },
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 21 },
{ kind: 'forceCount', zone: { kind: 'tanks' }, counter: 'total', compare: 'gte', value: 1 },
{ kind: 'forceCount', zone: { kind: 'location', territory: 'arrakeen', sector: 10 },
counter: 'total', compare: 'gte', value: 1 },
{ kind: 'handInventory', category: 'weapon', compare: 'gte', value: 1 },
],
};
}
/** Build three through eight clauses from original, independently known facts.
* The decisive final clause also exercises evaluation beyond the old second row. */
export function truthFactExpressionScenario(
kind: 'and' | 'or' = 'and',
count = 3,
answer: 'yes' | 'no' = 'yes',
): TruthFactExpressionScenario {
assert.ok(Number.isInteger(count) && count >= 3 && count <= 8);
const { trueFacts, falseFacts } = truthFactExpressionFacts();
const terms = (kind === 'and' ? trueFacts : falseFacts).slice(0, count);
if (kind === 'and' && answer === 'no') terms[count - 1] = falseFacts[count - 1];
if (kind === 'or' && answer === 'yes') terms[count - 1] = trueFacts[count - 1];
return { id: `${count}-${kind}-${answer}`, fact: { kind, terms }, answer };
}
export function truthFactExpressionNestedScenario(
kind: 'and' | 'or' = 'and',
answer: 'yes' | 'no' = 'yes',
): TruthFactExpressionScenario {
const { trueFacts, falseFacts } = truthFactExpressionFacts();
const fact: TruthFact = kind === 'and'
? { kind: 'and', terms: [trueFacts[0], { kind: 'or', terms: [falseFacts[0], falseFacts[1],
answer === 'yes' ? trueFacts[2] : falseFacts[2]] }, trueFacts[3]] }
: { kind: 'or', terms: [falseFacts[0], { kind: 'and', terms: [trueFacts[0], trueFacts[1],
answer === 'yes' ? trueFacts[2] : falseFacts[2]] }, falseFacts[3]] };
return { id: `nested-${kind}-${answer}`, fact, answer };
}
/** Original public Basic A/E/H/G setup, followed by its actual two zero Storm
* dials and one Emperor Ready. No postdeal hand, wallet, army, phase, response,
* question, answer or discard is installed. Only the inherited unopened deck
* positions and original scalar lotteries are selected. */
export function createTruthFactExpressionFixture() {
const base = createTruthMovementEarlyFixture({ phase: 0 });
let game = structuredClone(base.game);
const actions: TruthFactExpressionStep[] = structuredClone(base.actions);
const beforeStormControls = structuredClone(game);
assert.equal(game.stormPending, null);
for (const actor of game.stormDialers)
game = actTruthFactExpression(game, actor, { type: 'stormDial', amount: 0 }, actions);
game = actTruthFactExpression(game, base.target, { type: 'ready' }, actions);
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
assert.equal(game.turn, 1); assert.equal(game.phase, 0); assert.equal(game.stormPending, 0);
assert.deepEqual(game.ready, [base.target]); assert.deepEqual(game.expansions, []);
assert.equal(game.response ?? null, null); assert.ok(truthFactExpressionClean(game));
const target = truthFactExpressionPlayer(game, base.target);
assert.equal(target.spice, 10); assert.equal(target.reserves, 20); assert.equal(target.tanks, 0);
assert.deepEqual(target.forces, {}); assert.deepEqual(target.hand.map(card => card.name), ['Baliset']);
assert.ok(truthFactExpressionPlayer(game, base.asker).hand.some(card => card.id === base.card));
assert.ok(truthFactExpressionPlayer(game, base.counterHolder).hand.some(card => card.id === base.karama));
assert.equal(base.originalCards.length, 33);
assert.deepEqual(truthFactExpressionInventory(game), base.originalCards);
const scenario = truthFactExpressionScenario();
const question: TruthQuestion = { kind: 'fact', target: base.target, fact: scenario.fact };
const declarationStep: TruthFactExpressionStep = { actor: base.asker,
action: { type: 'card', card: base.card } };
const askStep: TruthFactExpressionStep = { actor: base.asker, action: { type: 'truthAsk', question } };
const answerStep: TruthFactExpressionStep = { actor: base.target,
action: { type: 'truthAnswer', answer: scenario.answer } };
const continuationStep: TruthFactExpressionStep = { actor: base.asker, action: { type: 'ready' } };
return {
game, source: structuredClone(game), base, beforeStormControls,
lobby: base.lobby, setup: base.setup, beforeStartingDeal: base.beforeStartingDeal,
afterSetup: base.afterSetup, deal: base.deal, dealOrder: base.dealOrder,
spiceOrder: base.spiceOrder, originalCards: base.originalCards, originalTreachery: base.originalTreachery,
originalSpice: base.originalSpice, asker: base.asker, target: base.target,
counterHolder: base.counterHolder, guild: base.guild, card: base.card, truth: base.card, karama: base.karama,
scenario, fact: scenario.fact, question, declarationStep, askStep, answerStep, continuationStep,
actions, sourceActionCount: actions.length,
stormSteps: structuredClone(actions.slice(base.sourceActionCount)),
sourceCounts: { setup: base.sourceActionCount, storm: actions.length },
staging: [...base.staging,
'The current-fact source additionally takes the two native zero Storm dials and Emperor Ready, retaining turn 1 Storm with that nonempty Ready list. Questions concern only Emperor’s original Baliset, ten spice, twenty total reserves, zero Tanks and empty Arrakis positions. Truthtrance declaration, every priority pass, authored question, aggregate answer, physical discard and later Atreides Ready remain caller actions.'],
};
}
export type TruthFactExpressionFixture = ReturnType<typeof createTruthFactExpressionFixture>;
export function passTruthFactExpressionPriority(game: Game, actions: TruthFactExpressionStep[] = []): Game {
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthFactExpression(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return game;
}
export function declareTruthFactExpression(fixture: TruthFactExpressionFixture) {
const before = structuredClone(fixture.game), actions: TruthFactExpressionStep[] = [];
const declaration = structuredClone(fixture.declarationStep);
const game = actTruthFactExpression(before, declaration.actor, declaration.action, actions);
assert.equal(game.truthtrance?.stage, 'priority');
return { game, before, declaration, opened: structuredClone(game), actions };
}
export function askTruthFactExpression(fixture: TruthFactExpressionFixture, fact: TruthFact = fixture.fact) {
const declared = declareTruthFactExpression(fixture), actions = declared.actions;
let game = passTruthFactExpressionPriority(declared.game, actions);
const beforeQuestion = structuredClone(game);
const question: TruthFactExpressionStep = { actor: fixture.asker,
action: { type: 'truthAsk', question: { kind: 'fact', target: fixture.target, fact: structuredClone(fact) } } };
game = actTruthFactExpression(game, question.actor, question.action, actions);
assert.equal(game.truthtrance?.stage, 'answer');
return { ...declared, game, beforeQuestion, question, actions };
}
export function answerTruthFactExpression(
game: Game, answer: TruthAnswer, actions: TruthFactExpressionStep[] = [],
): Game {
const target = game.truthtrance?.question?.target; assert.ok(target);
assert.equal(viewGame(game, target).truthAnswer, answer);
return actTruthFactExpression(game, target, { type: 'truthAnswer', answer }, actions);
}
export function finishTruthFactExpression(
fixture: TruthFactExpressionFixture,
scenario: TruthFactExpressionScenario = fixture.scenario,
) {
const asked = askTruthFactExpression(fixture, scenario.fact), actions = asked.actions;
const game = answerTruthFactExpression(asked.game, scenario.answer, actions);
const answerStep = actions.at(-1)!;
assert.ok(truthFactExpressionClean(game));
return { ...asked, asked: asked.game, game, answerStep, actions };
}
@@ -0,0 +1,376 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { TruthFactExpressionFields } from '../components/truth-fact-expression-fields';
import { TruthFactQuestion } from '../components/truth-fact-question';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { truthFactExpressionError, truthFactExpressionSize } from '../game/truth-fact-expression';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
},
});
const { Truthtrance } = await import('../components/truthtrance');
aliases.deregister();
// Controlled entitled projections supplement the separate native card journeys.
// No authoritative hand, question result or continuation is installed in a Game.
// Only hook-free components are invoked directly. React renders TruthFactFields
// and its stateful descendants; leaf edits cross its actual onChange boundary.
function projected(): GameView {
const game = createGame('TRUTHFACTCONTROLS', newPlayer('a', 'Asker', 'atreides'), false);
joinGame(game, newPlayer('t', 'Target', 'emperor'));
joinGame(game, newPlayer('o', 'Observer', 'harkonnen'));
const view = viewGame(game, 'a');
view.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'projected-truth-card' }], passed: [], question: null };
return view;
}
const hand = (name = 'Shield'): TruthFact => ({ kind: 'hand', name });
const spice = (value: number): TruthFact => ({ kind: 'spice', compare: 'gte', value });
const factQuestion = (fact: TruthFact, target = 't'): Extract<TruthQuestion, { kind: 'fact' }> => ({ kind: 'fact', target, fact });
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function control(tree: ReactNode, key: string): Element {
const found = elements(tree).find(element => element.props['data-truth-fact-control'] === key);
assert.ok(found, `Control ${key} is rendered`);
return found;
}
function click(button: Element) { (button.props.onClick as () => void)(); }
function fieldset(tree: ReactNode, path: string): Element {
const found = elements(tree).find(element => element.props['data-truth-fact-path'] === path);
assert.ok(found, `Fact or group ${path} is rendered`);
return found;
}
function editLeaf(tree: ReactNode, path: string, next: TruthFact) {
const field = elements(fieldset(tree, path)).find(element => element.type === TruthFactFields);
assert.ok(field, `Current fact fields for ${path} are rendered`);
(field.props.onChange as (fact: TruthFact) => void)(next);
}
function switchGroup(tree: ReactNode, path: string, kind: string) {
const select = elements(fieldset(tree, path)).find(element => element.type === 'select');
assert.ok(select, `Group meaning selector ${path} is rendered`);
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: kind } });
}
function editor(initial: TruthFact, game = projected(), disabled = false) {
let value = initial;
let changes = 0;
return {
get value() { return value; },
get changes() { return changes; },
tree: () => TruthFactExpressionFields({ value, game, target: 't', disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(TruthFactExpressionFields, {
value, game, target: 't', disabled, onChange() {},
})),
};
}
function askTree(game: GameView, question: Extract<TruthQuestion, { kind: 'fact' }>, busy = false) {
const actions: Action[] = [];
const changes: TruthFact[] = [];
const props = { game, question, busy, act(action: Action) { actions.push(action); }, onChange(next: TruthFact) { changes.push(next); } };
const tree = TruthFactQuestion(props);
const button = elements(tree).find(element => element.props['data-testid'] === 'truth-fact-ask');
assert.ok(button, 'The actual Ask action is rendered');
return { tree, button, actions, changes, html: () => renderToStaticMarkup(createElement(TruthFactQuestion, props)) };
}
function groupsDeep(depth: number): TruthFact {
let fact = hand();
for (let level = 1; level <= depth; level++) fact = { kind: 'and', terms: [fact, spice(level)] };
return fact;
}
void test('actual add and leaf-edit callbacks author three through eight terms without losing prior facts', () => {
const first: TruthFact = { kind: 'handCount', name: 'Shield', compare: 'eq', value: 2 };
const e = editor(first);
click(control(e.tree(), 'and-1'));
editLeaf(e.tree(), '1.2', { kind: 'traitor', leader: CHEAP_HERO_TRAITOR });
for (let count = 3; count <= 8; count++) {
const before = structuredClone(e.value);
click(control(e.tree(), 'add-1'));
editLeaf(e.tree(), `1.${count}`, spice(count));
assert.ok(e.value.kind === 'and');
assert.equal(e.value.terms.length, count);
assert.deepEqual(e.value.terms.slice(0, count - 1), (before as Extract<TruthFact, { kind: 'and' | 'or' }>).terms);
assert.deepEqual(e.value.terms[count - 1], spice(count));
}
assert.ok(e.value.kind === 'and');
assert.deepEqual(e.value.terms[0], first);
const html = e.html();
assert.equal((html.match(/Current fact type/g) ?? []).length, 8);
assert.match(html, /9 \/ 16 total clauses/);
const before = structuredClone(e.value), changes = e.changes;
assert.equal(control(e.tree(), 'add-1').props.disabled, true);
click(control(e.tree(), 'add-1'));
assert.equal(e.changes, changes);
assert.deepEqual(e.value, before);
});
void test('nested AND and OR callbacks preserve every branch while switching group meaning', () => {
const e = editor(hand('Snooper'));
click(control(e.tree(), 'and-1'));
editLeaf(e.tree(), '1.2', spice(7));
click(control(e.tree(), 'or-1.2'));
editLeaf(e.tree(), '1.2.2', { kind: 'handInventory', category: 'defense', compare: 'lte', value: 3 });
click(control(e.tree(), 'add-1.2'));
editLeaf(e.tree(), '1.2.3', { kind: 'stormForecast', compare: 'gte', value: 2 });
const before = structuredClone(e.value);
assert.ok(before.kind === 'and');
const originalNested = before.terms[1];
assert.ok(originalNested.kind === 'or');
switchGroup(e.tree(), '1.2', 'and');
assert.deepEqual(e.value, { kind: 'and', terms: [hand('Snooper'), {
kind: 'and', terms: [spice(7), { kind: 'handInventory', category: 'defense', compare: 'lte', value: 3 }, { kind: 'stormForecast', compare: 'gte', value: 2 }],
}] });
switchGroup(e.tree(), '1.2', 'or');
assert.deepEqual(e.value, before);
const changes = e.changes;
switchGroup(e.tree(), '1.2', 'single');
assert.equal(e.changes, changes);
click(control(e.tree(), 'nested-1.2'));
assert.ok(e.value.kind === 'and');
const nested = e.value.terms[1];
assert.ok(nested.kind === 'or');
assert.deepEqual(nested.terms.slice(0, 3), originalNested.terms);
assert.deepEqual(nested.terms[3], { kind: 'and', terms: [hand(), hand()] });
assert.match(e.html(), /Group 1\.2 · OR/);
assert.match(e.html(), /Group 1\.2\.4 · AND/);
});
void test('removing a fact collapses a singleton group and removing a group keeps the full survivor', () => {
const retained: TruthFact = { kind: 'forceCount', zone: { kind: 'location', territory: 'carthag', sector: 10 }, counter: 'total', compare: 'eq', value: 4 };
const e = editor({ kind: 'or', terms: [hand(), { kind: 'and', terms: [spice(9), retained] }] });
click(control(e.tree(), 'remove-1.2.1'));
assert.deepEqual(e.value, { kind: 'or', terms: [hand(), retained] });
assert.equal(truthFactExpressionError(e.value), null);
click(control(e.tree(), 'remove-1.1'));
assert.deepEqual(e.value, retained);
assert.equal(truthFactExpressionError(e.value), null);
const nestedSurvivor: TruthFact = { kind: 'or', terms: [spice(1), spice(2), spice(3)] };
const whole = editor({ kind: 'and', terms: [{ kind: 'and', terms: [hand(), hand('Snooper')] }, nestedSurvivor] });
click(control(whole.tree(), 'remove-1.1'));
assert.deepEqual(whole.value, nestedSurvivor);
});
void test('the sixteen-node envelope guards add and wrap callbacks without truncating the maximum expression', () => {
const full: TruthFact = { kind: 'and', terms: [
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => spice(index)) },
{ kind: 'and', terms: Array.from({ length: 5 }, (_, index) => spice(index + 8)) },
] };
assert.deepEqual(truthFactExpressionSize(full), { nodes: 16, depth: 2 });
const e = editor(full), before = structuredClone(full);
for (const key of ['add-1', 'nested-1', 'add-1.2', 'and-1.2.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true, key);
click(control(e.tree(), key));
}
assert.equal(e.changes, 0);
assert.deepEqual(e.value, before);
assert.equal((e.html().match(/Current fact type/g) ?? []).length, 13);
editLeaf(e.tree(), '1.2.5', { kind: 'handCount', name: 'Snooper', compare: 'eq', value: 4 });
assert.equal(e.changes, 1, 'Editing an existing leaf is still available at the maximum');
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 16, depth: 2 });
});
void test('four levels allow shallow nesting and deepest leaf edits while rejecting a fifth level in actual callbacks', () => {
const e = editor(groupsDeep(4));
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 9, depth: 4 });
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-1.1.1.1']) {
assert.equal(control(e.tree(), key).props.disabled, true, key);
click(control(e.tree(), key));
}
assert.equal(e.changes, 0);
editLeaf(e.tree(), '1.1.1.1.1', hand('Snooper'));
click(control(e.tree(), 'add-1.1.1.1'));
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 10, depth: 4 });
assert.equal(control(e.tree(), 'or-1.2').props.disabled, false);
click(control(e.tree(), 'or-1.2'));
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 12, depth: 4 });
assert.equal(truthFactExpressionError(e.value), null);
});
void test('busy disables the outer fieldset and guards directly invoked add, wrap, remove, switch and leaf callbacks', () => {
const initial: TruthFact = { kind: 'and', terms: [hand(), spice(3)] };
const e = editor(initial, projected(), true);
assert.match(e.html(), /<fieldset\b[^>]*disabled=""[^>]*data-testid="truth-fact-expression"/);
for (const key of ['add-1', 'nested-1', 'and-1.1', 'or-1.1', 'remove-1.2']) {
assert.equal(control(e.tree(), key).props.disabled, true, key);
click(control(e.tree(), key));
}
switchGroup(e.tree(), '1', 'or');
editLeaf(e.tree(), '1.2', spice(999));
assert.equal(e.changes, 0);
assert.deepEqual(e.value, initial);
});
void test('Ask sends the exact complete three-term, eight-term and nested question payload', () => {
const facts: TruthFact[] = [
{ kind: 'and', terms: [hand('Shield'), hand('Snooper'), spice(6)] },
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => spice(index)) },
{ kind: 'and', terms: [hand(), { kind: 'or', terms: [spice(5), hand('Snooper'), { kind: 'traitor', leader: CHEAP_HERO_TRAITOR }] }] },
];
for (const fact of facts) {
const game = projected(), question = factQuestion(fact), before = structuredClone(question);
const ask = askTree(game, question);
assert.equal(ask.button.props.disabled, false);
click(ask.button);
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question }]);
assert.deepEqual(Object.keys(ask.actions[0]).sort(), ['question', 'type']);
assert.deepEqual(question, before);
assert.match(ask.html(), /data-testid="truth-fact-preview"/);
}
});
void test('the stateful parent renders the default Shield expression with one public Ask action', () => {
for (const busy of [false, true]) {
const game = projected();
const html = renderToStaticMarkup(createElement(Truthtrance, { game, busy, act() {} }));
assert.match(html, /Verified current facts/);
assert.match(html, /data-testid="truth-fact-expression"/);
assert.match(html, /Is it true that you hold Shield\?/);
assert.equal((html.match(/Current fact type/g) ?? []).length, 1);
assert.equal((html.match(/Ask publicly/g) ?? []).length, 1);
const button = html.match(/<button\b(?=[^>]*data-testid="truth-fact-ask")[^>]*>[\s\S]*?<\/button>/)?.[0];
assert.ok(button);
assert.equal(/\sdisabled=/.test(button), busy);
assert.doesNotMatch(html, /Both · AND|Either · OR|Combine conditions/);
}
});
void test('a malformed third leaf blocks the whole AND or OR despite valid earlier clauses', () => {
const invalid: TruthFact[] = [
spice(-1), { kind: 'handCount', name: 'Shield', compare: 'eq', value: 0.5 },
{ kind: 'handInventory', category: 'all', compare: 'gte', value: NaN },
{ kind: 'prediction', field: 'turn', compare: 'eq', value: 0 },
{ kind: 'stormDial', compare: 'gte', value: 21 }, { kind: 'stormForecast', compare: 'lte', value: 0 },
{ kind: 'noFieldValue', value: 7 } as unknown as TruthFact,
{ kind: 'faceDancer', leader: 'not-a-leader' },
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: -1 },
];
for (const kind of ['and', 'or'] as const) for (const fact of invalid) {
const ask = askTree(projected(), factQuestion({ kind, terms: [hand(), spice(0), fact] }));
assert.equal(ask.button.props.disabled, true, `${kind}: ${fact.kind}`);
click(ask.button);
assert.deepEqual(ask.actions, []);
assert.match(ask.html(), /Resolve the marked current-fact fields before asking/);
}
});
void test('over-width, over-node, over-depth and singleton expressions cannot reach Ask and remain fully rendered', () => {
const invalid: TruthFact[] = [
{ kind: 'and', terms: [hand()] },
{ kind: 'or', terms: Array.from({ length: 9 }, (_, index) => spice(index)) },
{ kind: 'and', terms: [
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => spice(index)) },
{ kind: 'and', terms: Array.from({ length: 6 }, (_, index) => spice(index + 8)) },
] },
groupsDeep(5),
];
for (const fact of invalid) {
const ask = askTree(projected(), factQuestion(fact));
assert.equal(ask.button.props.disabled, true);
click(ask.button);
assert.deepEqual(ask.actions, []);
const html = ask.html();
assert.match(html, /role="alert"/);
const leafCount = (value: TruthFact): number => value.kind === 'and' || value.kind === 'or'
? value.terms.reduce((count, term) => count + leafCount(term), 0) : 1;
assert.equal((html.match(/Current fact type/g) ?? []).length, leafCount(fact));
}
});
void test('busy, wrong owner, absent windows and invalid targets guard Ask and the editor change callback', () => {
const question = factQuestion(hand());
const cases: { game: GameView; question?: typeof question; busy?: boolean }[] = [{ game: projected(), busy: true }];
for (const stage of ['priority', 'answer', 'unknown'] as const) {
const game = projected(); game.truthtrance!.stage = stage; cases.push({ game });
}
for (const owner of ['t', 'not-seated']) {
const game = projected(); game.truthtrance!.queue[0].player = owner; cases.push({ game });
}
const unseated = projected(); unseated.me = 'not-seated'; unseated.truthtrance!.queue[0].player = 'not-seated'; cases.push({ game: unseated });
const empty = projected(); empty.truthtrance!.queue = []; cases.push({ game: empty });
const absent = projected(); absent.truthtrance = null; cases.push({ game: absent });
for (const target of ['a', 'not-seated']) cases.push({ game: projected(), question: factQuestion({
kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 1,
}, target) });
for (const entry of cases) {
const ask = askTree(entry.game, entry.question ?? question, entry.busy);
assert.equal(ask.button.props.disabled, true);
click(ask.button);
const fields = elements(ask.tree).find(element => element.type === TruthFactExpressionFields);
if (fields) {
assert.equal(fields.props.disabled, true);
(fields.props.onChange as (next: TruthFact) => void)(spice(9));
}
assert.deepEqual(ask.actions, []);
assert.deepEqual(ask.changes, []);
assert.doesNotThrow(ask.html);
}
const game = projected(), stale = askTree(game, question);
game.truthtrance!.stage = 'answer';
click(stale.button);
assert.deepEqual(stale.actions, [], 'The existing callback rechecks the live ask stage');
game.truthtrance!.stage = 'ask'; game.truthtrance!.queue[0].player = 't';
click(stale.button);
assert.deepEqual(stale.actions, [], 'The existing callback rechecks the live queue owner');
game.truthtrance!.queue[0].player = 'a';
game.players = game.players.filter(player => player.id !== question.target);
click(stale.button);
assert.deepEqual(stale.actions, [], 'The existing callback rechecks the live target seat');
});
void test('meaning, current custody, app limits and public leader labels persist with the editor and preview', () => {
const game = projected(), leader = game.allLeaders[0];
const ask = askTree(game, factQuestion({ kind: 'and', terms: [
{ kind: 'traitor', leader: leader.id },
{ kind: 'or', terms: [hand(), { kind: 'traitor', leader: CHEAP_HERO_TRAITOR }] },
] }));
for (let render = 0; render < 3; render++) {
const html = ask.html();
assert.match(html, /AND requires all conditions; OR requires at least one/);
assert.match(html, /one aggregate answer/);
assert.match(html, /No makes the whole expression false, not necessarily every condition/);
assert.match(html, /Current facts are evaluated when the player answers/);
assert.match(html, /no promise to keep cards, spice or forces afterward/);
assert.match(html, /App limits: 8 terms per group, 16 total clauses/);
assert.match(html, /not additional printed rules/);
assert.ok(html.includes(`${leader.name} is your traitor`));
assert.match(html, /Cheap Hero \/ Heroine is your traitor/);
assert.doesNotMatch(html, /Confirm question|Acknowledge/);
}
});
void test('all current-fact controls and actual Ask callbacks avoid opponent private holdings or expected answers', () => {
const game = projected();
for (const player of game.players) for (const field of [
'hand', 'traitors', 'faceDancers', 'spice', 'leaders', 'forces', 'handLimit', 'prediction', 'noField',
]) Object.defineProperty(player, field, { get() { throw Error(`Current fact controls read private ${field}`); } });
for (const field of ['truthAnswer', 'truthKnowledge', 'truthHistory'])
Object.defineProperty(game, field, { get() { throw Error(`Current fact controls read ${field}`); } });
const facts: TruthFact[] = [
hand(), spice(5), { kind: 'handCount', name: 'Snooper', compare: 'eq', value: 1 },
{ kind: 'handInventory', category: 'weapon', compare: 'gte', value: 2 },
{ kind: 'traitor', leader: game.allLeaders[0].id }, { kind: 'faceDancer', leader: CHEAP_HERO_TRAITOR },
{ kind: 'prediction', field: 'faction', faction: 'atreides' },
{ kind: 'prediction', field: 'turn', compare: 'eq', value: 2 },
{ kind: 'stormDial', compare: 'lte', value: 20 }, { kind: 'stormForecast', compare: 'gte', value: 1 },
{ kind: 'noFieldValue', value: 3 },
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'eq', value: 4 },
];
for (const fact of facts) {
const question = factQuestion(fact), ask = askTree(game, question);
assert.equal(ask.button.props.disabled, false, fact.kind);
assert.doesNotThrow(ask.html);
click(ask.button);
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question }]);
}
});
+232
View File
@@ -0,0 +1,232 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { TruthFact } from '../game/truthtrance';
import {
createTruthFactExpressionFixture as fixture,
actTruthFactExpression as act,
truthFactExpressionClean as clean,
truthFactExpressionPlayer as player,
truthFactExpressionInventory as inventory,
truthFactExpressionFacts,
truthFactExpressionScenario as scenario,
truthFactExpressionNestedScenario as nestedScenario,
declareTruthFactExpression as declare,
passTruthFactExpressionPriority as passPriority,
askTruthFactExpression as ask,
answerTruthFactExpression as answer,
finishTruthFactExpression as finish,
type TruthFactExpressionFixture,
} from './fixture-truth-fact-expression';
function reject(game: Game, actor: string, action: Action, message?: RegExp) {
const before = structuredClone(game);
const run = () => applyAction(game, actor, action);
if (message) assert.throws(run, message);
else assert.throws(run);
assert.deepEqual(game, before);
}
/** Only the actual interrupted Storm controls, not a recovery snapshot. */
function stormControls(game: Game) {
return {
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
order: game.order, ready: game.ready, storm: game.storm,
stormPending: game.stormPending, stormDials: game.stormDials, stormDialers: game.stormDialers,
stormMovementSource: game.stormMovementSource,
response: game.response ?? null, decision: game.decision ?? null,
phaseOpening: game.phaseOpening ?? null, pendingKarama: game.pendingKarama ?? null,
};
}
function aggregateOnly(game: Game, f: TruthFactExpressionFixture, expected: 'yes' | 'no') {
assert.equal(game.truthtrance?.stage, 'answer');
assert.equal(viewGame(game, f.target).truthAnswer, expected);
for (const other of game.players.filter(candidate => candidate.id !== f.target))
assert.equal(viewGame(game, other.id).truthAnswer, null);
}
function completed(game: Game, f: TruthFactExpressionFixture, fact: TruthFact, expected: 'yes' | 'no') {
assert.ok(clean(game)); assert.equal(game.truthtrance, null);
assert.deepEqual(stormControls(game), stormControls(f.source));
assert.deepEqual(player(game, f.target).hand, player(f.source, f.target).hand);
assert.equal(player(game, f.target).spice, 10); assert.equal(player(game, f.target).reserves, 20);
assert.equal(game.discard.length, f.source.discard.length + 1);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.asker).hand.some(card => card.id === f.card), false);
assert.deepEqual(inventory(game), f.originalCards);
assert.equal(game.truthHistory!.length, (f.source.truthHistory?.length ?? 0) + 1);
assert.deepEqual(game.truthHistory!.at(-1), {
turn: 1, phase: 0, asker: f.asker,
question: { kind: 'fact', target: f.target, fact }, answer: expected,
});
}
void test('compound facts begin with original Basic cards and native Storm dials plus a real nonempty Ready list', () => {
const f = fixture();
assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.deepEqual(f.game.players.map(candidate => candidate.faction), ['atreides', 'emperor', 'harkonnen', 'guild']);
assert.equal(f.sourceActionCount, f.base.sourceActionCount + 3);
assert.equal(f.actions.length, f.sourceActionCount);
assert.deepEqual(f.stormSteps, [
...f.game.stormDialers.map(actor => ({ actor, action: { type: 'stormDial', amount: 0 } })),
{ actor: f.target, action: { type: 'ready' } },
]);
assert.equal(f.beforeStormControls.stormPending, null); assert.deepEqual(f.beforeStormControls.ready, []);
assert.deepEqual(f.game.ready, [f.target]); assert.equal(f.game.phase, 0); assert.equal(f.game.turn, 1);
assert.equal(f.game.response ?? null, null); assert.equal(f.game.truthtrance ?? null, null);
assert.equal(f.game.truthHistory?.length ?? 0, 0);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), [f.card]);
assert.deepEqual(player(f.game, f.target).hand.map(card => card.name), ['Baliset']);
assert.ok(player(f.game, f.counterHolder).hand.some(card => card.id === f.karama));
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
assert.deepEqual(f.fact, { kind: 'and', terms: [
{ kind: 'spice', compare: 'gte', value: 10 },
{ kind: 'handCount', name: 'Baliset', compare: 'gte', value: 1 },
{ kind: 'handCount', name: 'Shield', compare: 'eq', value: 0 },
] });
});
void test('original three through eight clause AND and OR questions enforce both truthful aggregate answers', () => {
const f = fixture();
for (const kind of ['and', 'or'] as const)
for (const count of [3, 4, 5, 6, 7, 8])
for (const expected of ['yes', 'no'] as const) {
const selected = scenario(kind, count, expected), asked = ask(f, selected.fact);
assert.equal(asked.actions.length, 5, 'One declaration, three other-seat priority passes and one question');
assert.deepEqual(stormControls(asked.game), stormControls(f.source));
aggregateOnly(asked.game, f, expected);
reject(asked.game, f.target, { type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes' }, /truthfully/);
reject(asked.game, f.target, { type: 'truthAnswer', answer: 'unknown' }, /truthfully/);
const done = answer(asked.game, expected);
completed(done, f, selected.fact, expected);
}
});
void test('mixed nested AND inside OR and OR inside AND retain their public question and target-only aggregate', () => {
const f = fixture();
for (const kind of ['and', 'or'] as const)
for (const expected of ['yes', 'no'] as const) {
const selected = nestedScenario(kind, expected), asked = ask(f, selected.fact);
assert.deepEqual(asked.game.truthtrance!.question, { kind: 'fact', target: f.target, fact: selected.fact });
aggregateOnly(asked.game, f, expected);
const done = answer(asked.game, expected);
completed(done, f, selected.fact, expected);
}
});
void test('the original holder waits for real priority and one answer discards once before the same Storm Ready control resumes', () => {
const f = fixture(), declared = declare(f);
assert.deepEqual(declared.game.truthtrance!.queue, [{ player: f.asker, card: f.card }]);
assert.deepEqual(declared.game.truthtrance!.passed, [f.asker]);
assert.deepEqual(stormControls(declared.game), stormControls(f.source));
reject(declared.game, f.asker, f.askStep.action, /priority/);
let game = passPriority(declared.game, declared.actions);
assert.equal(declared.actions.length, 4); assert.equal(game.truthtrance!.stage, 'ask');
reject(game, f.target, f.askStep.action, /holder/);
reject(game, f.asker, { type: 'truthPass' });
game = act(game, f.asker, f.askStep.action, declared.actions);
aggregateOnly(game, f, 'yes');
reject(game, f.asker, f.answerStep.action, /questioned player/);
reject(game, f.guild, f.answerStep.action, /questioned player/);
reject(game, f.target, { type: 'truthAnswer', answer: 'no' }, /truthfully/);
reject(game, f.target, { type: 'truthAnswer', answer: 'unknown' }, /truthfully/);
reject(game, f.asker, f.continuationStep.action);
assert.equal(game.discard.filter(card => card.id === f.card).length, 0);
game = answer(game, 'yes', declared.actions);
assert.equal(declared.actions.length, 6);
completed(game, f, f.fact, 'yes');
reject(game, f.target, f.answerStep.action);
reject(game, f.asker, f.askStep.action);
const continued = act(game, f.continuationStep.actor, f.continuationStep.action, declared.actions);
assert.equal(declared.actions.length, 7);
assert.equal(continued.phase, 0); assert.equal(continued.turn, 1);
assert.deepEqual(continued.ready, [f.target, f.asker]);
assert.equal(continued.response ?? null, null); assert.ok(clean(continued));
assert.equal(continued.discard.filter(card => card.id === f.card).length, 1);
assert.equal(continued.truthHistory!.length, 1);
assert.deepEqual(inventory(continued), f.originalCards);
});
void test('complete server validation rejects a bad third clause even when the first clause determines the aggregate', () => {
const f = fixture(), declared = declare(f), game = passPriority(declared.game);
const { trueFacts, falseFacts } = truthFactExpressionFacts();
const invalid = [
{ kind: 'or', terms: [trueFacts[0], falseFacts[0], { kind: 'spice', compare: 'gte', value: -1 }] },
{ kind: 'and', terms: [falseFacts[0], trueFacts[0],
{ kind: 'handCount', name: 'Not an original card', compare: 'eq', value: 0 }] },
{ kind: 'or', terms: [trueFacts[0], falseFacts[0],
{ kind: 'and', terms: [trueFacts[0], { kind: 'handInventory', category: 'unknown', compare: 'eq', value: 0 }] }] },
];
for (const fact of invalid)
reject(game, f.asker, { type: 'truthAsk', question: { kind: 'fact', target: f.target, fact } });
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
assert.deepEqual(stormControls(game), stormControls(f.source));
assert.deepEqual(game.discard, f.source.discard); assert.deepEqual(inventory(game), f.originalCards);
});
void test('original current facts accept the existing sixteen-node and four-depth boundaries', () => {
const f = fixture(), { trueFacts } = truthFactExpressionFacts();
const sixteen: TruthFact = { kind: 'and', terms: [
{ kind: 'or', terms: trueFacts.slice(0, 6) },
{ kind: 'and', terms: trueFacts.slice(0, 7) },
] };
let depthFour: TruthFact = trueFacts[0];
for (let depth = 0; depth < 4; depth++) depthFour = { kind: 'and', terms: [trueFacts[1], depthFour] };
for (const [id, fact] of [['sixteen-nodes', sixteen], ['depth-four', depthFour]] as const) {
const done = finish(f, { id, fact, answer: 'yes' });
assert.equal(done.actions.length, 6);
completed(done.game, f, fact, 'yes');
}
});
void test('a ninth group child, seventeenth node and fifth depth reject before the original card is consumed', () => {
const f = fixture(), game = passPriority(declare(f).game);
const { trueFacts } = truthFactExpressionFacts();
let depthFive: TruthFact = trueFacts[0];
for (let depth = 0; depth < 5; depth++) depthFive = { kind: 'and', terms: [trueFacts[1], depthFive] };
const invalid: [TruthFact, RegExp][] = [
[{ kind: 'and', terms: [] }, /two to eight/],
[{ kind: 'or', terms: [trueFacts[0]] }, /two to eight/],
[{ kind: 'or', terms: [...trueFacts, trueFacts[0]] }, /two to eight/],
[{ kind: 'and', terms: [
{ kind: 'or', terms: trueFacts.slice(0, 7) },
{ kind: 'and', terms: trueFacts.slice(0, 7) },
] }, /16 clauses/],
[depthFive, /four levels/],
];
for (const [fact, message] of invalid)
reject(game, f.asker, { type: 'truthAsk', question: { kind: 'fact', target: f.target, fact } }, message);
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
assert.deepEqual(game.discard, f.source.discard);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal profiles pass real priority and answer new compound questions from the private aggregate', () => {
const f = fixture();
for (const difficulty of DIFFICULTIES) {
const declared = declare(f), priorityView = viewGame(declared.game, f.target);
priorityView.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const priorityAction = botActions(priorityView);
assert.deepEqual(priorityAction, [{ type: 'truthPass' }]);
const passed = act(declared.game, f.target, priorityAction[0]);
assert.equal(passed.truthtrance!.passed.includes(f.target), true);
assert.equal(passPriority(passed).truthtrance!.stage, 'ask');
for (const selected of [scenario('and', 8, 'yes'), scenario('or', 8, 'no'),
nestedScenario('and', 'no'), nestedScenario('or', 'yes')]) {
const asked = ask(f, selected.fact), view = viewGame(asked.game, f.target);
view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const before = structuredClone(view), actions = botActions(view);
assert.deepEqual(view, before);
assert.deepEqual(actions, [{ type: 'truthAnswer', answer: selected.answer }]);
assert.deepEqual(botActions(viewGame(asked.game, f.asker)), []);
assert.deepEqual(botActions(viewGame(asked.game, f.guild)), []);
aggregateOnly(asked.game, f, selected.answer);
const done = act(asked.game, f.target, actions[0]);
completed(done, f, selected.fact, selected.answer);
}
}
});
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TruthFact } from '../game/truthtrance';
import {
truthFactExpressionError as error, truthFactExpressionSize as size,
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
TRUTH_FACT_SIZE_ERROR, TRUTH_FACT_GROUP_ERROR,
} from '../game/truth-fact-expression';
const leaf = (): TruthFact => ({ kind: 'hand', name: 'Shield' });
const group = (kind: 'and' | 'or', count: number): TruthFact =>
({ kind, terms: Array.from({ length: count }, leaf) });
void test('fact structure counts group containers and retains every clause without mutation', () => {
const fact: TruthFact = { kind: 'or', terms: [group('and', 3), group('or', 8)] };
const before = structuredClone(fact);
assert.deepEqual(size(fact), { nodes: 14, depth: 2 });
assert.equal(error(fact), null);
assert.deepEqual(fact, before);
assert.deepEqual(size(leaf()), { nodes: 1, depth: 0 });
assert.equal(error(leaf()), null);
});
void test('standalone AND and OR retain the existing two-through-eight term envelope', () => {
for (const kind of ['and', 'or'] as const) {
for (const count of [2, 3, 8]) assert.equal(error(group(kind, count)), null);
for (const count of [0, 1, 9]) assert.equal(error(group(kind, count)), TRUTH_FACT_GROUP_ERROR);
}
assert.equal(TRUTH_FACT_MAX_TERMS, 8);
});
void test('nested fact editing shares the parser total-node and depth boundaries', () => {
const maximum: TruthFact = { kind: 'and', terms: [group('and', 8), group('or', 5)] };
assert.deepEqual(size(maximum), { nodes: TRUTH_FACT_MAX_NODES, depth: 2 });
assert.equal(error(maximum), null);
assert.equal(error({ kind: 'and', terms: [group('and', 8), group('or', 6)] }), TRUTH_FACT_SIZE_ERROR);
let nested = leaf();
for (let depth = 1; depth <= TRUTH_FACT_MAX_DEPTH + 1; depth++) {
nested = { kind: depth % 2 ? 'and' : 'or', terms: [nested, leaf()] };
assert.equal(size(nested).depth, depth);
assert.equal(error(nested), depth <= TRUTH_FACT_MAX_DEPTH ? null : TRUTH_FACT_SIZE_ERROR);
}
});
void test('structural validation does not replace entitled leaf eligibility checks', () => {
const malformedGroup: TruthFact = { kind: 'or', terms: [leaf(), group('and', 1)] };
assert.equal(error(malformedGroup), TRUTH_FACT_GROUP_ERROR);
const invalidLeaf: TruthFact = { kind: 'and', terms: [leaf(), leaf(), { kind: 'spice', compare: 'gte', value: -1 }] };
assert.equal(error(invalidLeaf), null, 'The common leaf validator owns numeric and target-specific eligibility');
});