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 { BattlePromises } from '../components/battle-promises'; import { MixedBattleFields, mixedBattleInputError } from '../components/mixed-battle-fields'; import { MixedBattlePromises } from '../components/mixed-battle-promises'; import { TruthFactFields } from '../components/mixed-shipment-fields'; import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine'; import { mixedActionExpressionSize, type MixedActionExpression } from '../game/mixed-action-question'; import type { MixedBattleActionClaim } from '../game/mixed-battle-question'; import { CHEAP_HERO_TRAITOR } from '../game/traitors'; import type { TruthFact } from '../game/truthtrance'; import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names'; type Expression = MixedActionExpression; type ElementProps = { children?: ReactNode; [key: string]: unknown }; type Element = ReactElement; const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value }); const action = (value: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 0 }): Expression => ({ kind: 'action', action: value }); const dial = (value = 0): Expression => action({ kind: 'dial', compare: 'eq', value }); const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [dial(3), action({ kind: 'leader', leader: 'named-leader' })] }] }); const plan = (): Plan => ({ dial: 3, support: 0, leader: 'named-leader', weapon: null, defense: null, kwisatz: false }); function elements(node: ReactNode): Element[] { if (Array.isArray(node)) return node.flatMap(elements); if (!isValidElement(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-battle-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-battle-${path}-${field}`); (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); } function amount(tree: ReactNode, path: string, valueAsNumber: number) { const input = find(tree, 'id', `truth-mixed-battle-${path}-amount`); (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-battle-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 prove fields, private display and callback guards. * Source-earned questions, plan legality and reveal lifecycle have native tests. */ function projected(): GameView { const source = createGame('TRUTHMIXEDBATTLECONTROLS', newPlayer('p', 'Respondent', 'emperor'), false); joinGame(source, newPlayer('a', 'Asker', 'atreides')); const game = viewGame(source, 'p'); game.status = 'playing'; game.turn = 2; game.phase = 6; game.allLeaders = [ { id: 'named-leader', name: 'Named Commander', faction: 'emperor', strength: 3 }, { id: 'foreign-leader', name: 'Foreign Commander', faction: 'atreides', strength: 5 }, ]; game.mixedBattlePromises = [{ event: 'controlled-mixed-battle-question', player: 'p', asker: 'a', turn: 2, battle: 'controlled-native-battle', territory: 'arrakeen', mixed: claim(), answer: true, fulfilled: false, released: false }]; game.battle = { event: 'controlled-native-battle', territory: 'arrakeen', attacker: 'p', defender: 'a', revealed: false, submitted: [], plans: {}, truthPromises: [], compliantPlan: plan(), compliantPreparation: null, } as unknown as NonNullable; 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: () => MixedBattleFields({ value, game, target, disabled, onChange(next) { value = next; changes++; } }), html: () => renderToStaticMarkup(createElement(MixedBattleFields, { value, game, target, disabled, onChange() {} })), error: () => mixedBattleInputError(value, game, target), }; } function assistance(game = projected(), busy = false) { const filled: Plan[] = [], sent: Action[] = []; const props = { game, busy, fill(value: Plan) { filled.push(value); }, act(value: Action) { sent.push(value); } }; return { props, filled, sent, tree: () => BattlePromises(props), html: () => renderToStaticMarkup(createElement(BattlePromises, props)) }; } function nativeButton(tree: ReactNode, text: string) { const button = elements(tree).find(element => typeof element.props.onClick === 'function' && element.props.children === text); assert.ok(button, text); return button; } const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedBattlePromises, { game })); void test('controlled mixed battle editor shares the raw sixteen-node and eight-term budgets across fact and plan groups', () => { const full: Expression = { kind: 'and', terms: [fact({ kind: 'or', terms: Array.from({ length: 8 }, (): TruthFact => ({ kind: 'hand', name: 'Shield' })) }), { kind: 'or', terms: Array.from({ length: 5 }, (_, index) => dial(index)) }] }; const e = editor(full); assert.deepEqual(mixedActionExpressionSize(e.value), { leaves: 13, nodes: 16, depth: 2, maxTerms: 8 }); assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 total clauses/); assert.match(e.html(), /8 conditions per group/); for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'and-1.2.1', 'fact-or-1.1.1', 'fact-add-1.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, full); amount(e.tree(), '1.2.5', 7.5); assert.equal(e.error(), null); const ids = [...e.html().matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); assert.equal(new Set(ids).size, ids.length); const wide = editor({ kind: 'and', terms: [fact(), ...Array.from({ length: 7 }, () => dial())] }); assert.equal(wide.error(), null); assert.equal(mixedActionExpressionSize(wide.value).nodes, 9); for (const key of ['add-fact-1', 'add-action-1', 'nested-or-1']) { assert.equal(control(wide.tree(), key).props.disabled, true); click(control(wide.tree(), key)); } assert.equal(wide.changes, 0); assert.ok(mixedBattleInputError({ kind: 'and', terms: [fact(), ...Array.from({ length: 8 }, () => dial())] }, projected(), 'p')); assert.ok(mixedBattleInputError({ kind: 'and', terms: [full, dial()] }, projected(), 'p')); }); void test('controlled mixed and embedded fact edits preserve branches, collapse removals and stop a fifth grouping level', () => { const e = editor({ kind: 'and', terms: [fact(), dial(3)] }); click(control(e.tree(), 'fact-or-1.1')); editFact(e.tree(), '1.1.2', { kind: 'spice', compare: 'gte', value: 7 }); select(e.tree(), '1.1', 'fact-meaning', 'and'); click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and'); select(e.tree(), '1.2.1', 'kind', 'weapon'); select(e.tree(), '1.2.1', 'card', 'Shield'); assert.equal(e.error(), null); click(control(e.tree(), 'remove-1.2.2')); click(control(e.tree(), 'fact-remove-1.1.2')); assert.deepEqual(e.value, { kind: 'and', terms: [fact(), action({ kind: 'weapon', name: 'Shield' })] }); select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error()); select(e.tree(), '1.2', 'timing', 'action'); assert.equal(e.error(), null); let nested = fact(); for (let depth = 0; depth < 4; depth++) nested = { kind: 'and', terms: [nested, dial()] }; const deep = editor(nested); assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/); for (const key of ['or-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); editFact(deep.tree(), '1.1.1.1.1', { kind: 'hand', name: 'Poison Blade' }); assert.equal(deep.changes, 1); assert.ok(mixedBattleInputError({ kind: 'and', terms: [nested, dial()] }, projected(), 'p')); }); void test('controlled scalar battle drafts remain repairable and reject metadata, grouped actions and invalid native values', () => { for (const invalid of [NaN, -1, 0.25, 40.5, Infinity]) { const e = editor({ kind: 'and', terms: [fact(), dial()] }); amount(e.tree(), '1.2', invalid); assert.ok(e.error()); assert.match(e.html(), /role="alert"/); click(control(e.tree(), 'or-1.2')); assert.deepEqual(e.value, { kind: 'and', terms: [fact(), { kind: 'or', terms: [dial(invalid), fact()] }] }); amount(e.tree(), '1.2.1', 0.5); assert.equal(e.error(), null); select(e.tree(), '1.2.1', 'kind', 'support'); amount(e.tree(), '1.2.1', 0.5); assert.ok(e.error()); amount(e.tree(), '1.2.1', 1); assert.equal(e.error(), null); } for (const invalid of [{ kind: 'and', terms: [{ kind: 'dial', compare: 'eq', value: 1 }] }, { kind: 'dial', compare: 'eq', value: 1, battle: 'other' }, { kind: 'leader', leader: 'unknown' }, { kind: 'weapon', name: 'Unknown card' }, { kind: 'kwisatz', use: 'yes' }]) assert.ok(mixedBattleInputError({ kind: 'and', terms: [fact(), { kind: 'action', action: invalid }] }, projected(), 'p')); const frozen = editor({ kind: 'and', terms: [fact(), dial()] }, true), before = structuredClone(frozen.value); for (const key of ['and-1', 'nested-or-1', 'remove-1.2', 'fact-or-1.1']) click(control(frozen.tree(), key)); amount(frozen.tree(), '1.2', 3); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'kind', 'leader'); editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 }); assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before); }); void test('controlled plan choices use public names and leaders without consulting either hand, affordability or a fixed plan', () => { const game = projected(); for (const player of game.players) for (const key of ['hand', 'leaders', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'tanks', 'elites', 'kwisatz']) Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); for (const key of ['battle', 'deck', 'discard', 'truthMixedBattleAnswers']) Object.defineProperty(game, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true, }); const e = editor({ kind: 'and', terms: [fact(), action({ kind: 'leader', leader: 'foreign-leader' })] }, false, game); assert.equal(e.error(), null); const html = e.html(); assert.match(html, /Named Commander/); assert.match(html, /Foreign Commander/); assert.match(html, /Cheap Hero \/ Heroine/); assert.match(html, /No leader or hero/); assert.match(html, /actual plan in this battle/); assert.match(html, /already fixed, sealed or revealed/); assert.match(html, /No alternate route is forced/); select(e.tree(), '1.2', 'leader', CHEAP_HERO_TRAITOR); assert.equal(e.error(), null); select(e.tree(), '1.2', 'leader', ''); assert.equal(e.error(), null); select(e.tree(), '1.2', 'kind', 'weapon'); const card = find(e.tree(), 'id', 'truth-mixed-battle-1.2-card'); assert.deepEqual(elements(card).filter(element => element.type === 'option').map(element => element.props.children), ['Empty slot', ...TRUTH_CARD_NAMES]); select(e.tree(), '1.2', 'card', 'Shield'); assert.equal(e.error(), null); select(e.tree(), '1.2', 'kind', 'kwisatz'); select(e.tree(), '1.2', 'kwisatz', 'no'); assert.deepEqual(e.value, { kind: 'and', terms: [fact(), action({ kind: 'kwisatz', use: false })] }); }); void test('controlled mixed battle history shows only own original aggregate answers and remains after the battle', () => { const game = projected(); game.mixedBattlePromises.push( { ...game.mixedBattlePromises[0], event: 'no-question', answer: false }, { ...game.mixedBattlePromises[0], event: 'complete-question', fulfilled: true }, { ...game.mixedBattlePromises[0], event: 'released-question', released: true }, { ...game.mixedBattlePromises[0], event: 'foreign-question', player: 'a', mixed: { kind: 'or', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), dial(40)] } }, ); for (const promise of game.mixedBattlePromises) for (const key of ['binding', 'frozen', 'residual', 'factAnswers', 'compiled']) Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); game.battle = null; const html = markup(game); 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, /Named Commander/); assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/); assert.match(html, /Only the aggregate answer is shared/); assert.match(html, /No negates the whole expression/); assert.match(html, /An open commitment completes when both plans are revealed/); assert.match(html, /sealing your own plan does not finish it/); assert.match(html, /No alternate route is forced/); assert.doesNotMatch(html, /Poison Blade|controlled-native-battle|controlled-mixed-battle-question|Fill these plan choices|Complete next preparation/); assert.equal(MixedBattlePromises({ game: { ...game, me: 'missing' } }), null); }); void test('controlled mixed-only immediate completion uses the single native Fill control and keeps pure behavior', () => { const guide = assistance(), original = guide.props.game.battle!.compliantPlan!; const button = nativeButton(guide.tree(), 'Fill these plan choices'); assert.equal(button.props.disabled, false); click(button); assert.equal(guide.filled[0], original); assert.equal(guide.sent.length, 0); assert.match(guide.html(), /Review a compliant plan/); assert.doesNotMatch(guide.html(), /you hold Shield/); guide.props.game.battle!.preparation = { kind: 'voice', owner: 'a', beneficiary: 'p' }; click(button); assert.equal(guide.filled.length, 2); const pure = assistance(); pure.props.game.mixedBattlePromises = []; pure.props.game.battle!.truthPromises = [{ player: 'p', asker: 'a', answer: true, claim: { kind: 'dial', compare: 'gte', value: 3 } }]; click(nativeButton(pure.tree(), 'Fill these plan choices')); assert.equal(pure.filled[0], pure.props.game.battle!.compliantPlan); assert.match(pure.html(), /Truthtrance commitments/); const noGuide = projected(); noGuide.battle!.compliantPlan = null; assert.equal(BattlePromises({ game: noGuide, busy: false, fill() {}, act() {} }), null); for (const state of ['sealed', 'revealed', 'fulfilled'] as const) { const current = assistance(); if (state === 'sealed') current.props.game.battle!.submitted.push('p'); else if (state === 'revealed') current.props.game.battle!.revealed = true; else current.props.game.mixedBattlePromises[0].fulfilled = true; assert.doesNotMatch(current.html(), /Fill these plan choices/); } }); void test('controlled saved Fill callbacks reject stale owner, source, battle, turn, status, guide and quiet-window state', () => { const changes: ((game: GameView) => void)[] = [ game => { game.me = 'a'; }, game => { game.turn++; }, game => { game.phase = 5; }, game => { game.status = 'setup'; }, game => { game.battle!.event = 'later-battle'; }, game => { game.battle!.territory = 'carthag'; }, game => { game.battle = { ...game.battle! }; }, game => { game.battle!.revealed = true; }, game => { game.battle!.submitted.push('p'); }, game => { game.mixedBattlePromises[0].fulfilled = true; }, game => { game.mixedBattlePromises[0].released = true; }, game => { game.mixedBattlePromises[0].player = 'a'; }, game => { game.mixedBattlePromises[0].event = 'later-question'; }, game => { game.mixedBattlePromises[0].answer = false; }, game => { game.mixedBattlePromises[0].asker = 'p'; }, game => { game.mixedBattlePromises[0].mixed = claim(); }, game => { game.mixedBattlePromises.push({ ...game.mixedBattlePromises[0], event: 'another-question' }); }, game => { game.mixedBattlePromises[0].battle = 'later-battle'; }, game => { game.mixedBattlePromises = []; }, game => { game.battle!.compliantPlan = plan(); }, game => { game.battle!.compliantPlan!.dial = 4; }, game => { game.battle!.compliantPreparation = { plan: plan(), actions: [], waitingForIncome: true }; }, game => { game.players.find(player => player.id === 'p')!.autopilot = 'Easy'; }, game => { game.truthtrance = { stage: 'ask', queue: [], passed: [], question: null }; }, game => { game.response = {} as NonNullable; }, game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, game => { game.phaseOpening = {} as NonNullable; }, game => { game.automaticContinuationPending = true; }, game => { game.roomControl = { paused: true } as NonNullable; }, game => { game.roomControl = { closed: true } as NonNullable; }, ]; for (const change of changes) { const guide = assistance(), button = nativeButton(guide.tree(), 'Fill these plan choices'); change(guide.props.game); click(button); assert.equal(guide.filled.length, 0, change.toString()); } for (const change of [ (guide: ReturnType) => { guide.props.busy = true; }, (guide: ReturnType) => { guide.props.game = projected(); }, (guide: ReturnType) => { guide.props.fill = () => assert.fail('Do not use a replaced Fill callback'); }, ]) { const guide = assistance(), button = nativeButton(guide.tree(), 'Fill these plan choices'); change(guide); click(button); assert.equal(guide.filled.length, 0); } }); void test('controlled native preparation forwards the exact first card action and guards current preparation without auto-sealing', () => { function preparationGuide() { const guide = assistance(), battle = guide.props.game.battle!; battle.compliantPlan = null; battle.compliantPreparation = { plan: plan(), actions: [{ type: 'card', card: 'controlled-ghola', leader: 'named-leader' }], waitingForIncome: false }; return guide; } const guide = preparationGuide(), original = guide.props.game.battle!.compliantPreparation!.actions[0]; const button = nativeButton(guide.tree(), 'Complete next preparation'); assert.equal(button.props.disabled, false); click(button); assert.equal(guide.sent[0], original); assert.equal(guide.filled.length, 0); assert.doesNotMatch(guide.html(), /Fill these plan choices/); assert.match(guide.html(), /Play Ghola to revive Named Commander/); for (const change of [ (game: GameView) => { game.battle!.compliantPreparation = { ...game.battle!.compliantPreparation! }; }, (game: GameView) => { game.battle!.compliantPreparation!.actions[0] = { ...original }; }, (game: GameView) => { game.battle!.compliantPreparation!.actions[0].card = 'other-card'; }, (game: GameView) => { game.battle!.submitted.push('p'); }, (game: GameView) => { game.mixedBattlePromises[0].fulfilled = true; }, ]) { const fresh = preparationGuide(), saved = nativeButton(fresh.tree(), 'Complete next preparation'); change(fresh.props.game); click(saved); assert.equal(fresh.sent.length, 0); } const waiting = preparationGuide(); waiting.props.game.battle!.compliantPreparation = { plan: plan(), actions: [], waitingForIncome: true }; assert.match(waiting.html(), /Revival income must resolve/); assert.doesNotMatch(waiting.html(), /Complete next preparation|Fill these plan choices/); }); void test('controlled original private preparation remains usable without pure or mixed Truthtrance records', () => { const game = projected(); game.mixedBattlePromises = []; const battle = game.battle!; battle.truthPromises = []; battle.compliantPlan = null; const next: Action = { type: 'card', card: 'controlled-original-ghola', leader: 'named-leader' }; battle.compliantPreparation = { plan: plan(), actions: [next], waitingForIncome: false }; const guide = assistance(game), button = nativeButton(guide.tree(), 'Complete next preparation'); assert.equal(button.props.disabled, false); click(button); assert.equal(guide.sent[0], next); assert.equal(guide.filled.length, 0); assert.match(guide.html(), /Preparation ยท private/); assert.doesNotMatch(guide.html(), /Fill these plan choices/); });