Combine current facts with future ground movement

This commit is contained in:
Quintine committed 2026-10-26 16:25:54 +11:00
1 parent d524642b65
commit b63a60da64
19 files changed
+1590 -7

No files matched your search

+2
View File
@@ -2,6 +2,7 @@
import { TraitorDecisionPromises } from './traitor-decision-promises'; import { TraitorDecisionPromises } from './traitor-decision-promises';
import { MovementPromises } from './movement-promises'; import { MovementPromises } from './movement-promises';
import { MovementExpressionPromises } from './movement-expression-promises'; import { MovementExpressionPromises } from './movement-expression-promises';
import { MixedMovementPromises } from './mixed-movement-promises';
import { HomeworldEcazDuke } from './homeworld-ecaz-duke'; import { HomeworldEcazDuke } from './homeworld-ecaz-duke';
import { integerInputValue } from '../lib/integer-input'; import { integerInputValue } from '../lib/integer-input';
import { normalAuctionHandBlock } from '@/game/normal-auction'; import { normalAuctionHandBlock } from '@/game/normal-auction';
@@ -1989,6 +1990,7 @@ export function GameTable({
<HomeworldEcazDuke game={g} act={act} busy={reactionBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} /> <HomeworldEcazDuke game={g} act={act} busy={reactionBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<MovementPromises game={g} act={act} busy={reactionBusy} /> <MovementPromises game={g} act={act} busy={reactionBusy} />
<MovementExpressionPromises game={g} send={act} busy={reactionBusy} /> <MovementExpressionPromises game={g} send={act} busy={reactionBusy} />
<MixedMovementPromises game={g} send={act} busy={reactionBusy} />
{!reactionOwnsControls && g.semutaReaction && <SemutaReaction game={g} act={act} busy={reactionBusy} />} {!reactionOwnsControls && g.semutaReaction && <SemutaReaction game={g} act={act} busy={reactionBusy} />}
{g.karamaBlocked && <p className="notice" role="status">{g.karamaBlocked}</p>} {g.karamaBlocked && <p className="notice" role="status">{g.karamaBlocked}</p>}
{g.karamaPurposeBlocks?.map(({purpose, message}) => <p key={purpose} className="notice" role="status">{message}</p>)} {g.karamaPurposeBlocks?.map(({purpose, message}) => <p key={purpose} className="notice" role="status">{message}</p>)}
+181
View File
@@ -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<MovementClaim>;
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 <fieldset key={path} data-mixed-movement-fact-path={path} className="grid min-w-0 gap-2 p-2">
<legend>{fact.kind === 'and' || fact.kind === 'or' ? `Current-fact group ${path}` : `Current fact ${path}`}</legend>
{fact.kind === 'and' || fact.kind === 'or' ? <>
<label htmlFor={`truth-mixed-movement-${path}-fact-meaning`}>Current-fact group {path} meaning</label>
<select id={`truth-mixed-movement-${path}-fact-meaning`} value={fact.kind} disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === 'and' || kind === 'or') change({ ...fact, kind });
}}><option value="and">All conditions · AND</option><option value="or">At least one condition · OR</option></select>
{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 });
}))}
<Button type="button" data-mixed-movement-control={`fact-add-${path}`}
disabled={!allowed({ ...fact, terms: [...fact.terms, newFactLeaf()] })}
onClick={() => change({ ...fact, terms: [...fact.terms, newFactLeaf()] })}>Add current fact</Button>
</> : <TruthFactFields value={fact} game={game} target={target} onChange={change} />}
<div className="flex flex-wrap gap-2">
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-mixed-movement-control={`fact-${kind}-${path}`}
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group current facts with {kind.toUpperCase()}</Button>)}
{remove && <Button type="button" data-mixed-movement-control={`fact-remove-${path}`} disabled={disabled}
onClick={() => commit(remove())}>Remove current fact</Button>}
</div>
</fieldset>;
};
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 <fieldset key={path} data-mixed-movement-path={path} className="grid min-w-0 gap-2 p-2">
<legend>{node.kind === 'and' || node.kind === 'or' ? `Mixed group ${path} · ${node.kind.toUpperCase()}` : `Mixed condition ${path}`}</legend>
{node.kind === 'and' || node.kind === 'or' ? <>
<label htmlFor={`${id}-meaning`}>Mixed group {path} meaning</label>
<select id={`${id}-meaning`} value={node.kind} disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === 'and' || kind === 'or') change({ ...node, kind });
}}><option value="and">All conditions · AND</option><option value="or">At least one condition · OR</option></select>
{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 });
}))}
<div className="flex flex-wrap gap-2">
<Button type="button" data-mixed-movement-control={`add-fact-${path}`}
disabled={!allowed({ ...node, terms: [...node.terms, newFact()] })}
onClick={() => change({ ...node, terms: [...node.terms, newFact()] })}>Add current fact</Button>
<Button type="button" data-mixed-movement-control={`add-action-${path}`}
disabled={!allowed({ ...node, terms: [...node.terms, newMove()] })}
onClick={() => change({ ...node, terms: [...node.terms, newMove()] })}>Add ground-movement condition</Button>
{(['and', 'or'] as const).map(kind => {
const next: MixedMovementExpression = { ...node, terms: [...node.terms, { kind, terms: [newFact(), newMove()] }] };
return <Button key={kind} type="button" data-mixed-movement-control={`nested-${kind}-${path}`}
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
})}
</div>
</> : <>
<label htmlFor={`${id}-timing`}>Condition timing</label>
<select id={`${id}-timing`} value={node.kind} disabled={disabled} onChange={event => {
if (event.currentTarget.value === 'fact') change(newFact());
if (event.currentTarget.value === 'action') change(newMove());
}}><option value="fact">Current fact · at the answer</option><option value="action">Future ground move · this turn</option></select>
{node.kind === 'fact' ? renderFact(node.fact, path, fact => replace({ kind: 'fact', fact })) : node.kind === 'action' ? <>
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
<select id={`${id}-territory`} value={TERRITORIES.some(destination => destination.id === node.action.territory) ? node.action.territory : ''}
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === node.action.territory)}
onChange={event => change({ ...node, action: { ...node.action, territory: event.currentTarget.value } })}>
<option value="">Choose a printed Arrakis territory</option>
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
</select>
<label htmlFor={`${id}-minimum`}>Minimum physical forces in one ground move</label>
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required disabled={disabled}
value={Number.isNaN(node.action.minimum) ? '' : node.action.minimum}
aria-invalid={!Number.isSafeInteger(node.action.minimum) || node.action.minimum < 1 || node.action.minimum > 20}
onChange={event => change({ ...node, action: { ...node.action, minimum: event.currentTarget.valueAsNumber } })} />
</> : null}
</>}
<div className="flex flex-wrap gap-2">
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-mixed-movement-control={`${kind}-${path}`}
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
{remove && <Button type="button" data-mixed-movement-control={`remove-${path}`} disabled={disabled}
onClick={() => commit(remove())}>Remove {node.kind === 'and' || node.kind === 'or' ? 'group' : 'condition'}</Button>}
</div>
</fieldset>;
};
return <fieldset disabled={disabled} data-testid="mixed-movement-expression"
className="grid min-w-0 gap-2 [&_select]:min-w-0 [&_select]:max-w-full [&_button]:max-w-full [&_button]:min-h-11 [&_button]:h-auto [&_button]:py-2 [&_button]:whitespace-normal">
<legend>Mixed current-fact / future ground-movement claim</legend>
<p className="fine">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.</p>
<p className="fine">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.</p>
<p className="fine">App input limits: {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions and {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping.
These are input limits, not additional printed rules.</p>
<p className="fine">{size.leaves} / {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping</p>
{error && <p role="alert">{error}</p>}
{render(value, '1', next => next)}
</fieldset>;
}
+59
View File
@@ -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<MovementClaim>): string[] {
const found = new Set<string>();
const visit = (node: MixedActionExpression<MovementClaim>): 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 <section className="notice space-y-3" aria-label="Your Truthtrance mixed movement promises" id="mixed-movement-promise-guidance">
<h3>Your mixed movement promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
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.'}
</li>)}</ul>
<p>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.</p>
<p className="fine">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.
{' '}<a href="/rules#truthtrance-mixed-movement">Mixed movement rules</a>.</p>
{waiting && <p>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.'}
</p>}
{live && !sharedGuideElsewhere && <MovementExpressionPromiseGuideButton {...props} />}
</section>;
}
+4 -2
View File
@@ -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 && 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 === guide && current.movementExpressionPromiseGuide.phase === phase &&
current.movementExpressionPromiseGuide.action === action && JSON.stringify(action) === signature && opportunity && 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) && !!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || payment) &&
!current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; !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.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.'} {game.decision?.kind === 'nullentropy' && game.decision.player === game.me && ' Your already-paid Box search can finish now.'}
</p>} </p>}
{!liveMovementPromises(game.movementPromises ?? [], game.me, game.turn).length && <MovementExpressionPromiseGuideButton {...props} />} {live && !liveMovementPromises(game.movementPromises ?? [], game.me, game.turn).length && <MovementExpressionPromiseGuideButton {...props} />}
</section>; </section>;
} }
+26 -1
View File
@@ -1,5 +1,7 @@
'use client'; '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 { MovementExpressionFields, movementExpressionInputError } from './movement-expression-fields';
import type { MovementExpression } from '@/game/movement-expression-promises'; import type { MovementExpression } from '@/game/movement-expression-promises';
import { TERRITORIES } from '@/game/board'; import { TERRITORIES } from '@/game/board';
@@ -71,7 +73,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id, g.players.find((p) => p.id !== me.id)!.id,
); );
const [kind, setKind] = useState< 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'); >('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); 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 }, { kind: 'move', territory: 'imperial_basin', minimum: 1 }, { kind: 'move', territory: 'carthag', minimum: 1 },
] }); ] });
const movementExpressionError = movementExpressionInputError(movementExpression); const movementExpressionError = movementExpressionInputError(movementExpression);
const [mixedMovement, setMixedMovement] = useState<MixedActionExpression<MovementClaim>>({ 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<ShipmentExpression>({ territory: 'carthag', minimum: 6 }); const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState<MixedShipmentExpression>({ const [mixed, setMixed] = useState<MixedShipmentExpression>({
kind: 'and', kind: 'and',
@@ -145,6 +152,7 @@ export function Truthtrance({
claim: planClaim, claim: planClaim,
} }
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' } : 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 === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression }
: kind === 'movement' ? { kind, target, ...movement } : kind === 'movement' ? { kind, target, ...movement }
: kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression } : 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 })} {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)} </div>)}
</> </>
) : w.question!.kind === 'mixedMovement' ? (
<>
<p className="notice">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.</p>
{(g.truthMixedMovementAnswers ?? []).map(answer => <div key={answer}>
{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'movementExpression' ? ( ) : w.question!.kind === 'movementExpression' ? (
<> <>
<p className="notice">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.</p> <p className="notice">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.</p>
@@ -435,6 +450,7 @@ export function Truthtrance({
</option> </option>
<option value="movement">Bind ground movement this turn</option> <option value="movement">Bind ground movement this turn</option>
<option value="movementExpression">Combine ground moves with AND/OR</option> <option value="movementExpression">Combine ground moves with AND/OR</option>
<option value="mixedMovement">Combine current facts and future ground moves</option>
<option value="alliance">Bind alliance membership at this Nexus close</option> <option value="alliance">Bind alliance membership at this Nexus close</option>
<option value="bribe">Bind ordinary bribe payments this turn</option> <option value="bribe">Bind ordinary bribe payments this turn</option>
<option value="bribeExpression">Combine ordinary bribe payments with AND/OR</option> <option value="bribeExpression">Combine ordinary bribe payments with AND/OR</option>
@@ -523,6 +539,14 @@ export function Truthtrance({
{!g.truthTraitorTargets.includes(target) && <p>Reveal both plans and choose a voter who has not submitted their Traitor decision.</p>} {!g.truthTraitorTargets.includes(target) && <p>Reveal both plans and choose a voter who has not submitted their Traitor decision.</p>}
{g.battle?.revealed && <p className="notice" aria-label="Public Traitor question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>} {g.battle?.revealed && <p className="notice" aria-label="Public Traitor question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>}
</> </>
) : kind === 'mixedMovement' ? (
<>
<MixedMovementFields value={mixedMovement} game={g} target={target}
disabled={busy || !g.truthMixedMovementTargets.includes(target)} onChange={setMixedMovement} />
<p className="fine">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.</p>
{!g.truthMixedMovementTargets.includes(target) && <p className="notice">Choose a supported unused movement opportunity and finish pending decisions. Optional modules remain unfinished.</p>}
<p className="notice" aria-label="Public mixed movement question preview">{mixedMovementError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'movementExpression' ? ( ) : kind === 'movementExpression' ? (
<> <>
<MovementExpressionFields value={movementExpression} territories={TERRITORIES} <MovementExpressionFields value={movementExpression} territories={TERRITORIES}
@@ -623,6 +647,7 @@ export function Truthtrance({
{!['fact', 'battlePlan', 'shipment'].includes(kind) && button( {!['fact', 'battlePlan', 'shipment'].includes(kind) && button(
'Ask publicly', 'Ask publicly',
{ type: 'truthAsk', question }, { type: 'truthAsk', question },
(kind === 'mixedMovement' && (!!mixedMovementError || !g.truthMixedMovementTargets.includes(target))) ||
(kind === 'movementExpression' && (!!movementExpressionError || !g.truthMovementExpressionTargets.includes(target))) || (kind === 'movementExpression' && (!!movementExpressionError || !g.truthMovementExpressionTargets.includes(target))) ||
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) || (kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) || (kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
+32
View File
@@ -16,6 +16,38 @@ open mode gates.
## Current checkpoint and work ## Current checkpoint and work
**Truthtrance / current facts and future movement — 26 October. Prototyped / Partial.**
Full mixed AND/OR questions now freeze current facts at actual answer time and
bind only the remaining native ground-movement obligation. Unknown facts use
the conservative case guaranteeing the selected aggregate; decisive constants
need no future move. Public input rejects frozen constants/metadata, and own
projection retains raw question/aggregate/max progress without compiled clause
answers or residuals. Original entitlements/funding, physical commitment,
Hajr timing and owner finish remain. Full editor, private answers, one shared
native guide and four minimal policies are connected; scope remains provisional.
Root integration with disjoint generic/control/native lanes passes **19 files,
172/172**, types/lint/build and **nine native paths**,29 GameTable SSR renders/
16 actual guide callbacks plus one editor callback followed by native Ask.
Original base33 Basic20/Advanced23 prefixes add8: a true held-Hajr AND two moves
stays binding after the real physical Hajr is discarded. Edited true OR answers
Yes without a native binding; false AND answers No while a move stays legal.
False OR requires a real group, and true AND No forbids it. An untouched
non-Fremen forecast supplies unknown: OR Yes actually moves; AND No must avoid
that qualifying move. Charity13 adds14 actual later-phase/native steps.
All33 original cards remain physical; four policies actually answer and move.
Twelve generic,eight controlled and16 native cases retain shared nested limits,
eager fact snapshots, whole No, repairable drafts, original raw projection,
exact wire/stale packets and one shared guide. Two fixture type repairs precede
the passing frozen v2 proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-movement-proof-v2/`;
journeys/HTML: adjacent `20261026-truth-mixed-movement-runtime/`.
No saved-game/database/server/reset/restart/deployment operation is performed.
Other action families, correlated unknowns, wider combinations,
browser/mobile/deployed and final rules/assurance remain required.
[Authority, snapshot interpretation and evidence](TRUTHTRANCE_MIXED_MOVEMENT.md).
**Truthtrance / whole AND/OR ground moves — 26 October. Prototyped / Partial.** **Truthtrance / whole AND/OR ground moves — 26 October. Prototyped / Partial.**
Full grouped questions now combine future physical ground-group minima at Full grouped questions now combine future physical ground-group minima at
printed Arrakis destinations. Maximum-per-destination progress never sums printed Arrakis destinations. Maximum-per-destination progress never sums
+47
View File
@@ -1,5 +1,52 @@
# Dune implementation status # Dune implementation status
## 26 October 2026 — Current facts with future ground moves
**Prototyped / Partial.** Full mixed questions combine current facts and future
physical movement conditions under one16-leaf/depth4 budget including nested
facts. All facts are read before folding. Known values freeze at actual answer;
unknown takes false for Yes/true for No, then the residual must guarantee the
whole answer. No separate fact answers or reduced branches are published.
This conservative snapshot/compliance model remains provisional and does not
infer correlated unknowns or open complete-mode gates.
The reusable mixed compiler yields a constant aggregate or pure action tree.
Only a real residual binds the existing native movement-expression ledger;
raw metadata links original question/event without duplicating physical rules.
Decisive constants need no future movement. Original search, funding, physical
counters/cards, Hajr timing and actual owner deadline remain. New mixed discard
signatures include their metadata/residual without changing older record shape.
Public pure views filter those private residuals; only own original question,
aggregate status and recorded maxima are projected. Uniform early readiness and
no separate mixed movement-fulfillment log avoid direct compiled-branch cues.
Full authoring, guarded preview/Ask, private answers, one exact native guide and
four minimal profiles connect to actual actions; strategy remains deferred.
Root integration with disjoint generic/control/native lanes passes **19 files,
172/172**, types/lint/build and **nine native paths**,29 GameTable SSR renders/
16 actual guide callbacks plus one actual AND→OR editor callback then native Ask.
Genuine base33 Basic20/Advanced23-action prefixes add8: held Hajr is true at
answer, actual movement/Hajr disposal/second movement still fulfills AND after
current custody becomes false. Edited true OR is immediate Yes; false AND is
immediate No with legal movement. False OR requires a real group; true AND No
blocks one. Native unknown non-Fremen forecast OR Yes must move, AND No must
avoid the group. Original Charity13 adds14 real later-phase/native steps.
All33 cards remain physical. Four profiles actually answer and follow owned
movement preparation on both Basic/Advanced sources.
Twelve generic,eight controlled and16 native cases cover strict decisive
subtrees/forged constants, shared limits, eager immutable snapshots, known/
unknown whole answers, current card disposal, prior/max progress, exact wire
packets and owned-only raw projection. Initial type feedback repairs one nested
test union and two aggregate status fixture fields; the repaired frozen v2
source then passes every step. Proof/report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-movement-proof-v2/`;
journeys/HTML: adjacent `20261026-truth-mixed-movement-runtime/`.
No saved-game/database/server/reset/restart/deployment operation is performed.
Other action families, correlated-unknown reasoning, wider composition,
final rules/recovery/privacy/custody and browser/mobile/deployed acceptance remain.
[Contract](TRUTHTRANCE_MIXED_MOVEMENT.md).
## 26 October 2026 — Whole AND/OR future ground moves ## 26 October 2026 — Whole AND/OR future ground moves
**Prototyped / Partial.** Full grouped questions bind actual future physical **Prototyped / Partial.** Full grouped questions bind actual future physical
+14
View File
@@ -1,5 +1,19 @@
# Rule decision index # Rule decision index
## Current facts combined with future movement — 26 October 2026
Reuse recorded publisher FAQp8 current-turn/AND/OR authority and supplied
Advanced physical39 compliance/release/unknown authority. Current facts freeze
at answer time; the public original tree and aggregate never expose separate
frozen answers or compiled residuals. A definite Yes uses unknown facts as false
and a definite No uses them as true, then the remaining positive AND/OR movement
conditions must guarantee the selected answer. This conservative online
composition extends the existing mixed-shipment model; it is not a dedicated
publisher movement algorithm or correlated-unknown solver. Native obligations,
physical custody, opportunity/deadline and earlier funding stay. No mode gate,
save rewrite, server or publication operation is granted.
[Contract and evidence](TRUTHTRANCE_MIXED_MOVEMENT.md).
## Whole AND/OR future ground moves — 26 October 2026 ## Whole AND/OR future ground moves — 26 October 2026
Compose the recorded publisher November2020 FAQp8 current-turn/AND/OR authority Compose the recorded publisher November2020 FAQp8 current-turn/AND/OR authority
+122
View File
@@ -0,0 +1,122 @@
# Truthtrance current facts and future ground movement
## Mixed aggregate prototype — 26 October 2026
**Prototyped / Partial.** The packet is
`{kind:'mixedMovement',target,turn,mixed}`. The raw tree combines current
`{kind:'fact',fact}` leaves, future `{kind:'action',action:{territory,minimum}}`
leaves and full AND/OR. It uses the existing native Basic/Advanced phases0–5
[ground-movement opportunity](TRUTHTRANCE_MOVEMENT_EXPRESSIONS.md), with no new
mode/start permission. Both clause families are required in a mixed question.
## Authority and conservative snapshot meaning
Reuse the recorded [nonbattle source audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#source-basis),
[November2020 FAQp8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
current-turn/AND/OR authority and supplied Advanced physical39/printed37 public
answer/compliance/release/unknown text. These sources do not prescribe an
online solver. The existing [mixed-shipment model](COMPOUND_SHIPMENT_PROMISES.md)
provides the current-fact snapshot/aggregate precedent. This conservative
composition remains explicit provisional software interpretation under
standing14October choose-recommended priority.
Facts freeze at actual answer time. A fact about holding Hajr stays the
answer-time fact after the same physical card is played and discarded. Later
spending/forces/custody do not create a promise to preserve the earlier state.
Only the complete yes/no answer is public. Individual fact answers and privately
reduced branches are not separately published.
For a definite Yes, unknown facts take the worst case false; for No, the worst
case true. The remaining owned-action completion must guarantee that entire selected
answer. The formula is positive AND/OR, so the conservative case guarantees the
aggregate for all unresolved fact values. This does not infer correlations
between unknown predicates. If neither definite answer has a known completion,
unknown/save/change-question remains available. No negates the whole question,
not each fact or action leaf.
True Hajr OR move can settle Yes immediately. False Hajr AND move can settle No
immediately. True Hajr AND move retains its future movement requirement;
unknown forecast OR move Yes must actually move, while unknown forecast AND
move No must avoid the qualifying group. Ordinary per-destination maximum
progress, not summed smaller moves, remains.
## Native integration and controls
`game/mixed-action-question.ts` validates the full public tree before any
short-circuit, with one16-leaf/depth4 budget including nested fact groups. The
server's existing fact decoder and exact movement-leaf decoder remain authority.
Public constants, targets, turns and other metadata cannot be forged inside
clauses. The reusable compiler evaluates all fact leaves before folding known
constants/identity children/singletons into an action-only residual.
The engine converts that residual into the existing movement-expression ledger;
there is one physical fulfillment ledger, not a second set of movement/payment
rules. Raw `mixedMovementQuestions` metadata links the original question to its
binding event. A constant aggregate needs no native binding. Existing owned
search, card preservation, preparations, funding, range responses and actual
owner finish retain their original behavior. Early readiness resets uniformly
for definite mixed answers; no public branch-specific readiness cue is added.
New mixed discard signatures include its metadata/binding while older record
signatures keep their original shape.
Only own raw question, aggregate answer/status and recorded native movement
maxima are projected. Compiled residuals are filtered out of the public pure
movement ledger, and raw internal metadata/frozen fact answers are not exposed.
The native guide is still entitled only to its owner and exact live event.
No separate public movement-fulfillment message reveals whether a private fact
created a residual. Required whole-answer release and public physical actions
retain their original aggregate information.
The full controlled editor reuses current-fact and nested-fact fields, supports
add/wrap/nest/switch/remove/collapse and preserves repairable invalid drafts.
Preview/Ask retain the whole raw tree. The aggregate panel and pure/scalar
movement panels show one shared exact guide. Four minimal policies answer and
follow that native guide; strategy/calibration remain deferred.
## Evidence and remaining gates
Generic cases: `tests/mixed-action-question.test.ts`.
Controlled cases: `tests/truth-mixed-movement-controls.test.tsx`.
Original sources/cases: `tests/fixture-truth-mixed-movement.ts` and
`tests/truth-mixed-movement-runtime.test.ts`. Twelve generic,eight controlled
and16 native cases are included in the frozen **19-file,172/172** union.
Types/lint, production build and nine native paths pass on the same unchanged
source. Actual GameTable SSR renders29 times;16 actual guide callbacks submit
native packets. One actual mixed editor meaning callback retains the complete
raw tree through native Ask. Four minimal profiles actually answer and complete
the native Hajr path on both Basic/Advanced original sources.
| Original base33 source | Prefix actions | Further actions | Actual result |
| --- | ---: | ---: | --- |
| Basic frozen Hajr AND | 20 | 8 | Held-Hajr fact survives real disposal and two future groups |
| Advanced frozen Hajr AND | 23 | 8 | Same snapshot with native post-movement Hajr timing |
| Basic edited true OR | 20 | 5 | Actual AND→OR edit/Ask; immediate Yes, no native binding |
| Basic false AND | 20 | 6 | Immediate No; a real named move remains legal |
| Basic false OR | 20 | 6 | Yes requires the actual future group |
| Basic true AND No | 20 | 6 | Qualifying group rejects; nonmatching group/real finish allowed |
| Basic unknown OR Yes | 20 | 6 | Native unknown forecast cannot substitute for real movement |
| Basic unknown AND No | 20 | 5 | Qualifying movement forbidden until actual owner finish |
| Basic early Charity | 13 | 14 | Real later readiness/auction/shipment/movement path |
Initial type feedback repairs a nested generic test union and explicit status
fields on a controlled view; no production rule repair is needed for that
feedback. The passing v2 source snapshot is
`557dd5178963e1a868be972d1f44da91d4522d874fa38147970c8a106e9aad3a`
(3153 files). Final evidence prose is a separate three-document overlay.
Frozen proof/report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-movement-proof-v2/`;
journeys/HTML: adjacent `20261026-truth-mixed-movement-runtime/`.
SSR/editor/guide callbacks are local engine/control evidence, not DOM,
hydration, browser/mobile or deployed acceptance.
Sources inherit the original base33 Basic20/Advanced23 Hajr/Truthtrance setup
and paid Polar shipment, plus Charity13. Native non-Fremen forecast supplies an
actual unknown without installed knowledge. No extra clock, purse, hand, army,
fact answer, promise or progress is installed. Controlled projections retain
those labels rather than claiming original acquisition histories.
Other action families, correlated-unknown reasoning, optional modules, broader
faction/card/funding compositions, final rules/recovery/privacy/custody review
and browser/mobile/deployed acceptance remain required. No complete mode is
certified. No saved-game/database/server/reset/restart/deployment operation is
performed by this batch.
+4
View File
@@ -1,5 +1,9 @@
# Truthtrance combined future ground moves # Truthtrance combined future ground moves
The [mixed current-fact follow-up](TRUTHTRANCE_MIXED_MOVEMENT.md) adds a raw
mixed question compiled privately into these same native movement obligations.
Its original tree/aggregate is projected separately; compiled facts are hidden.
## Whole AND/OR prototype — 26 October 2026 ## Whole AND/OR prototype — 26 October 2026
**Prototyped / Partial.** A structured question combines future ordinary **Prototyped / Partial.** A structured question combines future ordinary
+4
View File
@@ -1353,6 +1353,10 @@ function policyActions(g: GameView): Action[] {
const answer = answers.find(answer => answer === 'yes') ?? answers[0]; const answer = answers.find(answer => answer === 'yes') ?? answers[0];
return answer ? [{ type: 'truthAnswer', answer }] : []; 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') { if (w.question.kind === 'movementExpression') {
const answer = g.truthMovementExpressionAnswers?.[0]; const answer = g.truthMovementExpressionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : []; return answer ? [{ type: 'truthAnswer', answer }] : [];
+69 -4
View File
@@ -1,5 +1,6 @@
import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question';
import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent, 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, import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates,
liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises, liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises,
type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises'; type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises';
@@ -599,6 +600,7 @@ import {
type TruthQueueEntry, type TruthQueueEntry,
type TruthRecord, type TruthRecord,
type TruthAnswer, type TruthAnswer,
type MixedMovementClaim,
} from './truthtrance'; } from './truthtrance';
import type { MixedShipmentExpression } from './mixed-shipment-question'; import type { MixedShipmentExpression } from './mixed-shipment-question';
import { controlsLeader, nativeAvailable } from './leader-control'; import { controlsLeader, nativeAvailable } from './leader-control';
@@ -2164,6 +2166,10 @@ export type Game = {
alliancePromises?: AlliancePromise[]; alliancePromises?: AlliancePromise[];
movementPromises?: MovementPromise[]; movementPromises?: MovementPromise[];
movementExpressionPromises?: MovementExpressionPromise[]; 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[]; movementPromiseSequence?: string[];
/** Server-only certainty of movement-relevant cards still in the discard. */ /** Server-only certainty of movement-relevant cards still in the discard. */
movementDiscardKnowledge?: MovementDiscardKnowledge; movementDiscardKnowledge?: MovementDiscardKnowledge;
@@ -6902,6 +6908,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
battle: g.battle, battle: g.battle,
shipmentPromises: g.shipmentPromises, shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises, movementPromises: g.movementPromises,
...(next.record.question.kind === 'mixedMovement' ? {
mixedMovementQuestions: g.mixedMovementQuestions, movementExpressionPromises: g.movementExpressionPromises,
} : {}),
active: g.active, active: g.active,
order: g.order, order: g.order,
status: g.status, status: g.status,
@@ -18607,6 +18616,56 @@ function bindMovementExpressionTruth(g: Game, p: Player, claim: MovementExpressi
(g.movementExpressionPromises ??= []).push(promise); (g.movementExpressionPromises ??= []).push(promise);
(g.movementPromiseSequence ??= []).push(promise.event); (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<MovementClaim>): 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 /** Nested native force records survive JSON transport; record key order is
* immaterial but fields, values and the originating guide event must match. */ * immaterial but fields, values and the originating guide event must match. */
function canonicalMovementGuideValue(value: unknown): unknown { 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)); } try { g.movementExpressionPromises = recordMovementExpressionEvent(expressions, p.id, g.turn, groundMovementEvent(move)); }
catch (error) { if (error instanceof MovementExpressionPromiseError) throw new RuleError(error.message); throw error; } catch (error) { if (error instanceof MovementExpressionPromiseError) throw new RuleError(error.message); throw error; }
g.movementExpressionPromises.forEach((promise, index) => { 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.`); log(g, `${p.name} fulfilled a whole Truthtrance movement answer with this committed move.`);
}); });
} }
@@ -36388,6 +36447,8 @@ function applyActionInner(
bindMovement: bindMovementTruth, bindMovement: bindMovementTruth,
movementExpressionAnswers: movementExpressionTruthAnswers, movementExpressionAnswers: movementExpressionTruthAnswers,
bindMovementExpression: bindMovementExpressionTruth, bindMovementExpression: bindMovementExpressionTruth,
mixedMovementAnswers: mixedMovementTruthAnswers,
bindMixedMovement: bindMixedMovementTruth,
allianceAnswers: allianceTruthAnswers, allianceAnswers: allianceTruthAnswers,
bindAlliance: bindAllianceTruth, bindAlliance: bindAllianceTruth,
bribeAnswers: bribeTruthAnswers, bribeAnswers: bribeTruthAnswers,
@@ -36411,7 +36472,7 @@ function applyActionInner(
) )
return g; return g;
} catch (error) { } 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 new RuleError(error.message);
throw error; 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), truthMovementExpressionTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
truthMovementExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'movementExpression' && truthMovementExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'movementExpression' &&
g.truthtrance.question.target === id ? movementExpressionTruthAnswers(g, me, g.truthtrance.question) : null, 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), movementExpressionPromiseGuide: projectedMovementExpressionGuide(g, me),
truthShipmentSupported: g.players.some(p => shipmentPromiseModeSupported(g, p.id)), truthShipmentSupported: g.players.some(p => shipmentPromiseModeSupported(g, p.id)),
truthShipmentTargets: !g.response && !g.decision && !g.phaseOpening && !g.pendingShipment truthShipmentTargets: !g.response && !g.decision && !g.phaseOpening && !g.pendingShipment
+116
View File
@@ -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<A> =
| { kind: 'fact'; fact: TruthFact }
| { kind: 'action'; action: A }
| { kind: 'and' | 'or'; terms: MixedActionExpression<A>[] };
/** Private answer-time result. Residual groups contain only actions and groups. */
export type CompiledMixedAction<A> =
| { kind: 'constant'; value: boolean }
| { kind: 'action'; action: A }
| { kind: 'and' | 'or'; terms: CompiledMixedAction<A>[] };
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<string, unknown> {
check(value && typeof value === 'object' && !Array.isArray(value),
'Choose a current fact, future action, or AND/OR group.');
return value as Record<string, unknown>;
}
/** 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<A>(
value: unknown,
parseFact: (value: unknown) => TruthFact,
parseAction: (value: unknown) => A,
limits: MixedActionExpressionLimits = {},
): MixedActionExpression<A> {
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<A> => {
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<A>(
mixed: MixedActionExpression<A>,
answerFact: (fact: TruthFact) => TruthAnswer,
answer: boolean,
): CompiledMixedAction<A> {
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<A>(
mixed: MixedActionExpression<A>,
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 ')})`;
}
+27
View File
@@ -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'] }, { 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', id: 'truthtrance-movement-expression',
title: 'Truthtrance: combined ground moves', 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.', '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 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 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 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 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.', '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-shipment',
'truthtrance-movement', 'truthtrance-movement',
'truthtrance-movement-expression', 'truthtrance-movement-expression',
'truthtrance-mixed-movement',
'truthtrance-bid', 'truthtrance-bid',
'truthtrance-revival', 'truthtrance-revival',
'truthtrance-leader-revival', 'truthtrance-leader-revival',
+27
View File
@@ -1,3 +1,4 @@
import { parseMixedActionExpression, mixedActionExpressionText, type MixedActionExpression } from './mixed-action-question';
import { parseMovementExpressionClaim, movementExpressionText, type MovementExpressionClaim } from './movement-expression-promises'; import { parseMovementExpressionClaim, movementExpressionText, type MovementExpressionClaim } from './movement-expression-promises';
import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionText, import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionText,
type BribeExpressionClaim } from './bribe-expression-promises'; type BribeExpressionClaim } from './bribe-expression-promises';
@@ -67,7 +68,9 @@ export type TruthFact =
| { kind: 'traitor'; leader: string } | { kind: 'traitor'; leader: string }
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number } | { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
| { kind: 'and' | 'or'; terms: TruthFact[] }; | { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
export type TruthQuestion = export type TruthQuestion =
| ({ kind: 'mixedMovement'; target: string } & MixedMovementClaim)
| ({ kind: 'movementExpression'; target: string } & MovementExpressionClaim) | ({ kind: 'movementExpression'; target: string } & MovementExpressionClaim)
| ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim) | ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim)
| ({ kind: 'revivalExpression'; target: string } & RevivalExpressionClaim) | ({ 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.'); '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 }; 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') { if (v.kind === 'movementExpression') {
check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn', check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn',
'Supply the current turn, complete movement expression and respondent.'); '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 `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 ')})`; 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' return `Is it true that ${q.kind === 'mixedShipment'
? mixedShipmentExpressionText(q.mixed,clause) : clause(q.fact)}?`; ? mixedShipmentExpressionText(q.mixed,clause) : clause(q.fact)}?`;
} }
@@ -587,6 +604,8 @@ export function resolveTruthAction(
asker: string, asker: string,
mixed?: MixedShipmentExpression, mixed?: MixedShipmentExpression,
) => void; ) => 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[]; movementExpressionAnswers?: (g: Game, p: Player, claim: MovementExpressionClaim) => TruthAnswer[];
bindMovementExpression?: (g: Game, p: Player, claim: MovementExpressionClaim, answer: boolean, asker: string) => void; bindMovementExpression?: (g: Game, p: Player, claim: MovementExpressionClaim, answer: boolean, asker: string) => void;
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[]; movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
@@ -732,6 +751,9 @@ export function resolveTruthAction(
if (w.question.kind === 'alliance') if (w.question.kind === 'alliance')
check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 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.'); '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') if (w.question.kind === 'movementExpression')
check(effects.movementExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, check(effects.movementExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a supported whole ground-movement question.'); 'Choose a supported whole ground-movement question.');
@@ -792,6 +814,11 @@ export function resolveTruthAction(
effects.bindShipment(g, p, claim, a.answer === 'yes', current.player, effects.bindShipment(g, p, claim, a.answer === 'yes', current.player,
q.kind === 'mixedShipment' ? q.mixed : undefined); 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') { if (q.kind === 'movementExpression') {
check(effects.movementExpressionAnswers && effects.bindMovementExpression && check(effects.movementExpressionAnswers && effects.bindMovementExpression &&
effects.movementExpressionAnswers(g, p, q).includes(a.answer), effects.movementExpressionAnswers(g, p, q).includes(a.answer),
+79
View File
@@ -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<MovementClaim>;
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<typeof createTruthMixedMovementFixture>;
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;
}
+259
View File
@@ -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<MovementClaim>;
type Compiled = CompiledMixedAction<MovementClaim>;
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<Record<string, boolean>>): 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);
});
@@ -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<MovementClaim>;
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
const 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<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function find(tree: ReactNode, key: string, value: string): Element {
const found = elements(tree).find(element => element.props[key] === value);
assert.ok(found, `${key}=${value}`); return found;
}
const control = (tree: ReactNode, key: string) => find(tree, 'data-mixed-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<typeof MovementExpressionPromiseGuideButton>[0]) {
const button = MovementExpressionPromiseGuideButton(props); assert.ok(button); return button;
}
const press = (button: ReturnType<typeof guide>) => 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<string, number>)['arrakeen:10'] = 2; },
game => { (game.movementExpressionPromiseGuide!.action.eliteForces as Record<string, number>)['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), /<button/);
}
const game = projected(); game.active = 'a'; game.movementExpressionPromiseGuide = null;
assert.match(markup(game), /Your Movement turn is still ahead/);
game.active = 'p'; game.movementExpressionPromiseGuide = projected().movementExpressionPromiseGuide;
assert.match(markup(game), /data-testid="movement-expression-promise-guide"/);
game.movementPromises.push({ player: 'p', asker: 'a', turn: 2, territory: 'carthag', minimum: 4, answer: true });
assert.doesNotMatch(markup(game), /data-testid="movement-expression-promise-guide"/);
game.movementPromises[0].fulfilled = true;
game.movementExpressionPromises.push({ event: 'controlled-pure-event', player: 'p', asker: 'a', turn: 2,
claim: { kind: 'move', territory: 'carthag', minimum: 4 }, answer: true, progress: [] });
assert.doesNotMatch(markup(game), /data-testid="movement-expression-promise-guide"/);
const pureHtml = renderToStaticMarkup(createElement(MovementExpressionPromises, { game, busy: false, send() {} }));
assert.match(pureHtml, /data-testid="movement-expression-promise-guide"/);
game.movementExpressionPromises[0].fulfilled = true; assert.match(markup(game), /data-testid="movement-expression-promise-guide"/);
const historicalPureHtml = renderToStaticMarkup(createElement(MovementExpressionPromises, { game, busy: false, send() {} }));
assert.doesNotMatch(historicalPureHtml, /data-testid="movement-expression-promise-guide"/);
game.mixedMovementPromises[0].fulfilled = true;
assert.doesNotMatch(markup(game), /data-testid="movement-expression-promise-guide"|Your Movement turn is still ahead/);
});
+262
View File
@@ -0,0 +1,262 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
import {
actTruthMixedMovement as act, advanceTruthMixedMovement as advance,
answerTruthMixedMovement as answer, askTruthMixedMovement as ask, bindTruthMixedMovement as bind,
completeTruthMixedMovement as complete, createTruthMixedMovementFixture as fixture,
declareTruthMixedMovement as declare, finishTruthMixedMovement as finish, settleTruthMixedMovement as settle,
truthMixedMovementClean as clean, truthMixedMovementFact as fact, truthMixedMovementGroup as group,
truthMixedMovementInventory as inventory, truthMixedMovementMove as move, truthMixedMovementPlayer as player,
truthMixedMovementPromise as promise, type TruthMixedMovementExpression,
} from './fixture-truth-mixed-movement';
const maximum = (game: Game, territory: string) =>
promise(game).progress.find(entry => entry.territory === territory)?.maximum ?? 0;
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physicalTruthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} mixed Hajr fact AND two moves freezes custody before real Hajr disposal`, () => {
const f = fixture({ advanced }); assert.equal(f.setup.status, 'setup'); assert.equal(f.originalCards.length, 33);
assert.equal(f.sourceActionCount, advanced ? 23 : 20); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(f.game.expansions, []); assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 2).map(card => card.id), f.deal);
assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 2);
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10, 'polar_sink:0': 1 });
assert.equal(truthFactAnswer(player(f.game, f.target), f.hajrFact), 'yes');
const source = structuredClone(f.game); let game = bind(f).game; assert.deepEqual(f.game, source);
assert.deepEqual(promise(game).mixed, f.mixed); assert.deepEqual(promise(game).progress, []);
physicalTruthOnce(game, f.truth); rejected(game, f.target, { type: 'endMovement' });
if (advanced) rejected(game, f.target, f.hajrAction!);
game = settle(act(game, f.target, f.firstMove));
assert.equal(maximum(game, 'imperial_basin'), 1); assert.equal(maximum(game, 'carthag'), 0);
assert.ok(!promise(game).fulfilled && !promise(game).released);
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.hajr);
const progress = structuredClone(promise(game).progress);
game = settle(act(game, f.target, guide.action));
assert.equal(truthFactAnswer(player(game, f.target), f.hajrFact), 'no');
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
assert.deepEqual(promise(game).progress, progress, 'Physical Hajr grants an allowance without moving a force');
assert.ok(!promise(game).fulfilled, 'Discarding the formerly held Hajr cannot rewrite the frozen true fact');
rejected(game, f.target, { type: 'endMovement' });
game = settle(act(game, f.target, f.secondMove));
assert.equal(promise(game).fulfilled, true); assert.equal(maximum(game, 'carthag'), 1);
assert.equal(player(game, f.target).moved, 2); assert.equal(player(game, f.target).forces['arrakeen:10'], 8);
game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native true held Hajr OR future move is immediate Yes with no residual movement binding', () => {
const f = fixture(), mixed = group('or', fact(f.hajrFact), move()), declared = declare(f);
const asked = ask(declared.game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedMovementAnswers, ['yes']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' });
let game = answer(asked, f.target, 'yes');
assert.equal(promise(game).fulfilled, true); assert.deepEqual(promise(game).progress, []);
assert.deepEqual(promise(game).mixed, mixed); assert.equal(game.movementExpressionPromises?.length ?? 0, 0);
assert.equal(viewGame(game, f.target).movementExpressionPromiseGuide, null);
physicalTruthOnce(game, f.truth); game = finish(game, f.target);
assert.equal(player(game, f.target).moved, 0); assert.ok(player(game, f.target).hand.some(card => card.id === f.hajr));
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false held projectile OR future movement Yes requires an actual qualifying ground group', () => {
const f = fixture(), mixed = group('or', fact(f.projectileFact), move());
assert.equal(truthFactAnswer(player(f.game, f.target), f.projectileFact), 'no');
let game = bind(f, 'yes', mixed).game;
assert.ok(!promise(game).fulfilled); assert.deepEqual(promise(game).progress, []);
rejected(game, f.target, { type: 'endMovement' });
game = settle(act(game, f.target, f.firstMove));
assert.equal(promise(game).fulfilled, true); assert.equal(maximum(game, 'imperial_basin'), 1);
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
void test('native actual projectile makes false Hajr AND future movement immediate No and leaves either movement legal', () => {
const f = fixture({ hajr: false }), mixed = group('and', fact(f.hajrFact), move()), declared = declare(f);
assert.equal(player(f.game, f.target).hand.find(card => card.id === f.targetCard)?.kind, 'projectile');
assert.equal(truthFactAnswer(player(f.game, f.target), f.hajrFact), 'no');
const asked = ask(declared.game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedMovementAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const bound = answer(asked, f.target, 'no');
assert.equal(promise(bound).fulfilled, true); assert.equal(bound.movementExpressionPromises?.length ?? 0, 0);
for (const action of [f.firstMove, f.secondMove]) {
const moved = settle(act(bound, f.target, action));
assert.equal(player(moved, f.target).moved, 1); assert.equal(promise(moved).fulfilled, true);
assert.deepEqual(promise(moved).progress, []); assert.deepEqual(inventory(finish(moved, f.target)), f.originalCards);
}
physicalTruthOnce(bound, f.truth);
});
void test('native true Hajr AND future movement No blocks its qualifying move until actual owner finish', () => {
const f = fixture(); let game = bind(f, 'no', group('and', fact(f.hajrFact), move())).game;
rejected(game, f.target, f.firstMove); assert.ok(!promise(game).fulfilled);
game = settle(act(game, f.target, f.secondMove));
assert.equal(player(game, f.target).forces['carthag:11'], 1); assert.deepEqual(promise(game).progress, []);
assert.ok(!promise(game).fulfilled); game = finish(game, f.target);
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native actual projectile fact cannot supply a missing second movement allowance for mixed AND Yes', () => {
const f = fixture({ hajr: false }), mixed = group('and', fact(f.projectileFact), move(), move('carthag'));
assert.equal(truthFactAnswer(player(f.game, f.target), f.projectileFact), 'yes');
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedMovementAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'no'); game = settle(act(game, f.target, f.firstMove));
assert.equal(maximum(game, 'imperial_basin'), 1); assert.ok(!promise(game).fulfilled);
game = finish(game, f.target); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed future OR retains both actual destinations instead of selecting a residual branch in advance', () => {
const f = fixture(), mixed = group('or', group('and', fact(f.hajrFact), move()), move('carthag'));
const bound = bind(f, 'yes', mixed).game;
for (const action of [f.firstMove, f.secondMove]) {
const game = settle(act(bound, f.target, action));
assert.equal(promise(game).fulfilled, true); assert.equal(maximum(game, String(action.territory)), 1);
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
}
});
void test('native mixed thresholds retain maximum actual group rather than adding two smaller Hajr moves', () => {
const f = fixture(), mixed = group('and', fact(f.hajrFact), move(), move('imperial_basin', 2));
let game = bind(f, 'no', mixed).game;
game = settle(act(game, f.target, f.firstMove)); game = settle(act(game, f.target, f.hajrAction!));
game = settle(act(game, f.target, f.sameDestinationMove));
assert.equal(player(game, f.target).forces['imperial_basin:9'], 2);
assert.deepEqual(promise(game).progress, [{ territory: 'imperial_basin', maximum: 1 }]);
assert.ok(!promise(game).fulfilled); game = finish(game, f.target);
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native newly answered mixed movement excludes actual movement before the answer', () => {
const f = fixture(); let game = settle(act(f.game, f.target, f.firstMove));
game = bind(f, 'yes', group('and', fact(f.hajrFact), group('or', move(), move('carthag'))), game).game;
assert.deepEqual(promise(game).progress, []); assert.ok(!promise(game).fulfilled);
rejected(game, f.target, { type: 'endMovement' });
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.hajr);
game = settle(act(game, f.target, guide.action)); game = settle(act(game, f.target, f.secondMove));
assert.deepEqual(promise(game).progress, [{ territory: 'carthag', maximum: 1 }]);
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native unknown non-Fremen forecast uses the conservative whole ${answerValue} movement constraint`, () => {
const f = fixture(), p = player(f.game, f.target);
assert.equal(p.faction, 'atreides'); assert.equal(truthKnowledgeOf(f.game, p).stormForecast, null);
assert.equal(truthFactAnswer(p, f.forecastFact, truthKnowledgeOf(f.game, p)), 'unknown');
const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), move());
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedMovementAnswers, [answerValue]);
rejected(asked, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
let game = answer(asked, f.target, answerValue);
assert.ok(!promise(game).fulfilled); assert.deepEqual(promise(game).progress, []);
if (answerValue === 'yes') {
rejected(game, f.target, { type: 'endMovement' }); game = settle(act(game, f.target, f.firstMove));
assert.equal(promise(game).fulfilled, true); assert.equal(maximum(game, 'imperial_basin'), 1);
} else rejected(game, f.target, f.firstMove);
game = finish(game, f.target); assert.equal(promise(game).fulfilled, true);
physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed projection retains only the owned raw question and aggregate while hiding frozen residual facts', () => {
const f = fixture(), bound = bind(f), game = bound.game;
for (const actor of [f.target, f.asker]) {
const view = viewGame(game, actor);
assert.ok(!('mixedMovementQuestions' in view)); assert.deepEqual(view.movementExpressionPromises, []);
assert.deepEqual(view.truthHistory.at(-1)?.question, f.question);
assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
if (actor === f.asker) {
assert.deepEqual(view.mixedMovementPromises, []); assert.equal(view.movementExpressionPromiseGuide, null);
assert.equal(view.truthMixedMovementAnswers, null);
} else {
const row = promise(game);
const required = ['answer', 'asker', 'event', 'mixed', 'player', 'progress', 'turn'];
const allowed = [...required, 'fulfilled', 'released'];
assert.ok(required.every(key => Object.keys(row).includes(key)));
assert.ok(Object.keys(row).every(key => allowed.includes(key)), 'The aggregate excludes every internal binding or frozen clause');
assert.deepEqual(row.mixed, f.mixed); assert.deepEqual(row.progress, []);
assert.equal(row.asker, f.asker); assert.equal(row.player, f.target); assert.equal(row.turn, game.turn);
assert.ok(view.movementExpressionPromiseGuide?.action.movementExpressionPromiseEvent);
}
}
assert.ok(game.movementExpressionPromises?.length, 'Only the internal game retains the native binding');
const residual = game.movementExpressionPromises![0];
assert.equal(residual.event, promise(game).event);
assert.ok(!JSON.stringify(promise(game)).includes('"claim"'));
});
void test('native exact mixed movement guides keep ownership event and nested wire packets authoritative', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthMixedMovementTargets.includes(f.target));
rejected(declared.game, f.target, f.questionAction);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
const asked = ask(declared.game, f.asker, f.target, f.mixed);
assert.equal(viewGame(asked, f.asker).truthMixedMovementAnswers, null);
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'yes');
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'move'); assert.ok(guide.action.forces);
assert.equal(guide.action.movementExpressionPromiseEvent, promise(game).event);
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, movementExpressionPromiseEvent: 'stale-mixed-movement-event' });
const wire: Action = JSON.parse(JSON.stringify(guide.action));
const modified = structuredClone(wire), forces = modified.forces as Record<string, number>;
const origin = Object.keys(forces)[0]; assert.ok(origin); forces[origin] += 1;
rejected(game, f.target, modified);
const event = promise(game).event; game = settle(act(game, f.target, wire));
assert.equal(promise(game).event, event); assert.ok(promise(game).progress.length > 0);
rejected(game, f.target, wire); game = complete(game, f.target); rejected(game, f.target, guide.action);
assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
});
void test('all four minimal policies answer the mixed question and follow the exact native Hajr movement guides', () => {
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked);
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const answerAction = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
assert.equal(answerAction.answer, 'yes', difficulty);
assert.ok(answering.truthMixedMovementAnswers?.includes('yes'));
game = act(game, f.target, answerAction);
for (let count = 0; count < 8 && !promise(game).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.movementExpressionPromiseGuide; assert.ok(guide, difficulty);
const action = botActions(view).find(action => action.movementExpressionPromiseEvent === guide.action.movementExpressionPromiseEvent);
assert.ok(action, difficulty); assert.deepEqual(action, guide.action);
game = settle(act(game, f.target, action));
}
assert.equal(promise(game).fulfilled, true, difficulty); physicalTruthOnce(game, f.truth);
assert.equal(player(game, f.target).moved, 2); assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
}
});
void test('native Charity mixed promise follows original Bidding Revival paid shipment and actual ground movement', () => {
const f = fixture({ window: 'charity' }); assert.equal(f.game.phase, 2); assert.equal(f.game.turn, 1);
assert.equal(f.sourceActionCount, 13); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.ok(f.sourceActionCount < f.sourceCounts.inheritedActive);
const mixed: TruthMixedMovementExpression = group('and', fact(f.hajrFact), move(), move('carthag'));
let game = bind(f, 'yes', mixed).game; assert.deepEqual(promise(game).progress, []);
rejected(game, f.target, f.firstMove); rejected(game, f.target, f.hajrAction!);
const actions: { actor: string; action: Action }[] = [];
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state), actions);
assert.ok(actions.some(step => step.action.type === 'passBid')); assert.ok(actions.some(step => step.action.type === 'ready'));
assert.equal(player(game, f.target).shipped, false); assert.deepEqual(promise(game).progress, []);
const before = player(game, f.target).spice;
game = settle(act(game, f.target, f.shipmentStep.action, actions), actions);
assert.equal(before - player(game, f.target).spice, 2); assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
assert.deepEqual(promise(game).progress, [], 'The real reserve shipment is not a ground move');
game = complete(game, f.target, actions); assert.equal(promise(game).fulfilled, true);
assert.equal(maximum(game, 'imperial_basin'), 1); assert.equal(maximum(game, 'carthag'), 1);
assert.deepEqual(inventory(finish(game, f.target, actions)), f.originalCards);
});