From b63a60da64f5de531c3e9708d97e9e235a169cbb Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:25:54 +1100 Subject: [PATCH] Combine current facts with future ground movement --- components/game-table.tsx | 2 + components/mixed-movement-fields.tsx | 181 +++++++++++++ components/mixed-movement-promises.tsx | 59 +++++ components/movement-expression-promises.tsx | 6 +- components/truthtrance.tsx | 27 +- docs/CURRENT_STATUS.md | 32 +++ docs/IMPLEMENTATION_STATUS.md | 47 ++++ docs/RULE_DECISIONS.md | 14 + docs/TRUTHTRANCE_MIXED_MOVEMENT.md | 122 +++++++++ docs/TRUTHTRANCE_MOVEMENT_EXPRESSIONS.md | 4 + game/bots.ts | 4 + game/engine.ts | 73 +++++- game/mixed-action-question.ts | 116 ++++++++ game/reference.ts | 27 ++ game/truthtrance.ts | 27 ++ tests/fixture-truth-mixed-movement.ts | 79 ++++++ tests/mixed-action-question.test.ts | 259 ++++++++++++++++++ tests/truth-mixed-movement-controls.test.tsx | 256 ++++++++++++++++++ tests/truth-mixed-movement-runtime.test.ts | 262 +++++++++++++++++++ 19 files changed, 1590 insertions(+), 7 deletions(-) create mode 100644 components/mixed-movement-fields.tsx create mode 100644 components/mixed-movement-promises.tsx create mode 100644 docs/TRUTHTRANCE_MIXED_MOVEMENT.md create mode 100644 game/mixed-action-question.ts create mode 100644 tests/fixture-truth-mixed-movement.ts create mode 100644 tests/mixed-action-question.test.ts create mode 100644 tests/truth-mixed-movement-controls.test.tsx create mode 100644 tests/truth-mixed-movement-runtime.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 469650b3..405d2568 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -2,6 +2,7 @@ import { TraitorDecisionPromises } from './traitor-decision-promises'; import { MovementPromises } from './movement-promises'; import { MovementExpressionPromises } from './movement-expression-promises'; +import { MixedMovementPromises } from './mixed-movement-promises'; import { HomeworldEcazDuke } from './homeworld-ecaz-duke'; import { integerInputValue } from '../lib/integer-input'; import { normalAuctionHandBlock } from '@/game/normal-auction'; @@ -1989,6 +1990,7 @@ export function GameTable({ + {!reactionOwnsControls && g.semutaReaction && } {g.karamaBlocked &&

{g.karamaBlocked}

} {g.karamaPurposeBlocks?.map(({purpose, message}) =>

{message}

)} diff --git a/components/mixed-movement-fields.tsx b/components/mixed-movement-fields.tsx new file mode 100644 index 00000000..e39ad057 --- /dev/null +++ b/components/mixed-movement-fields.tsx @@ -0,0 +1,181 @@ +'use client'; + +import type { ReactElement } from 'react'; +import type { GameView } from '@/game/engine'; +import { TERRITORIES } from '@/game/board'; +import { parseMixedActionExpression, type MixedActionExpression } from '@/game/mixed-action-question'; +import { MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES } from '@/game/movement-expression-promises'; +import { parseMovementClaim, type MovementClaim } from '@/game/movement-promises'; +import type { TruthFact } from '@/game/truthtrance'; +import { truthFactExpressionError } from '@/game/truth-fact-expression'; +import { TruthFactFields, invalidTruthFact } from './mixed-shipment-fields'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +type MixedMovementExpression = MixedActionExpression; +const newFact = (): MixedMovementExpression => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } }); +const newMove = (): MixedMovementExpression => ({ kind: 'action', action: { territory: 'carthag', minimum: 1 } }); +const newFactLeaf = (): TruthFact => ({ kind: 'hand', name: 'Shield' }); +function factSize(fact: TruthFact): { leaves: number; depth: number } { + if (fact.kind !== 'and' && fact.kind !== 'or') return { leaves: 1, depth: 0 }; + const sizes = fact.terms.map(factSize); + return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) }; +} +function sizeOf(node: MixedMovementExpression): { leaves: number; depth: number } { + if (node.kind === 'fact') return factSize(node.fact); + if (node.kind === 'action') return { leaves: 1, depth: 0 }; + const sizes = node.terms.map(sizeOf); + return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) }; +} +function structuralError(node: MixedMovementExpression): string | null { + const size = sizeOf(node); + if (size.leaves > MOVEMENT_EXPRESSION_MAX_LEAVES || size.depth > MOVEMENT_EXPRESSION_MAX_DEPTH) + return 'Use at most sixteen conditions and four levels of grouping.'; + if (node.kind === 'fact') return truthFactExpressionError(node.fact); + if (node.kind === 'action') return null; + if (node.terms.length < 2) return 'Join two or more conditions with AND or OR.'; + for (const term of node.terms) { + const error = structuralError(term); + if (error) return error; + } + return null; +} + +/** Draft feedback reuses the existing fact fields. The server remains the + * authority for current-fact eligibility and legal future movement routes. */ +export function mixedMovementInputError(value: unknown, game: GameView, target: string): string | null { + try { + parseMixedActionExpression(value, input => { + const fact = input as TruthFact; + const error = truthFactExpressionError(fact); + if (error) throw new Error(error); + if (invalidTruthFact(fact, game, target)) throw new Error('Resolve the marked current-fact fields.'); + return fact; + }, parseMovementClaim); + return null; + } catch (error) { + return error instanceof Error ? error.message : 'Choose a current fact and a future ground-movement condition.'; + } +} + +/** Preserve the complete original tree, including incomplete numeric drafts. + * Structural changes never evaluate current facts or select a future branch. */ +export function MixedMovementFields({ value, game, target, disabled, onChange }: { + value: MixedMovementExpression; + game: GameView; + target: string; + disabled: boolean; + onChange: (expression: MixedMovementExpression) => void; +}) { + const size = sizeOf(value), error = mixedMovementInputError(value, game, target); + const commit = (next: MixedMovementExpression) => { + if (!disabled && !structuralError(next)) onChange(next); + }; + const renderFact = (fact: TruthFact, path: string, replace: (next: TruthFact) => MixedMovementExpression, + remove?: () => MixedMovementExpression): ReactElement => { + const allowed = (next: TruthFact) => !disabled && !structuralError(replace(next)); + const change = (next: TruthFact) => commit(replace(next)); + const wrap = (kind: 'and' | 'or'): TruthFact => ({ kind, terms: [fact, newFactLeaf()] }); + return
+ {fact.kind === 'and' || fact.kind === 'or' ? `Current-fact group ${path}` : `Current fact ${path}`} + {fact.kind === 'and' || fact.kind === 'or' ? <> + + + {fact.terms.map((term, index) => renderFact(term, `${path}.${index + 1}`, + next => replace({ ...fact, terms: fact.terms.map((old, i) => i === index ? next : old) }), + () => { + const terms = fact.terms.filter((_, i) => i !== index); + return replace(terms.length === 1 ? terms[0] : { ...fact, terms }); + }))} + + : } +
+ {(['and', 'or'] as const).map(kind => )} + {remove && } +
+
; + }; + const render = (node: MixedMovementExpression, path: string, replace: (next: MixedMovementExpression) => MixedMovementExpression, + remove?: () => MixedMovementExpression): ReactElement => { + const id = `truth-mixed-movement-${path}`; + const allowed = (next: MixedMovementExpression) => !disabled && !structuralError(replace(next)); + const change = (next: MixedMovementExpression) => commit(replace(next)); + const wrap = (kind: 'and' | 'or'): MixedMovementExpression => ({ kind, terms: [node, node.kind === 'fact' ? newMove() : newFact()] }); + return
+ {node.kind === 'and' || node.kind === 'or' ? `Mixed group ${path} · ${node.kind.toUpperCase()}` : `Mixed condition ${path}`} + {node.kind === 'and' || node.kind === 'or' ? <> + + + {node.terms.map((term, index) => render(term, `${path}.${index + 1}`, + next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }), + () => { + const terms = node.terms.filter((_, i) => i !== index); + return replace(terms.length === 1 ? terms[0] : { ...node, terms }); + }))} +
+ + + {(['and', 'or'] as const).map(kind => { + const next: MixedMovementExpression = { ...node, terms: [...node.terms, { kind, terms: [newFact(), newMove()] }] }; + return ; + })} +
+ : <> + + + {node.kind === 'fact' ? renderFact(node.fact, path, fact => replace({ kind: 'fact', fact })) : node.kind === 'action' ? <> + + + + 20} + onChange={event => change({ ...node, action: { ...node.action, minimum: event.currentTarget.valueAsNumber } })} /> + : null} + } +
+ {(['and', 'or'] as const).map(kind => )} + {remove && } +
+
; + }; + return
+ Mixed current-fact / future ground-movement claim +

Current facts are evaluated when the player answers. They create no promise to keep cards, spice or forces afterward. + AND requires every condition; OR requires at least one. No makes the whole grouped claim false, not necessarily every condition. + Only the aggregate answer is shared; an unknown current fact can still allow a definite whole answer.

+

Each movement condition needs one actual future ground move after the answer, before your Movement turn ends this round. + Smaller moves never add together. Prior moves and shipments do not count. The claim grants no extra movement opportunity.

+

App input limits: {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions and {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping. + These are input limits, not additional printed rules.

+

{size.leaves} / {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping

+ {error &&

{error}

} + {render(value, '1', next => next)} +
; +} diff --git a/components/mixed-movement-promises.tsx b/components/mixed-movement-promises.tsx new file mode 100644 index 00000000..6e7af68a --- /dev/null +++ b/components/mixed-movement-promises.tsx @@ -0,0 +1,59 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import type { MixedActionExpression } from '@/game/mixed-action-question'; +import type { MovementClaim } from '@/game/movement-promises'; +import { liveMovementPromises } from '@/game/movement-promises'; +import { liveMovementExpressionPromises } from '@/game/movement-expression-promises'; +import { truthQuestionText } from '@/game/truthtrance'; +import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; +import { MovementExpressionPromiseGuideButton } from './movement-expression-promises'; + +const destinationName = (game: GameView, id: string) => game.combatLocations.find(location => location.id === id)?.name ?? id; +function destinations(mixed: MixedActionExpression): string[] { + const found = new Set(); + const visit = (node: MixedActionExpression): void => { + if (node.kind === 'action') found.add(node.action.territory); + else if (node.kind === 'and' || node.kind === 'or') node.terms.forEach(visit); + }; + visit(mixed); + return [...found]; +} + +/** Display only the original question, aggregate answer and physical progress. + * No current-fact evaluation or reduced future branch is projected or inferred. */ +export function MixedMovementPromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) { + const game = props.game, own = game.mixedMovementPromises.filter(promise => promise.player === game.me); + if (!own.length) return null; + const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released); + const waiting = live && (game.phase < 5 || game.phase === 5 && game.active !== game.me); + const sharedGuideElsewhere = liveMovementPromises(game.movementPromises ?? [], game.me, game.turn).length > 0 || + liveMovementExpressionPromises(game.movementExpressionPromises, game.me, game.turn).length > 0; + return
+

Your mixed movement promises

+
    {own.map(promise =>
  • + Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedMovement', target: promise.player, + turn: promise.turn, mixed: promise.mixed }, id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' + : game.allLeaders.find(leader => leader.id === id)?.name ?? id, + id => destinationName(game, id), id => game.players.find(player => player.id === id)?.name ?? id)} + {' '}Largest future ground move to each named destination: {destinations(promise.mixed).map(territory => + `${destinationName(game, territory)}: ${promise.progress.find(progress => progress.territory === territory)?.maximum ?? 0} physical forces`).join('; ')}. + {promise.released ? ' Released: this whole commitment is no longer possible.' + : promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding until your Movement turn ends this round.'} +
  • )}
+

Current facts are evaluated at the answer. They create no promise to keep cards, spice or forces afterward. + Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private. + AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately. + Legal alternatives remain available; no fixed branch is selected for you.

+

Future movement progress records the maximum physical forces in one actual move after the answer, never the sum of smaller moves. + Prior moves and shipments do not count. A shipment can prepare a move but does not fulfill a movement condition. + The original movement, shipment, preparation, payment and turn rules still apply; this claim grants no extra movement opportunity. + A paid Box search must finish before moving; its fee is paid only once. + {' '}Mixed movement rules.

+ {waiting &&

Your Movement turn is still ahead. Keep any needed forces, spice and cards available; movement controls appear on your turn. + {game.phase < 5 && ' Complete any current preparation below before confirming readiness.'} + {game.decision?.kind === 'nullentropy' && game.decision.player === game.me && ' Your already-paid Box search can finish now.'} +

} + {live && !sharedGuideElsewhere && } +
; +} diff --git a/components/movement-expression-promises.tsx b/components/movement-expression-promises.tsx index 0914d4ae..c09af684 100644 --- a/components/movement-expression-promises.tsx +++ b/components/movement-expression-promises.tsx @@ -42,7 +42,9 @@ export function MovementExpressionPromiseGuideButton(props: MovementExpressionPr Number.isSafeInteger(phase) && phase >= 0 && phase <= 5 && current.phase === phase && current.turn === turn && current.me === actor && current.movementExpressionPromiseGuide === guide && current.movementExpressionPromiseGuide.phase === phase && current.movementExpressionPromiseGuide.action === action && JSON.stringify(action) === signature && opportunity && - typeof event === 'string' && liveMovementExpressionPromises(current.movementExpressionPromises, actor, turn).some(promise => promise.event === event) && + typeof event === 'string' && (liveMovementExpressionPromises(current.movementExpressionPromises, actor, turn).some(promise => promise.event === event) || + current.mixedMovementPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn && + !promise.fulfilled && !promise.released)) && !!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || payment) && !current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; }; @@ -113,6 +115,6 @@ export function MovementExpressionPromises(props: MovementExpressionPromiseProps {game.phase < 5 && ' Complete any current preparation below before confirming readiness.'} {game.decision?.kind === 'nullentropy' && game.decision.player === game.me && ' Your already-paid Box search can finish now.'}

} - {!liveMovementPromises(game.movementPromises ?? [], game.me, game.turn).length && } + {live && !liveMovementPromises(game.movementPromises ?? [], game.me, game.turn).length && } ; } diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 5de7fe6e..142c63f9 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,5 +1,7 @@ 'use client'; +import { MixedMovementFields, mixedMovementInputError } from './mixed-movement-fields'; +import type { MixedActionExpression } from '@/game/mixed-action-question'; import { MovementExpressionFields, movementExpressionInputError } from './movement-expression-fields'; import type { MovementExpression } from '@/game/movement-expression-promises'; import { TERRITORIES } from '@/game/board'; @@ -71,7 +73,7 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); const [kind, setKind] = useState< - 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' >('fact'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); @@ -109,6 +111,11 @@ export function Truthtrance({ { kind: 'move', territory: 'imperial_basin', minimum: 1 }, { kind: 'move', territory: 'carthag', minimum: 1 }, ] }); const movementExpressionError = movementExpressionInputError(movementExpression); + const [mixedMovement, setMixedMovement] = useState>({ kind: 'or', terms: [ + { kind: 'fact', fact: { kind: 'hand', name: 'Hajr' } }, + { kind: 'action', action: { territory: 'carthag', minimum: 1 } }, + ] }); + const mixedMovementError = mixedMovementInputError(mixedMovement, g, target); const [shipment, setShipment] = useState({ territory: 'carthag', minimum: 6 }); const [mixed, setMixed] = useState({ kind: 'and', @@ -145,6 +152,7 @@ export function Truthtrance({ claim: planClaim, } : kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' } + : kind === 'mixedMovement' ? { kind, target, turn: g.turn, mixed: mixedMovement } : kind === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression } : kind === 'movement' ? { kind, target, ...movement } : kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression } @@ -310,6 +318,13 @@ export function Truthtrance({ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })} )} + ) : w.question!.kind === 'mixedMovement' ? ( + <> +

This answers the whole current-fact and future-movement question. Facts are fixed at this answer; later card or spice changes do not rewrite it. Unknown facts require an answer you can guarantee through the remaining legal movement. Individual fact answers and compiled branches are not published.

+ {(g.truthMixedMovementAnswers ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })} +
)} + ) : w.question!.kind === 'movementExpression' ? ( <>

Yes requires the whole AND/OR question to become true through your future physical ground moves. No keeps the whole expression false and may permit some moves. Each condition needs one qualifying group; smaller moves never add together. Original movement allowances, cards and responses still apply.

@@ -435,6 +450,7 @@ export function Truthtrance({ + @@ -523,6 +539,14 @@ export function Truthtrance({ {!g.truthTraitorTargets.includes(target) &&

Reveal both plans and choose a voter who has not submitted their Traitor decision.

} {g.battle?.revealed &&

{truthQuestionText(question, leaderName, locationName, name)}

} + ) : kind === 'mixedMovement' ? ( + <> + +

Current facts freeze when answered. Only future actual physical ground moves can fulfill movement conditions; smaller groups never add together. No negates the whole mixed question. Native entitlements, owned preparations and earlier obligations remain.

+ {!g.truthMixedMovementTargets.includes(target) &&

Choose a supported unused movement opportunity and finish pending decisions. Optional modules remain unfinished.

} +

{mixedMovementError ?? truthQuestionText(question, leaderName, locationName, name)}

+ ) : kind === 'movementExpression' ? ( <> answer === 'yes') ?? answers[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } + if (w.question.kind === 'mixedMovement') { + const answer = g.truthMixedMovementAnswers?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'movementExpression') { const answer = g.truthMovementExpressionAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; diff --git a/game/engine.ts b/game/engine.ts index cb60f8e6..7a0944ad 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,5 +1,6 @@ +import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question'; import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent, - validateMovementExpressionPromises, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises'; + validateMovementExpressionPromises, type MovementExpression, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises'; import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates, liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises, type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises'; @@ -599,6 +600,7 @@ import { type TruthQueueEntry, type TruthRecord, type TruthAnswer, + type MixedMovementClaim, } from './truthtrance'; import type { MixedShipmentExpression } from './mixed-shipment-question'; import { controlsLeader, nativeAvailable } from './leader-control'; @@ -2164,6 +2166,10 @@ export type Game = { alliancePromises?: AlliancePromise[]; movementPromises?: MovementPromise[]; movementExpressionPromises?: MovementExpressionPromise[]; + /** Original mixed questions only; actual obligations remain in the native ledger. */ + mixedMovementQuestions?: (MixedMovementClaim & { + event: string; player: string; asker: string; answer: boolean; binding: boolean; + })[]; movementPromiseSequence?: string[]; /** Server-only certainty of movement-relevant cards still in the discard. */ movementDiscardKnowledge?: MovementDiscardKnowledge; @@ -6902,6 +6908,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { battle: g.battle, shipmentPromises: g.shipmentPromises, movementPromises: g.movementPromises, + ...(next.record.question.kind === 'mixedMovement' ? { + mixedMovementQuestions: g.mixedMovementQuestions, movementExpressionPromises: g.movementExpressionPromises, + } : {}), active: g.active, order: g.order, status: g.status, @@ -18607,6 +18616,56 @@ function bindMovementExpressionTruth(g: Game, p: Player, claim: MovementExpressi (g.movementExpressionPromises ??= []).push(promise); (g.movementPromiseSequence ??= []).push(promise.event); } +function compileMixedMovement(g: Game, p: Player, claim: MixedMovementClaim, answer: boolean) { + const compiled = compileMixedActionExpression(claim.mixed, + fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer); + if (compiled.kind === 'constant') return { constant: compiled.value }; + const movement = (node: CompiledMixedAction): MovementExpression => { + if (node.kind === 'action') return { kind: 'move', ...node.action }; + requireRule(node.kind !== 'constant', 'Retain the complete compiled mixed-movement residual.'); + return { kind: node.kind, terms: node.terms.map(movement) }; + }; + return { residual: { turn: claim.turn, claim: movement(compiled) } }; +} +function mixedMovementTruthAnswers(g: Game, p: Player, claim: MixedMovementClaim): TruthAnswer[] { + if (claim.turn !== g.turn || !movementPromiseTargetAvailable(g, p.id)) return []; + const answers: TruthAnswer[] = []; + for (const answer of [true, false]) { + const compiled = compileMixedMovement(g, p, claim, answer); + if (compiled.constant !== undefined ? compiled.constant === answer : + movementExpressionTruthAnswers(g, p, compiled.residual!).includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no'); + } + return answers.length ? answers : ['unknown']; +} +function bindMixedMovementTruth(g: Game, p: Player, claim: MixedMovementClaim, answer: boolean, asker: string) { + requireRule(mixedMovementTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), + 'Choose a whole mixed-movement answer compatible with current facts and owned actions.'); + const compiled = compileMixedMovement(g, p, claim, answer); + let event: string; + if (compiled.residual) { + bindMovementExpressionTruth(g, p, compiled.residual, answer, asker); + event = g.movementExpressionPromises!.at(-1)!.event; + } else { + requireRule(compiled.constant === answer, 'Retain the truthful whole mixed-movement answer.'); + // Uniform early readiness avoids publishing whether a private fact made a + // residual necessary. Only the original aggregate answer is public. + reopenMovementPreparation(g, p); + event = crypto.randomUUID(); + } + (g.mixedMovementQuestions ??= []).push({ turn: claim.turn, mixed: structuredClone(claim.mixed), + event, player: p.id, asker, answer, binding: !!compiled.residual }); +} +function isMixedMovementEvent(g: Game, event: string): boolean { + return !!g.mixedMovementQuestions?.some(question => question.event === event); +} +function projectedMixedMovementPromises(g: Game, player: string) { + return (g.mixedMovementQuestions ?? []).filter(question => question.player === player).map(question => { + const bound = g.movementExpressionPromises?.find(promise => promise.event === question.event); + return { event: question.event, player: question.player, asker: question.asker, turn: question.turn, + mixed: structuredClone(question.mixed), answer: question.answer, progress: structuredClone(bound?.progress ?? []), + fulfilled: !question.binding || bound?.fulfilled === true, released: bound?.released === true }; + }); +} /** Nested native force records survive JSON transport; record key order is * immaterial but fields, values and the originating guide event must match. */ function canonicalMovementGuideValue(value: unknown): unknown { @@ -18652,7 +18711,7 @@ function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) { try { g.movementExpressionPromises = recordMovementExpressionEvent(expressions, p.id, g.turn, groundMovementEvent(move)); } catch (error) { if (error instanceof MovementExpressionPromiseError) throw new RuleError(error.message); throw error; } g.movementExpressionPromises.forEach((promise, index) => { - if (promise.fulfilled && !expressions[index].fulfilled) + if (promise.fulfilled && !expressions[index].fulfilled && !isMixedMovementEvent(g, promise.event)) log(g, `${p.name} fulfilled a whole Truthtrance movement answer with this committed move.`); }); } @@ -36388,6 +36447,8 @@ function applyActionInner( bindMovement: bindMovementTruth, movementExpressionAnswers: movementExpressionTruthAnswers, bindMovementExpression: bindMovementExpressionTruth, + mixedMovementAnswers: mixedMovementTruthAnswers, + bindMixedMovement: bindMixedMovementTruth, allianceAnswers: allianceTruthAnswers, bindAlliance: bindAllianceTruth, bribeAnswers: bribeTruthAnswers, @@ -36411,7 +36472,7 @@ function applyActionInner( ) return g; } catch (error) { - if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof TraitorDecisionError) + if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError) throw new RuleError(error.message); throw error; } @@ -40198,7 +40259,11 @@ export function viewGame(state: Game, id: string) { truthMovementExpressionTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id), truthMovementExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'movementExpression' && g.truthtrance.question.target === id ? movementExpressionTruthAnswers(g, me, g.truthtrance.question) : null, - movementExpressionPromises: (g.movementExpressionPromises ?? []).filter(promise => promise.player === id).map(promise => structuredClone(promise)), + movementExpressionPromises: (g.movementExpressionPromises ?? []).filter(promise => promise.player === id && !isMixedMovementEvent(g, promise.event)).map(promise => structuredClone(promise)), + truthMixedMovementTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id), + truthMixedMovementAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'mixedMovement' && + g.truthtrance.question.target === id ? mixedMovementTruthAnswers(g, me, g.truthtrance.question) : null, + mixedMovementPromises: projectedMixedMovementPromises(g, id), movementExpressionPromiseGuide: projectedMovementExpressionGuide(g, me), truthShipmentSupported: g.players.some(p => shipmentPromiseModeSupported(g, p.id)), truthShipmentTargets: !g.response && !g.decision && !g.phaseOpening && !g.pendingShipment diff --git a/game/mixed-action-question.ts b/game/mixed-action-question.ts new file mode 100644 index 00000000..626fdb5e --- /dev/null +++ b/game/mixed-action-question.ts @@ -0,0 +1,116 @@ +import type { TruthAnswer, TruthFact } from './truthtrance'; + +/** Original public clauses only; answer-time constants never belong in a question. */ +export type MixedActionExpression = + | { kind: 'fact'; fact: TruthFact } + | { kind: 'action'; action: A } + | { kind: 'and' | 'or'; terms: MixedActionExpression[] }; + +/** Private answer-time result. Residual groups contain only actions and groups. */ +export type CompiledMixedAction = + | { kind: 'constant'; value: boolean } + | { kind: 'action'; action: A } + | { kind: 'and' | 'or'; terms: CompiledMixedAction[] }; + +export type MixedActionExpressionLimits = { maxDepth?: number; maxLeaves?: number }; +export class MixedActionQuestionError extends Error {} + +function check(value: unknown, message: string): asserts value { + if (!value) throw new MixedActionQuestionError(message); +} + +function record(value: unknown): Record { + check(value && typeof value === 'object' && !Array.isArray(value), + 'Choose a current fact, future action, or AND/OR group.'); + return value as Record; +} + +/** The callbacks retain fact authority and validate one action leaf, respectively. + * One logical budget spans the outer expression and all embedded fact groups. */ +export function parseMixedActionExpression( + value: unknown, + parseFact: (value: unknown) => TruthFact, + parseAction: (value: unknown) => A, + limits: MixedActionExpressionLimits = {}, +): MixedActionExpression { + const maxDepth = limits.maxDepth ?? 4; + const maxLeaves = limits.maxLeaves ?? 16; + check(Number.isSafeInteger(maxDepth) && maxDepth >= 0 && + Number.isSafeInteger(maxLeaves) && maxLeaves >= 1, + 'Choose valid logical depth and clause limits.'); + let leaves = 0; + let hasFact = false; + let hasAction = false; + const count = (depth: number, leaf: boolean): void => { + check(depth <= maxDepth && (!leaf || ++leaves <= maxLeaves), + `Truthtrance supports at most ${maxLeaves} logical clauses and ${maxDepth} levels of grouping.`); + }; + const countFact = (fact: TruthFact, depth: number): void => { + const group = fact.kind === 'and' || fact.kind === 'or'; + count(depth, !group); + if (group) for (const term of fact.terms) countFact(term, depth + 1); + }; + const parse = (input: unknown, depth: number): MixedActionExpression => { + const node = record(input); + if (node.kind === 'fact') { + check(Object.keys(node).sort().join(',') === 'fact,kind', + 'Supply only the original current fact, not a frozen answer.'); + const fact = parseFact(node.fact); + countFact(fact, depth); + hasFact = true; + return { kind: 'fact', fact }; + } + count(depth, node.kind === 'action'); + if (node.kind === 'action') { + check(Object.keys(node).sort().join(',') === 'action,kind', + 'Supply only one original future-action statement.'); + const action = parseAction(node.action); + hasAction = true; + return { kind: 'action', action }; + } + check(Object.keys(node).sort().join(',') === 'kind,terms' && + (node.kind === 'and' || node.kind === 'or') && Array.isArray(node.terms) && + node.terms.length >= 2 && node.terms.length <= maxLeaves, + 'Join two or more current facts or future actions with AND or OR.'); + return { kind: node.kind, terms: node.terms.map(term => parse(term, depth + 1)) }; + }; + const mixed = parse(value, 0); + check(hasFact && hasAction, + 'A mixed question must include a current fact and a future-action statement.'); + return mixed; +} + +/** Snapshot every fact before folding. Unknown takes the worst case for the + * selected whole answer, so the residual guarantees it without hidden guesses. */ +export function compileMixedActionExpression( + mixed: MixedActionExpression, + answerFact: (fact: TruthFact) => TruthAnswer, + answer: boolean, +): CompiledMixedAction { + if (mixed.kind === 'fact') { + const result = answerFact(mixed.fact); + return { kind: 'constant', value: result === 'unknown' ? !answer : result === 'yes' }; + } + if (mixed.kind === 'action') return { kind: 'action', action: mixed.action }; + // Do not short-circuit: a decisive earlier clause cannot skip later facts. + const terms = mixed.terms.map(term => compileMixedActionExpression(term, answerFact, answer)); + const identity = mixed.kind === 'and'; + if (terms.some(term => term.kind === 'constant' && term.value !== identity)) + return { kind: 'constant', value: !identity }; + const residual = terms.filter(term => term.kind !== 'constant'); + if (residual.length === 0) return { kind: 'constant', value: identity }; + if (residual.length === 1) return residual[0]; + return { kind: mixed.kind, terms: residual }; +} + +/** Public text comes from the original tree, never private compiled clauses. */ +export function mixedActionExpressionText( + mixed: MixedActionExpression, + factText: (fact: TruthFact) => string, + actionText: (action: A) => string, +): string { + if (mixed.kind === 'fact') return factText(mixed.fact); + if (mixed.kind === 'action') return actionText(mixed.action); + return `(${mixed.terms.map(term => mixedActionExpressionText(term, factText, actionText)) + .join(mixed.kind === 'and' ? ' AND ' : ' OR ')})`; +} diff --git a/game/reference.ts b/game/reference.ts index f6bdc9b4..6daf8e54 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3382,6 +3382,31 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Original public H/A/E sources and actual callbacks cover Yes/No, payments, release, multiple original cards, stale ownership and earlier shipment funding. Broader rule/module, recovery and deployed acceptance remain required.', evidence: ['tests/fixture-truth-bid.ts', 'tests/truth-bid-runtime.test.ts', 'tests/truth-bid-controls.test.tsx'] }, ], }, + { + id: 'truthtrance-mixed-movement', + title: 'Truthtrance: current facts and future ground moves', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Combine a fact at answer time with future physical ground moves, publishing the aggregate answer and preserving the original native movement obligations.', + steps: [ + 'Choose Combine current facts and future ground moves. Build full AND/OR groups with at least one current fact and one destination/minimum action. One logical budget spans nested fact groups and future conditions: sixteen leaves and depth four as software limits.', + 'Current facts are evaluated when the respondent answers. Later spending, card disposal or other custody changes do not rewrite that historical fact. The question, whole answer and physical actions are public; individual fact answers and the reduced obligation are not published.', + 'A definite answer must be guaranteed by the current facts and remaining owned movement. Unknown facts take the conservative case for that answer: Yes cannot rely on an unknown being true, and No cannot rely on it being false. If neither whole answer is currently supportable, the holder may ask differently or save the unanswered card.', + 'A fact may already settle the aggregate answer, requiring no future move. Otherwise the remaining pure movement conditions use the original native search, funding, allowances, cards, responses and actual owner finish. No negates the entire mixed question.', + 'Each movement condition needs one actual future physical group; smaller moves never add together. Prior moves, shipment and the concealed No-Field marker do not fulfill it. Owned Hajr still follows its original timing, even when the question asked about holding that same physical Hajr before it was played.', + 'Persistent own raw question, aggregate status, recorded movement maxima and one exact native guide connect to real actions and all four minimal policies. Pure and mixed panels share the guide. Stale, foreign or modified nested packets reject.', + 'Native Basic/Advanced phases0–5 retain the existing unused movement opportunity and preparation scope. The fact/action composition is provisional application of general Truthtrance authority; other action families, optional modules and complete-mode acceptance remain unfinished.', + ], + related: ['card-truthtrance', 'truthtrance-fact-expression', 'truthtrance-movement-expression', 'truthtrance-movement', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Strict generic mixed parser/compiler freezes facts conservatively and binds only the resulting native movement obligation; immediate aggregate answers create none.', evidence: ['game/mixed-action-question.ts', 'game/truthtrance.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Full mixed editor with reused fact controls, guarded raw preview/Ask, private answers, aggregate panel and shared exact guide.', evidence: ['components/mixed-movement-fields.tsx', 'components/mixed-movement-promises.tsx', 'components/movement-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer the aggregate and follow native movement guides. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-movement-runtime.test.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'Snapshot, conservative unknown semantics, original physical obligations and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_MOVEMENT.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused generic, controlled editor/guide and original native known/unknown/Hajr/Charity cases. Broader composition and final browser/deployed acceptance remain open.', evidence: ['tests/mixed-action-question.test.ts', 'tests/fixture-truth-mixed-movement.ts', 'tests/truth-mixed-movement-runtime.test.ts', 'tests/truth-mixed-movement-controls.test.tsx'] }, + ], + }, { id: 'truthtrance-movement-expression', title: 'Truthtrance: combined ground moves', @@ -3570,6 +3595,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.', 'A current-Nexus alliance question binds actual final membership with a named partner at that Nexus close. Yes uses existing partnership or an actual invitation; offers alone do not form alliances. No may require a later break before Ready. Unknown can save the card until consent exists. Other players retain their original withdrawal/break rights. See the alliance-promise guide for its provisional interval and scope.', 'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.', + 'A mixed current-fact and ground-movement question freezes the fact at answer time and keeps only the native movement obligation needed for the whole answer. Later card disposal does not rewrite the fact, and unknown clauses require a guaranteed aggregate. See the mixed movement guide for its provisional scope.', 'A grouped ground-movement question combines future physical group minima with full AND/OR. One move must meet each leaf; smaller moves never add together. Actual owned entitlements can fulfill several leaves over several moves, while No negates the whole expression. See the combined movement guide for its provisional opportunity and scope.', 'A grouped ordinary-bribe question combines named future payment minima with full AND/OR. No negates the whole question; real payments rebuild legal funded alternatives, and next-opening recipient collection adds no new progress. See the combined bribe guide for its provisional interval and scope.', 'A grouped Revival question combines future ordinary force minima and named native leader/Kwisatz returns with full AND/OR. No negates the whole expression, not every leaf. Legal alternatives are rebuilt after actual returns without fixing one branch. See the grouped Revival guide for input limits, source and provisional scope.', @@ -3592,6 +3618,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'truthtrance-shipment', 'truthtrance-movement', 'truthtrance-movement-expression', + 'truthtrance-mixed-movement', 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-leader-revival', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index fe3b4cf8..bdba3f53 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,4 @@ +import { parseMixedActionExpression, mixedActionExpressionText, type MixedActionExpression } from './mixed-action-question'; import { parseMovementExpressionClaim, movementExpressionText, type MovementExpressionClaim } from './movement-expression-promises'; import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionText, type BribeExpressionClaim } from './bribe-expression-promises'; @@ -67,7 +68,9 @@ export type TruthFact = | { kind: 'traitor'; leader: string } | { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number } | { kind: 'and' | 'or'; terms: TruthFact[] }; +export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression }; export type TruthQuestion = + | ({ kind: 'mixedMovement'; target: string } & MixedMovementClaim) | ({ kind: 'movementExpression'; target: string } & MovementExpressionClaim) | ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim) | ({ kind: 'revivalExpression'; target: string } & RevivalExpressionClaim) @@ -311,6 +314,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { 'Choose the current revealed battle and a still-unsubmitted Traitor decision.'); return { kind: 'traitorCall', target: v.target as string, battle: v.battle, territory: v.territory as string }; } + if (v.kind === 'mixedMovement') { + check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn && + movementPromiseTargetAvailable(g, v.target as string), + 'Supply the current turn, complete mixed movement question and supported unused respondent.'); + const respondent = g.players.find(player => player.id === v.target)!; + const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), action => { + check(action && typeof action === 'object' && !Array.isArray(action) && + Object.keys(action).sort().join(',') === 'minimum,territory', 'Supply only a printed movement destination and physical minimum.'); + return parseMovementClaim(action); + }); + return { kind: 'mixedMovement', target: v.target as string, turn: g.turn, mixed }; + } if (v.kind === 'movementExpression') { check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn', 'Supply the current turn, complete movement expression and respondent.'); @@ -563,6 +578,8 @@ export function truthQuestionText( return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`; return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`; }; + if (q.kind === 'mixedMovement') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, + action => `you ${movementClaimText(action)} before your Movement opportunity ends`)}?`; return `Is it true that ${q.kind === 'mixedShipment' ? mixedShipmentExpressionText(q.mixed,clause) : clause(q.fact)}?`; } @@ -587,6 +604,8 @@ export function resolveTruthAction( asker: string, mixed?: MixedShipmentExpression, ) => void; + mixedMovementAnswers?: (g: Game, p: Player, claim: MixedMovementClaim) => TruthAnswer[]; + bindMixedMovement?: (g: Game, p: Player, claim: MixedMovementClaim, answer: boolean, asker: string) => void; movementExpressionAnswers?: (g: Game, p: Player, claim: MovementExpressionClaim) => TruthAnswer[]; bindMovementExpression?: (g: Game, p: Player, claim: MovementExpressionClaim, answer: boolean, asker: string) => void; movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[]; @@ -732,6 +751,9 @@ export function resolveTruthAction( if (w.question.kind === 'alliance') check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a current Nexus question with a compatible answer.'); + if (w.question.kind === 'mixedMovement') + check(effects.mixedMovementAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, + 'Choose a supported mixed current-fact and ground-movement question.'); if (w.question.kind === 'movementExpression') check(effects.movementExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a supported whole ground-movement question.'); @@ -792,6 +814,11 @@ export function resolveTruthAction( effects.bindShipment(g, p, claim, a.answer === 'yes', current.player, q.kind === 'mixedShipment' ? q.mixed : undefined); } + if (q.kind === 'mixedMovement') { + check(effects.mixedMovementAnswers && effects.bindMixedMovement && + effects.mixedMovementAnswers(g, p, q).includes(a.answer), 'Choose a compatible whole mixed-movement answer.'); + if (a.answer !== 'unknown') effects.bindMixedMovement(g, p, q, a.answer === 'yes', current.player); + } if (q.kind === 'movementExpression') { check(effects.movementExpressionAnswers && effects.bindMovementExpression && effects.movementExpressionAnswers(g, p, q).includes(a.answer), diff --git a/tests/fixture-truth-mixed-movement.ts b/tests/fixture-truth-mixed-movement.ts new file mode 100644 index 00000000..cbe5896c --- /dev/null +++ b/tests/fixture-truth-mixed-movement.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import type { MixedActionExpression } from '../game/mixed-action-question'; +import type { MovementClaim } from '../game/movement-promises'; +import type { TruthFact } from '../game/truthtrance'; +import { + actTruthMovementExpression, advanceTruthMovementExpression, completeTruthMovementExpression, + createTruthMovementExpressionFixture, declareTruthMovementExpression, finishTruthMovementExpression, + nextTruthMovementExpression, settleTruthMovementExpression, truthMovementExpressionClean, + truthMovementExpressionInventory, truthMovementExpressionPlayer, type TruthMovementExpressionStep, +} from './fixture-truth-movement-expression'; + +export type TruthMixedMovementExpression = MixedActionExpression; +export type TruthMixedMovementStep = TruthMovementExpressionStep; +export const actTruthMixedMovement = actTruthMovementExpression; +export const advanceTruthMixedMovement = advanceTruthMovementExpression; +export const completeTruthMixedMovement = completeTruthMovementExpression; +export const finishTruthMixedMovement = finishTruthMovementExpression; +export const nextTruthMixedMovement = nextTruthMovementExpression; +export const settleTruthMixedMovement = settleTruthMovementExpression; +export const truthMixedMovementClean = truthMovementExpressionClean; +export const truthMixedMovementInventory = truthMovementExpressionInventory; +export const truthMixedMovementPlayer = truthMovementExpressionPlayer; + +export const truthMixedMovementFact = (fact: TruthFact): TruthMixedMovementExpression => ({ kind: 'fact', fact }); +export const truthMixedMovementMove = (territory = 'imperial_basin', minimum = 1): TruthMixedMovementExpression => + ({ kind: 'action', action: { territory, minimum } }); +export const truthMixedMovementGroup = (kind: 'and' | 'or', ...terms: TruthMixedMovementExpression[]): TruthMixedMovementExpression => + ({ kind, terms }); + +/** Thin raw-question adapter: original Basic20/Advanced23 base33 starts and + * their Charity13 prefix retain every real deal, resource and native control. */ +export function createTruthMixedMovementFixture(options: { + advanced?: boolean; hajr?: boolean; window?: 'active' | 'charity'; mixed?: TruthMixedMovementExpression; +} = {}) { + const base = createTruthMovementExpressionFixture(options); + const hajr = base.setup.deck.find(card => card.effect === 'hajr'); + const projectile = base.setup.deck.find(card => card.kind === 'projectile'); + assert.ok(hajr && projectile, 'The unopened base33 component inventory supplies both printed fact identities'); + const hajrFact: TruthFact = { kind: 'hand', name: hajr.name }; + const projectileFact: TruthFact = { kind: 'hand', name: projectile.name }; + const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 }; + const mixed = options.mixed ?? truthMixedMovementGroup('and', truthMixedMovementFact(hajrFact), + truthMixedMovementMove(), truthMixedMovementMove('carthag')); + const question = { kind: 'mixedMovement' as const, target: base.target, turn: base.game.turn, mixed }; + return { ...base, movementSource: base, mixed, question, hajrFact, projectileFact, forecastFact, + questionAction: { type: 'truthAsk', question } satisfies Action, + sourceDisclosure: `${base.sourceDisclosure} This mixed adapter changes only the original raw question: current facts and future physical ground moves share its AND/OR tree. No fact answer, compiled residual, promise, progress or additional card is installed.` }; +} +export type TruthMixedMovementFixture = ReturnType; + +export function declareTruthMixedMovement(f: TruthMixedMovementFixture, source = f.game) { + return declareTruthMovementExpression(f.movementSource, source); +} +export function askTruthMixedMovement(game: Game, asker: string, target: string, mixed: TruthMixedMovementExpression, + actions: TruthMixedMovementStep[] = []): Game { + return actTruthMixedMovement(game, asker, { type: 'truthAsk', question: { + kind: 'mixedMovement', target, turn: game.turn, mixed: structuredClone(mixed), + } }, actions); +} +export function answerTruthMixedMovement(game: Game, target: string, answer: 'yes' | 'no', + actions: TruthMixedMovementStep[] = []): Game { + return actTruthMixedMovement(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthMixedMovement(f: TruthMixedMovementFixture, answer: 'yes' | 'no' = 'yes', + mixed = f.mixed, source = f.game) { + const declared = declareTruthMixedMovement(f, source), actions = [...declared.actions]; + const asked = askTruthMixedMovement(declared.game, f.asker, f.target, mixed, actions); + const game = answerTruthMixedMovement(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} + +/** Public aggregate only: this accessor intentionally never reads the private + * frozen facts or the internal residual movement-expression record. */ +export function truthMixedMovementPromise(game: Game, target = game.players[0].id) { + const row = viewGame(game, target).mixedMovementPromises.find(row => row.player === target); + assert.ok(row, 'The respondent retains the raw mixed question and whole-answer status'); + return row; +} diff --git a/tests/mixed-action-question.test.ts b/tests/mixed-action-question.test.ts new file mode 100644 index 00000000..a6549486 --- /dev/null +++ b/tests/mixed-action-question.test.ts @@ -0,0 +1,259 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + compileMixedActionExpression, + mixedActionExpressionText, + MixedActionQuestionError, + parseMixedActionExpression, + type CompiledMixedAction, + type MixedActionExpression, + type MixedActionExpressionLimits, +} from '../game/mixed-action-question'; +import { parseMovementClaim, type MovementClaim } from '../game/movement-promises'; +import type { TruthAnswer, TruthFact } from '../game/truthtrance'; + +type Expression = MixedActionExpression; +type Compiled = CompiledMixedAction; +const fact = (value = 5): Expression => ({ + kind: 'fact', fact: { kind: 'spice', compare: 'gte', value }, +}); +const action = (territory = 'carthag', minimum = 3): Expression => ({ + kind: 'action', action: { territory, minimum }, +}); +const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms }); + +// Fixtures isolate the shared budget; the caller retains the authoritative fact decoder. +const decodeFact = (input: unknown): TruthFact => structuredClone(input) as TruthFact; +const decodeAction = (input: unknown): MovementClaim => { + assert(input && typeof input === 'object' && !Array.isArray(input)); + assert.equal(Object.keys(input).sort().join(','), 'minimum,territory'); + return parseMovementClaim(input); +}; +const decode = (input: unknown, limits?: MixedActionExpressionLimits): Expression => + parseMixedActionExpression(input, decodeFact, decodeAction, limits); +const compile = (input: Expression, factAnswer: TruthAnswer, answer = true): Compiled => + compileMixedActionExpression(input, () => factAnswer, answer); +const compiledAction = (territory = 'carthag', minimum = 3): Compiled => ({ + kind: 'action', action: { territory, minimum }, +}); +const constant = (value: boolean): Compiled => ({ kind: 'constant', value }); + +function evaluate(compiled: Compiled, values: Readonly>): boolean { + if (compiled.kind === 'constant') return compiled.value; + if (compiled.kind === 'action') return values[compiled.action.territory] ?? false; + const terms = compiled.terms.map(term => evaluate(term, values)); + return compiled.kind === 'and' ? terms.every(Boolean) : terms.some(Boolean); +} + +function assertPureResidual(compiled: Compiled): void { + if (compiled.kind !== 'and' && compiled.kind !== 'or') return; + assert(compiled.terms.length >= 2); + for (const term of compiled.terms) { + assert.notEqual(term.kind, 'constant'); + assertPureResidual(term); + } +} + +void test('mixed action parsing requires both families and preserves raw nested grouping', () => { + const original = group('and', fact(), group('or', action(), action('arrakeen', 2))); + const parsed = decode(original); + assert.deepEqual(parsed, original); + assert.notEqual(parsed, original); + if ('terms' in parsed && 'terms' in original) { + assert.notEqual(parsed.terms, original.terms); + assert.notEqual(parsed.terms[0], original.terms[0]); + } + for (const input of [fact(), action(), group('and', fact(), fact(10)), + group('or', action(), action('arrakeen'))]) + assert.throws(() => decode(input), MixedActionQuestionError); +}); + +void test('mixed action public parsing rejects frozen constants, metadata and malformed groups everywhere', () => { + const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] }, + { kind: 'or', terms: [fact()] }, { kind: 'xor', terms: [fact(), action()] }, + { kind: 'and', terms: [fact(), action()], answer: true }, + { ...fact(), value: true }, { ...action(), target: 'player' }, + { kind: 'constant', value: true }, { constant: 'no' }, + { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 }, constant: 'yes' }, + { kind: 'action', action: { territory: 'carthag', minimum: 3 }, turn: 1 }]; + for (const input of invalid) { + assert.throws(() => decode(input), MixedActionQuestionError); + assert.throws(() => decode(group('or', fact(), action(), input as Expression))); + } + for (const input of [{ territory: 'carthag', minimum: 3, target: 'player' }, + { territory: 'carthag', minimum: 3, turn: 1 }, + { kind: 'constant', value: true }, + { kind: 'and', terms: [{ territory: 'carthag', minimum: 3 }] }]) + assert.throws(() => decode(group('and', fact(), { kind: 'action', action: input } as Expression))); +}); + +void test('decisive current-fact siblings cannot hide invalid action or fact subtrees', () => { + const forbidden = new Error('This fact is unavailable.'); + for (const kind of ['and', 'or'] as const) { + for (const reverse of [false, true]) { + const invalid = action('missing', 3); + const terms = reverse ? [invalid, fact()] : [fact(), invalid]; + assert.throws(() => decode(group(kind, ...terms))); + const input = group(kind, fact(), action(), fact(10)); + assert.throws(() => parseMixedActionExpression(input, value => { + const decoded = decodeFact(value); + if (decoded.kind === 'spice' && decoded.value === 10) throw forbidden; + return decoded; + }, decodeAction), error => error === forbidden); + } + } + const badAction = new Error('This action is unavailable.'); + assert.throws(() => parseMixedActionExpression(group('or', fact(), action()), decodeFact, + () => { throw badAction; }), error => error === badAction); +}); + +void test('one sixteen-leaf budget includes every nested TruthFact leaf rather than wrappers or groups', () => { + assert.doesNotThrow(() => decode(group('and', + ...Array.from({ length: 15 }, () => fact()), action()))); + assert.throws(() => decode(group('and', + ...Array.from({ length: 16 }, () => fact()), action())), MixedActionQuestionError); + const embedded: Expression = { kind: 'fact', fact: { + kind: 'or', terms: [ + { kind: 'spice', compare: 'gte', value: 5 }, + { kind: 'and', terms: [ + { kind: 'spice', compare: 'gte', value: 10 }, + { kind: 'spice', compare: 'gte', value: 15 }, + ] }, + ], + } }; + assert.doesNotThrow(() => decode(group('or', + ...Array.from({ length: 5 }, () => embedded), action()))); + assert.throws(() => decode(group('or', + ...Array.from({ length: 5 }, () => embedded), fact(), action())), MixedActionQuestionError); +}); + +void test('one four-level budget includes nested current-fact AND/OR and supports explicit smaller limits', () => { + const embedded: Expression = { kind: 'fact', fact: { kind: 'and', terms: [ + { kind: 'spice', compare: 'gte', value: 5 }, + { kind: 'spice', compare: 'gte', value: 10 }, + ] } }; + let nested: Expression = embedded; + for (let depth = 0; depth < 3; depth++) nested = group('or', action(), nested); + assert.doesNotThrow(() => decode(nested)); + assert.throws(() => decode(group('and', action(), nested)), MixedActionQuestionError); + const simple = group('and', fact(), action()); + assert.deepEqual(decode(simple, { maxDepth: 1, maxLeaves: 2 }), simple); + assert.throws(() => decode(simple, { maxDepth: 0 }), MixedActionQuestionError); + assert.throws(() => decode(simple, { maxLeaves: 1 }), MixedActionQuestionError); + assert.throws(() => decode(group('and', embedded, action()), { maxDepth: 1 }), MixedActionQuestionError); + for (const limits of [{ maxDepth: -1 }, { maxDepth: 1.5 }, { maxLeaves: 0 }, + { maxLeaves: Infinity }, { maxLeaves: Number.MAX_SAFE_INTEGER + 1 }]) + assert.throws(() => decode(simple, limits), MixedActionQuestionError); +}); + +void test('known facts fold whole AND/OR questions independently of the selected Yes or No answer', () => { + for (const answer of [false, true]) { + assert.deepEqual(compile(group('and', fact(), action()), 'yes', answer), compiledAction()); + assert.deepEqual(compile(group('and', fact(), action()), 'no', answer), constant(false)); + assert.deepEqual(compile(group('or', fact(), action()), 'yes', answer), constant(true)); + assert.deepEqual(compile(group('or', fact(), action()), 'no', answer), compiledAction()); + } +}); + +void test('unknown facts conservatively constrain whole Yes and whole No for AND and OR', () => { + for (const answer of [false, true]) { + const and = compile(group('and', fact(), action()), 'unknown', answer); + const or = compile(group('or', fact(), action()), 'unknown', answer); + assert.deepEqual(and, answer ? constant(false) : compiledAction()); + assert.deepEqual(or, answer ? compiledAction() : constant(true)); + for (const actualFact of [false, true]) for (const actualAction of [false, true]) { + // Any accepted residual guarantees the whole selected answer for either unknown outcome. + if (evaluate(and, { carthag: actualAction }) === answer) + assert.equal(actualFact && actualAction, answer); + if (evaluate(or, { carthag: actualAction }) === answer) + assert.equal(actualFact || actualAction, answer); + } + } +}); + +void test('nested unknown clauses keep every whole-No alternative without negating individual actions', () => { + const asked = group('and', action(), group('or', fact(), action('arrakeen'))); + const compiled = compile(asked, 'unknown', false); + assert.deepEqual(compiled, compiledAction()); + assert.equal(evaluate(compiled, { carthag: false, arrakeen: true }), false); + assert.equal(evaluate(compiled, { carthag: true, arrakeen: false }), true); + const alternate = compile(group('or', fact(), group('and', action(), action('arrakeen'))), 'no', false); + assert.deepEqual(alternate, { kind: 'and', terms: [compiledAction(), compiledAction('arrakeen')] }); + assert.equal(evaluate(alternate, { carthag: true, arrakeen: false }), false); + assert.equal(evaluate(alternate, { carthag: false, arrakeen: true }), false); + assert.equal(evaluate(alternate, { carthag: true, arrakeen: true }), true); +}); + +void test('all facts are answered once before decisive folding, including later nested branches', () => { + const asked = group('or', fact(1), group('and', fact(2), action()), fact(3)); + const seen: number[] = []; + assert.deepEqual(compileMixedActionExpression(asked, current => { + assert.equal(current.kind, 'spice'); + if (current.kind !== 'spice') throw new Error('Unexpected fact.'); + seen.push(current.value); + return current.value === 1 ? 'yes' : 'no'; + }, true), constant(true)); + assert.deepEqual(seen, [1, 2, 3]); + const invalidLater = new Error('Answer-time fact failure.'); + assert.throws(() => compileMixedActionExpression(asked, current => { + if (current.kind === 'spice' && current.value === 3) throw invalidLater; + return 'yes'; + }, true), error => error === invalidLater); +}); + +void test('snapshot compilation survives later current-fact changes and JSON without retaining callbacks', () => { + let spice = 7; + let calls = 0; + const answerFact = (current: TruthFact): TruthAnswer => { + calls++; + assert.equal(current.kind, 'spice'); + return current.kind === 'spice' && spice >= current.value ? 'yes' : 'no'; + }; + const asked = decode(group('or', fact(), action())); + const frozen = compileMixedActionExpression(asked, answerFact, true); + assert.equal(calls, 1); + spice = 0; + assert.deepEqual(frozen, constant(true)); + assert.deepEqual(JSON.parse(JSON.stringify(frozen)) as Compiled, constant(true)); + assert.equal(calls, 1); + assert.deepEqual(compileMixedActionExpression(asked, answerFact, true), compiledAction()); + assert.equal(calls, 2); + const negative = compileMixedActionExpression(group('and', fact(), action()), answerFact, false); + spice = 20; + assert.deepEqual(negative, constant(false)); +}); + +void test('folding removes all internal constants and singleton groups while retaining multi-action choices', () => { + const asked = group('and', fact(1), group('or', fact(2), action(), action('arrakeen')), + group('and', fact(3), action('polar_sink'))); + const compiled = compileMixedActionExpression(asked, current => + current.kind === 'spice' && current.value === 2 ? 'no' : 'yes', true); + assert.deepEqual(compiled, { kind: 'and', terms: [ + { kind: 'or', terms: [compiledAction(), compiledAction('arrakeen')] }, + compiledAction('polar_sink'), + ] }); + assertPureResidual(compiled); + const entirelyKnown = group('or', group('and', fact(), fact()), group('and', fact(), action())); + assert.deepEqual(compile(entirelyKnown, 'no'), constant(false)); + assert.deepEqual(compile(entirelyKnown, 'yes'), constant(true)); +}); + +void test('public text retains every original clause after private folding and trees stay immutable', () => { + const original = group('or', fact(5), group('and', action(), action('arrakeen', 2))); + const snapshot = structuredClone(original); + const freeze = (node: Expression): void => { + if (node.kind === 'fact') Object.freeze(node.fact); + else if (node.kind === 'action') Object.freeze(node.action); + else { node.terms.forEach(freeze); Object.freeze(node.terms); } + Object.freeze(node); + }; + freeze(original); + assert.deepEqual(decode(original), snapshot); + assert.deepEqual(compile(original, 'yes'), constant(true)); + const text = mixedActionExpressionText(original, + current => current.kind === 'spice' ? `have at least ${current.value} spice` : 'current fact', + move => `move at least ${move.minimum} to ${move.territory}`); + assert.equal(text, '(have at least 5 spice OR (move at least 3 to carthag AND move at least 2 to arrakeen))'); + assert.deepEqual(original, snapshot); + assert.equal(text.includes('constant'), false); +}); diff --git a/tests/truth-mixed-movement-controls.test.tsx b/tests/truth-mixed-movement-controls.test.tsx new file mode 100644 index 00000000..87dad0b9 --- /dev/null +++ b/tests/truth-mixed-movement-controls.test.tsx @@ -0,0 +1,256 @@ +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 { MixedMovementFields, mixedMovementInputError } from '../components/mixed-movement-fields'; +import { MixedMovementPromises } from '../components/mixed-movement-promises'; +import { TruthFactFields } from '../components/mixed-shipment-fields'; +import { MovementExpressionPromiseGuideButton, MovementExpressionPromises } from '../components/movement-expression-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import type { MixedActionExpression } from '../game/mixed-action-question'; +import type { MovementClaim } from '../game/movement-promises'; +import type { TruthFact } from '../game/truthtrance'; + +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 move = (minimum = 1, territory = 'carthag'): Expression => ({ kind: 'action', action: { territory, minimum } }); +const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [move(4), move(2, 'arrakeen')] }] }); +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-movement-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-movement-${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-movement-${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-movement-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 personalized views test UI contracts and callbacks. Native earned + * cards, route feasibility and actual physical events have separate proof. */ +function projected(phase = 5, action: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 }, + eliteForces: { 'arrakeen:10': 1 }, territory: 'carthag', sector: 11 }): GameView { + const source = createGame('TRUTHMIXEDMOVEMENTCONTROLS', newPlayer('p', 'Respondent', 'emperor')); + joinGame(source, newPlayer('a', 'Asker', 'atreides')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = phase === 5 ? 'p' : null; game.ready = []; + game.movementPromises = []; game.movementExpressionPromises = []; + game.mixedMovementPromises = [{ event: 'controlled-mixed-event', player: 'p', asker: 'a', turn: 2, + mixed: claim(), answer: true, progress: [{ territory: 'carthag', maximum: 2 }, { territory: 'arrakeen', maximum: 1 }], + fulfilled: false, released: false }]; + game.movementExpressionPromiseGuide = { phase, action: { ...action, movementExpressionPromiseEvent: 'controlled-mixed-event' } }; + game.auction = phase === 3 ? { bid: 4, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null } : null; + return game; +} +function editor(initial: Expression, disabled = false) { + let value = initial, changes = 0; + const game = projected(); + return { + get value() { return value; }, get changes() { return changes; }, + tree: () => MixedMovementFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }), + html: () => renderToStaticMarkup(createElement(MixedMovementFields, { value, game, target: 'p', disabled, onChange() {} })), + error: () => mixedMovementInputError(value, game, 'p'), + }; +} +function guide(props: Parameters[0]) { + const button = MovementExpressionPromiseGuideButton(props); assert.ok(button); return button; +} +const press = (button: ReturnType) => button.props.onClick(); +const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedMovementPromises, { game, busy: false, send() {} })); + +void test('mixed editor keeps sixteen original clauses and blocks structural overflow without truncation', () => { + 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); + if (count % 2) select(e.tree(), `1.${count}`, 'territory', 'arrakeen'); + } + const full = e.value; assert.ok(full.kind === 'or'); assert.equal(full.terms.length, 16); + assert.deepEqual(full.terms[0], fact()); assert.equal(e.error(), null); + assert.match(e.html(), /16 \/ 16 conditions/); + assert.equal((e.html().match(/Ground-movement destination/g) ?? []).length, 15); + 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 and embedded fact groups preserve all branches through add, meaning, switch and singleton collapse', () => { + const e = editor({ kind: 'and', terms: [fact(), move(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')); + const value = e.value; assert.ok(value.kind === 'and'); assert.equal(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(), move(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, move()); assert.ok(e.error()); + click(control(e.tree(), 'and-1')); assert.equal(e.error(), null); +}); + +void test('four-level envelope counts fact subgroups and keeps invalid numbers and destinations repairable', () => { + let nested = fact(); + for (let depth = 0; depth < 4; depth++) nested = { kind: 'and', terms: [nested, move()] }; + 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); + for (const invalid of [NaN, -1, 0, 0.5, 21, Infinity]) { + const e = editor({ kind: 'and', terms: [fact(), move()] }); + minimum(e.tree(), '1.2', invalid); assert.ok(e.error()); + select(e.tree(), '1.2', 'territory', ''); click(control(e.tree(), 'or-1.2')); + const raw = e.value; assert.ok(raw.kind === 'and'); + assert.deepEqual(raw.terms[1], { kind: 'or', terms: [move(invalid, ''), fact()] }); + assert.match(e.html(), /role="alert"/); + minimum(e.tree(), '1.2.1', 2); select(e.tree(), '1.2.1', 'territory', 'arrakeen'); assert.equal(e.error(), null); + 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); + } + const frozen = editor({ kind: 'and', terms: [fact(), move()] }, 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.1', 'timing', 'action'); + editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 }); + assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before); +}); + +void test('known and unknown current facts use existing fields and aggregate guidance without inspecting secret custody', () => { + const game = projected(), value: Expression = { kind: 'and', terms: [ + fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), move(2), + ] }; + for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves']) + Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true }); + const html = renderToStaticMarkup(createElement(MixedMovementFields, { value, game, target: 'p', disabled: false, onChange() {} })); + assert.match(html, /Current facts are evaluated when the player answers/); + assert.match(html, /unknown current fact can still allow a definite whole answer/); + assert.match(html, /If no such value is available, the answer is I don’t know/); + assert.match(html, /Only the aggregate answer is shared/); assert.match(html, /grants no extra movement opportunity/); + assert.equal(mixedMovementInputError(value, game, 'p'), null); + assert.ok(mixedMovementInputError({ kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: NaN }), move()] }, game, 'p')); +}); + +void test('own mixed panel retains the full original question, aggregate status and maximum progress without revealing frozen branches', () => { + const game = projected(); game.movementExpressionPromiseGuide = null; + game.mixedMovementPromises.push( + { ...game.mixedMovementPromises[0], event: 'no-event', answer: false }, + { ...game.mixedMovementPromises[0], event: 'fulfilled-event', fulfilled: true }, + { ...game.mixedMovementPromises[0], event: 'released-event', released: true }, + { ...game.mixedMovementPromises[0], event: 'foreign-event', player: 'a', mixed: { kind: 'or', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), move(19, 'sietch_tabr')] } }, + ); + for (const promise of game.mixedMovementPromises) for (const key of ['binding', 'residual', 'factAnswers', 'compiled']) + Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + 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, /Carthag: 2 physical forces; Arrakeen: 1 physical forces/); + 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 branch remain private/); + assert.match(html, /No negates the whole expression, not each condition separately/); + assert.match(html, /never the sum of smaller moves/); assert.match(html, /Prior moves and shipments do not count/); + assert.doesNotMatch(html, /Poison Blade|19 physical|controlled-mixed-event|fulfilled-event|factAnswers/); + assert.equal(MixedMovementPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null); +}); + +void test('shared native guide forwards exact mixed event, nested force records and paid preparation packets', () => { + const actions: [number, Action][] = [ + [5, projected().movementExpressionPromiseGuide!.action], + [5, { type: 'move', forces: { 'arrakeen:10': 4 }, eliteForces: {}, territory: 'carthag', sector: 11, + movementCard: 'controlled-ornithopter', ornithopter: 'twoGroups', ornithopterEvent: 'controlled-flight' }], + [5, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0 }], + [5, { type: 'card', card: 'controlled-hajr' }], [5, { type: 'endMovement' }], + [2, { type: 'charity' }], [4, { type: 'revive', amount: 3, elite: 1 }], [3, { type: 'passBid' }], + [3, { type: 'decision', karama: false }], + ]; + for (const [phase, action] of actions) { + const game = projected(phase, action), sent: Action[] = []; + if (action.type === 'decision') game.decision = { kind: 'auctionPayment', player: 'p' }; + const button = guide({ game, busy: false, send(value) { sent.push(value); } }); + assert.equal(button.props.disabled, false); press(button); + assert.equal(sent[0], game.movementExpressionPromiseGuide!.action); assert.deepEqual(sent, [game.movementExpressionPromiseGuide!.action]); + assert.match(markup(game), /data-testid="movement-expression-promise-guide"/); + } + const box = projected(2, { type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-hajr' }); + box.decision = { kind: 'nullentropy', player: 'p' }; + box.nullentropy = { card: { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' }, blocked: null, + search: { event: 'controlled-paid-search', cards: [{ id: 'controlled-recovered-hajr', name: 'Hajr', kind: 'special', effect: 'hajr' }] } }; + assert.match(markup(box), /Choose Hajr in your paid Box search/); + const sent: Action[] = [], button = guide({ game: box, busy: false, send(action) { sent.push(action); } }); + press(button); assert.equal(sent[0], box.movementExpressionPromiseGuide!.action); + box.nullentropy.search!.event = 'different-search'; press(button); assert.equal(sent.length, 1); +}); + +void test('stale mixed guides reject event, owner, turn, status, phase and nested quoted action changes', () => { + const changes: ((game: GameView) => void)[] = [ + game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; }, + game => { game.active = 'a'; }, game => { game.movementExpressionPromiseGuide = null; }, + game => { game.movementExpressionPromiseGuide = { phase: 5, action: { ...game.movementExpressionPromiseGuide!.action } }; }, + game => { (game.movementExpressionPromiseGuide!.action.forces as Record)['arrakeen:10'] = 2; }, + game => { (game.movementExpressionPromiseGuide!.action.eliteForces as Record)['arrakeen:10'] = 0; }, + game => { game.mixedMovementPromises[0].event = 'expired'; }, game => { game.mixedMovementPromises[0].player = 'a'; }, + game => { game.mixedMovementPromises[0].turn = 1; }, game => { game.mixedMovementPromises[0].fulfilled = true; }, + game => { game.mixedMovementPromises[0].released = true; }, game => { game.mixedMovementPromises = []; }, + game => { game.phaseOpening = { passed: [] }; }, game => { game.automaticContinuationPending = true; }, + game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, + game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; }, + game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, + 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 }; }, + ]; + 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 busy guide cannot send'); } }; + const button = guide(busy); busy.busy = true; press(button); + const replaced = { game: projected(), busy: false, send() { assert.fail('A replaced view cannot send'); } }; + const original = guide(replaced); replaced.game = { ...replaced.game }; press(original); +}); + +void test('mixed panel waits for the native turn and shares one guide with live scalar or pure panels', () => { + for (const phase of [0, 1, 2, 3, 4]) { + const game = projected(phase); game.movementExpressionPromiseGuide = null; + assert.match(markup(game), /Your Movement turn is still ahead/); assert.doesNotMatch(markup(game), /