Support whole AND/OR future ground moves
This commit is contained in:
1 parent
0e3c155876
commit
d524642b65
20 files changed
+1803
-63
No files matched your search
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { TraitorDecisionPromises } from './traitor-decision-promises';
|
||||
import { MovementPromises } from './movement-promises';
|
||||
import { MovementExpressionPromises } from './movement-expression-promises';
|
||||
import { HomeworldEcazDuke } from './homeworld-ecaz-duke';
|
||||
import { integerInputValue } from '../lib/integer-input';
|
||||
import { normalAuctionHandBlock } from '@/game/normal-auction';
|
||||
@@ -1987,6 +1988,7 @@ export function GameTable({
|
||||
<NexusFremenRideBetrayal game={g} act={act} busy={reactionBusy} />
|
||||
<HomeworldEcazDuke game={g} act={act} busy={reactionBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||||
<MovementPromises game={g} act={act} busy={reactionBusy} />
|
||||
<MovementExpressionPromises game={g} send={act} busy={reactionBusy} />
|
||||
{!reactionOwnsControls && g.semutaReaction && <SemutaReaction game={g} act={act} busy={reactionBusy} />}
|
||||
{g.karamaBlocked && <p className="notice" role="status">{g.karamaBlocked}</p>}
|
||||
{g.karamaPurposeBlocks?.map(({purpose, message}) => <p key={purpose} className="notice" role="status">{message}</p>)}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
parseMovementExpression, MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES,
|
||||
type MovementExpression,
|
||||
} from '@/game/movement-expression-promises';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
type TerritoryChoice = { id: string; name: string };
|
||||
export function movementExpressionInputError(value: unknown): string | null {
|
||||
try { parseMovementExpression(value); return null; }
|
||||
catch (error) { return error instanceof Error ? error.message : 'Choose a valid ground-movement expression.'; }
|
||||
}
|
||||
|
||||
// Structural edits preserve repairable destination and numeric drafts. The
|
||||
// detached valid placeholders are used only to check tree shape and limits.
|
||||
const structuralDraft = (node: MovementExpression): MovementExpression => node.kind === 'move'
|
||||
? { ...node, territory: 'carthag', minimum: 1 }
|
||||
: { ...node, terms: node.terms.map(structuralDraft) };
|
||||
const inputSize = (node: MovementExpression): { leaves: number; depth: number } => {
|
||||
if (node.kind === 'move') return { leaves: 1, depth: 0 };
|
||||
const sizes = node.terms.map(inputSize);
|
||||
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
|
||||
};
|
||||
|
||||
/** Hook-free controlled tree. The server decides future route feasibility. */
|
||||
export function MovementExpressionFields({ value, territories, disabled, onChange }: {
|
||||
value: MovementExpression;
|
||||
territories: readonly TerritoryChoice[];
|
||||
disabled: boolean;
|
||||
onChange: (expression: MovementExpression) => void;
|
||||
}) {
|
||||
const newLeaf = (): MovementExpression => ({ kind: 'move', territory: territories[0]?.id ?? 'carthag', minimum: 1 });
|
||||
const size = inputSize(value), error = movementExpressionInputError(value);
|
||||
const commit = (next: MovementExpression) => {
|
||||
if (!disabled && !movementExpressionInputError(structuralDraft(next))) onChange(next);
|
||||
};
|
||||
const render = (node: MovementExpression, path: string, replace: (next: MovementExpression) => MovementExpression,
|
||||
remove?: () => MovementExpression): ReactElement => {
|
||||
const id = `truth-movement-expression-${path}`;
|
||||
const allowed = (next: MovementExpression) => !disabled && !movementExpressionInputError(structuralDraft(replace(next)));
|
||||
const change = (next: MovementExpression) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): MovementExpression => ({ kind, terms: [node, newLeaf()] });
|
||||
return <fieldset key={path} data-movement-expression-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{node.kind === 'move' ? `Movement condition ${path}` : `Movement group ${path} · ${node.kind.toUpperCase()}`}</legend>
|
||||
{node.kind === 'move' ? <>
|
||||
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
|
||||
<select id={`${id}-territory`} value={territories.some(destination => destination.id === node.territory) ? node.territory : ''}
|
||||
disabled={disabled} aria-invalid={!territories.some(destination => destination.id === node.territory)}
|
||||
onChange={event => change({ ...node, 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.minimum) ? '' : node.minimum} aria-invalid={!!movementExpressionInputError(node)}
|
||||
onChange={event => change({ ...node, minimum: event.currentTarget.valueAsNumber })} />
|
||||
</> : <>
|
||||
<label htmlFor={`${id}-meaning`}>Movement 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-movement-expression-control={`add-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newLeaf()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newLeaf()] })}>Add movement condition</Button>
|
||||
{(['and', 'or'] as const).map(kind => {
|
||||
const next: MovementExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] };
|
||||
return <Button key={kind} type="button" data-movement-expression-control={`nested-${kind}-${path}`}
|
||||
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
|
||||
})}
|
||||
</div>
|
||||
</>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-movement-expression-control={`${kind}-${path}`}
|
||||
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
|
||||
{remove && <Button type="button" data-movement-expression-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {node.kind === 'move' ? 'condition' : 'group'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="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>Ground-movement expression</legend>
|
||||
<p className="fine">
|
||||
AND requires every condition; OR requires at least one. No makes the whole expression false,
|
||||
not necessarily every condition. Legal alternatives remain available; the question does not pin a fixed branch.
|
||||
Each condition needs one future actual ground move after the answer, before your Movement turn ends this round.
|
||||
Smaller moves never add together; a shipment can prepare a move but does not fulfill a condition.
|
||||
</p>
|
||||
<p className="fine">App input limits: {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions and {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping.
|
||||
Groups do not count as conditions. 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>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { liveMovementExpressionPromises, movementExpressionTerritories, movementExpressionText } from '@/game/movement-expression-promises';
|
||||
import { liveMovementPromises } from '@/game/movement-promises';
|
||||
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type MovementExpressionPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
const destinationName = (game: GameView, id: string) => game.combatLocations.find(location => location.id === id)?.name ?? id;
|
||||
|
||||
/** Forward one exact native quote. This guide does not choose a future branch
|
||||
* or reconstruct physical forces, card entitlement or payment legality. */
|
||||
export function MovementExpressionPromiseGuideButton(props: MovementExpressionPromiseProps) {
|
||||
const game = props.game, guide = game.movementExpressionPromiseGuide;
|
||||
if (!guide) return null;
|
||||
const actor = game.me, turn = game.turn, phase = guide.phase, action = guide.action;
|
||||
const event = action.movementExpressionPromiseEvent, signature = JSON.stringify(action);
|
||||
const canAct = () => {
|
||||
const current = props.game, me = current.players.find(player => player.id === current.me);
|
||||
const paidBox = action.type === 'decision' && current.decision?.kind === 'nullentropy' &&
|
||||
current.decision.player === actor && !!current.nullentropy?.search &&
|
||||
current.nullentropy.search.event === action.event && !current.nullentropy.blocked;
|
||||
const payment = phase === 3 && action.type === 'decision' &&
|
||||
current.decision?.kind === 'auctionPayment' && current.decision.player === actor;
|
||||
const native = current.richeseAuction;
|
||||
const nativePass = phase === 3 && action.type === 'richeseBid' && native &&
|
||||
native.event === action.event && !native.outcome && native.eligible.includes(actor) &&
|
||||
(native.method === 'silent' ? !native.submitted.includes(actor) && action.amount === 0
|
||||
: native.active === actor && action.amount === null) && action.allyPayment === 0;
|
||||
const revivalOrder = current.revivalOrder;
|
||||
const opportunity = paidBox || (phase === 2
|
||||
? !current.ready.includes(actor) && (action.type === 'charity' || action.type === 'ready')
|
||||
: phase === 3
|
||||
? payment || nativePass || action.type === 'passBid' && !native && current.auction?.active === actor
|
||||
: phase === 4
|
||||
? !current.ready.includes(actor) && (action.type === 'revive' || action.type === 'ready') &&
|
||||
(!revivalOrder || revivalOrder.turn === turn && revivalOrder.remaining[0] === actor && current.active === actor)
|
||||
: phase === 5 && current.active === actor &&
|
||||
['move', 'ship', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type));
|
||||
return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
|
||||
Number.isSafeInteger(phase) && phase >= 0 && phase <= 5 && current.phase === phase && current.turn === turn && current.me === actor &&
|
||||
current.movementExpressionPromiseGuide === guide && current.movementExpressionPromiseGuide.phase === phase &&
|
||||
current.movementExpressionPromiseGuide.action === action && JSON.stringify(action) === signature && opportunity &&
|
||||
typeof event === 'string' && liveMovementExpressionPromises(current.movementExpressionPromises, actor, turn).some(promise => promise.event === event) &&
|
||||
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || payment) &&
|
||||
!current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
|
||||
};
|
||||
const amount = action.forces && typeof action.forces === 'object'
|
||||
? Object.values(action.forces as Record<string, number>).reduce((sum, count) => sum + count, 0)
|
||||
: typeof action.amount === 'number' ? action.amount : 0;
|
||||
const destination = typeof action.territory === 'string' ? destinationName(game, action.territory) : 'the chosen destination';
|
||||
const sector = typeof action.sector === 'number' ? action.sector : 0;
|
||||
// These already-projected own faces supply labels only, never eligibility.
|
||||
const card = (action.type === 'card' || action.type === 'decision')
|
||||
? game.players.find(player => player.id === actor)?.hand?.find(card => card.id === action.card) : undefined;
|
||||
const recovered = action.type === 'decision' && game.decision?.kind === 'nullentropy'
|
||||
? game.nullentropy?.search?.cards.find(card => card.id === action.card) : undefined;
|
||||
const payment = action.type === 'decision' && game.decision?.kind === 'auctionPayment';
|
||||
const label = payment ? action.karama === true ? `Use ${card?.name ?? 'your card'} for the current auction payment` : 'Pay the current auction price'
|
||||
: action.type === 'decision' ? `Choose ${recovered?.name ?? 'the required card'} in your paid Box search`
|
||||
: action.type === 'charity' ? 'Claim Charity for your later movement'
|
||||
: action.type === 'revive' ? `Revive ${amount} forces for your later movement`
|
||||
: action.type === 'ready' ? 'Finish this preparation phase while preserving your promises'
|
||||
: action.type === 'passBid' ? 'Pass this bid and preserve later movement'
|
||||
: action.type === 'richeseBid' ? action.amount === 0 ? 'Submit zero in this Silent auction and preserve later movement' : 'Pass this Richese bid and preserve later movement'
|
||||
: action.type === 'card' && action.mode === 'shipment' ? `Use ${card?.name ?? 'your card'} for Guild shipment rates`
|
||||
: action.type === 'card' && card?.effect === 'nullentropyBox' ? 'Pay 2 spice and search with Nullentropy Box'
|
||||
: action.type === 'card' && card?.effect === 'ghola' ? `Play Ghola to return ${amount} ${amount === 1 ? 'force' : 'forces'} to reserves for the required movement`
|
||||
: action.type === 'card' && action.mode === 'choam' ? 'Play Kulon to extend the required movement'
|
||||
: action.type === 'prepareAdvisors' ? `Prepare advisors in ${destination} for the required movement`
|
||||
: action.type === 'revealNoField' ? 'Reveal your No-Field to prepare physical forces for the required movement'
|
||||
: action.type === 'card' ? 'Play Hajr to prepare the required movement'
|
||||
: action.type === 'ship' && action.noField ? `Ship a concealed No-Field to ${destination}, sector ${sector}, before the required movement`
|
||||
: action.type === 'ship' ? `Ship ${amount} forces to ${destination}, sector ${sector}, before the required movement`
|
||||
: action.type === 'endMovement' ? 'Finish movement while honoring the whole expression'
|
||||
: action.noField && !amount ? `Move your concealed No-Field to ${destination}, sector ${sector}, to prepare the required movement`
|
||||
: action.noField ? `Move ${amount} physical forces with your No-Field to ${destination}, sector ${sector}`
|
||||
: `${action.movementCard ? 'Use Ornithopter and move' : 'Move'} ${amount} forces to ${destination}, sector ${sector}`;
|
||||
return <Button className="game-action min-h-11 whitespace-normal" data-testid="movement-expression-promise-guide"
|
||||
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>{label}</Button>;
|
||||
}
|
||||
|
||||
export function MovementExpressionPromises(props: MovementExpressionPromiseProps) {
|
||||
const game = props.game, own = game.movementExpressionPromises.filter(promise => promise.player === game.me);
|
||||
if (!own.length) return null;
|
||||
const live = liveMovementExpressionPromises(own, game.me, game.turn).length > 0;
|
||||
const waiting = live && (game.phase < 5 || game.phase === 5 && game.active !== game.me);
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance movement expression promises" id="movement-expression-promise-guidance">
|
||||
<h3>Your movement expression promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {movementExpressionText(promise.claim)}.
|
||||
{' '}Largest future ground move to each destination: {movementExpressionTerritories(promise.claim).map(territory =>
|
||||
`${destinationName(game, territory)}: ${promise.progress.find(progress => progress.territory === territory)?.maximum ?? 0} physical forces`).join('; ')}.
|
||||
{promise.released ? ' Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This whole answer is binding until your Movement turn ends this round.'}
|
||||
</li>)}</ul>
|
||||
<p>
|
||||
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.
|
||||
Each condition requires one actual future ground move of the named minimum after the answer.
|
||||
Smaller moves never add together; progress records the maximum in one move to each destination.
|
||||
Prior moves and shipments do not count. A shipment can prepare a move but does not fulfill a condition.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Yes must be completed before your Movement turn ends this round. No remains binding through every later Hajr or Ornithopter move.
|
||||
Original movement, shipment, preparation, payment and turn rules still apply.
|
||||
A paid Box search must finish before moving; its fee is paid only once.
|
||||
Opposing effects release an answer only if it becomes impossible.
|
||||
{' '}<a href="/rules#truthtrance-movement-expression">Movement expression rules</a>.
|
||||
</p>
|
||||
{waiting && <p>Your Movement turn is still ahead. Keep the 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>}
|
||||
{!liveMovementPromises(game.movementPromises ?? [], game.me, game.turn).length && <MovementExpressionPromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { MovementExpressionFields, movementExpressionInputError } from './movement-expression-fields';
|
||||
import type { MovementExpression } from '@/game/movement-expression-promises';
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import { BribeExpressionFields, bribeExpressionInputError } from './bribe-expression-fields';
|
||||
import { bribeExpressionRecipients, type BribeExpression } from '@/game/bribe-expression-promises';
|
||||
import { RevivalExpressionFields, revivalExpressionInputError } from './revival-expression-fields';
|
||||
@@ -68,7 +71,7 @@ export function Truthtrance({
|
||||
g.players.find((p) => p.id !== me.id)!.id,
|
||||
);
|
||||
const [kind, setKind] = useState<
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression'
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression'
|
||||
>('fact');
|
||||
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
|
||||
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
|
||||
@@ -102,6 +105,10 @@ export function Truthtrance({
|
||||
const bidClaim: BidClaim = { turn: g.bidPromiseLot?.turn ?? g.turn,
|
||||
lot: g.bidPromiseLot?.lot ?? 1, minimum: bidMinimum };
|
||||
const [movement, setMovement] = useState<MovementClaim>({ territory: 'carthag', minimum: 6 });
|
||||
const [movementExpression, setMovementExpression] = useState<MovementExpression>({ kind: 'and', terms: [
|
||||
{ kind: 'move', territory: 'imperial_basin', minimum: 1 }, { kind: 'move', territory: 'carthag', minimum: 1 },
|
||||
] });
|
||||
const movementExpressionError = movementExpressionInputError(movementExpression);
|
||||
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
|
||||
const [mixed, setMixed] = useState<MixedShipmentExpression>({
|
||||
kind: 'and',
|
||||
@@ -138,6 +145,7 @@ export function Truthtrance({
|
||||
claim: planClaim,
|
||||
}
|
||||
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
|
||||
: kind === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression }
|
||||
: kind === 'movement' ? { kind, target, ...movement }
|
||||
: kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression }
|
||||
: kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim }
|
||||
@@ -302,6 +310,13 @@ export function Truthtrance({
|
||||
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : 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>
|
||||
{(g.truthMovementExpressionAnswers ?? []).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 === 'movement' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires one matching ground movement before your turn ends, while possible. No forbids every matching move through later Hajr or Ornithopter opportunities. Separate smaller groups do not add together. The No-Field marker itself is not a physical force counter.</p>
|
||||
@@ -419,6 +434,7 @@ export function Truthtrance({
|
||||
Verified current facts
|
||||
</option>
|
||||
<option value="movement">Bind ground movement this turn</option>
|
||||
<option value="movementExpression">Combine ground moves with AND/OR</option>
|
||||
<option value="alliance">Bind alliance membership at this Nexus close</option>
|
||||
<option value="bribe">Bind ordinary bribe payments this turn</option>
|
||||
<option value="bribeExpression">Combine ordinary bribe payments with AND/OR</option>
|
||||
@@ -507,6 +523,16 @@ export function Truthtrance({
|
||||
{!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>}
|
||||
</>
|
||||
) : kind === 'movementExpression' ? (
|
||||
<>
|
||||
<MovementExpressionFields value={movementExpression} territories={TERRITORIES}
|
||||
disabled={busy || !g.truthMovementExpressionTargets.includes(target)} onChange={setMovementExpression} />
|
||||
<p className="fine">Each condition requires one future physical group of its minimum. AND may require several moves and owned extra-movement cards; OR keeps alternatives. No negates the whole question. Shipment, the concealed No-Field marker, HMS relocation and Ambassador relocation do not fulfill a ground-move condition.</p>
|
||||
{g.phase < 5 && <p className="notice">Movement comes later. Preserve its forces, spice and cards; any current owned preparation appears when available.</p>}
|
||||
{g.advanced && <p className="fine">Advanced Hajr remains immediately after a completed movement and its required arrival decisions.</p>}
|
||||
{!g.truthMovementExpressionTargets.includes(target) && <p className="notice">Choose a supported respondent with an unused movement opportunity and finish pending decisions. Optional modules remain unfinished.</p>}
|
||||
<p className="notice" aria-label="Public combined movement question preview">{movementExpressionError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'movement' ? (
|
||||
<>
|
||||
<MovementClaimFields id={`${factId}-movement`} value={movement} onChange={setMovement} />
|
||||
@@ -597,6 +623,7 @@ export function Truthtrance({
|
||||
{!['fact', 'battlePlan', 'shipment'].includes(kind) && button(
|
||||
'Ask publicly',
|
||||
{ type: 'truthAsk', question },
|
||||
(kind === 'movementExpression' && (!!movementExpressionError || !g.truthMovementExpressionTargets.includes(target))) ||
|
||||
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
|
||||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
|
||||
(kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) ||
|
||||
|
||||
@@ -16,6 +16,38 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Truthtrance / whole AND/OR ground moves — 26 October. Prototyped / Partial.**
|
||||
Full grouped questions now combine future physical ground-group minima at
|
||||
printed Arrakis destinations. Maximum-per-destination progress never sums
|
||||
smaller moves. Whole No retains all legal alternatives; the shared owned-action
|
||||
search handles scalar/whole claims and real Hajr/other preparations. Actual
|
||||
commitment, native counters, earlier funding and accepted release order remain.
|
||||
Full authoring, private answers, own progress, exact nested event/phase guide
|
||||
and four minimal profiles are connected. Online interval/search composition
|
||||
stays provisional; modes and optional-module gates remain open.
|
||||
|
||||
Root integration with disjoint pure/search,control and native lanes passes
|
||||
**22 files,177/177**, types/lint/build and **eight native paths**,33 GameTable SSR
|
||||
renders/12 actual guide callbacks plus one editor callback followed by native
|
||||
Ask. Original Basic20/Advanced23-action base33 sources add8: actual move/Hajr/
|
||||
move completes AND and consumes that physical Hajr once. Edited OR accepts the
|
||||
manual alternate route. No AND permits one qualifying move; No OR blocks either.
|
||||
Two real one-force moves place2 at Basin but keep maximum1. A13-action Charity
|
||||
source adds14 through actual phases/paid shipment and later ground moves.
|
||||
Original Advanced Fremen27 adds8: pending range records nothing, then the actual
|
||||
elite commits and fulfills. All33 original cards remain physical.
|
||||
|
||||
Eleven pure,five search-adapter,eight controlled and13 native cases retain
|
||||
full-tree validation, real entitlements, prior exclusion, exact wire/stale
|
||||
nested actions and four policies. One fixture-only String conversion resolves
|
||||
lint feedback before the passing frozen v2 batch. Proof/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-movement-expression-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-movement-expression-runtime/`.
|
||||
No saved-game/database/server/reset/restart/deployment operation is performed.
|
||||
Cross-family/current-fact expressions, wider optional/faction/card compositions,
|
||||
browser/mobile/deployed and final assurance remain required.
|
||||
[Authority, interval and evidence](TRUTHTRANCE_MOVEMENT_EXPRESSIONS.md).
|
||||
|
||||
**Truthtrance / whole AND/OR ordinary payments — 26 October. Prototyped / Partial.**
|
||||
Full grouped questions now combine named cumulative future ordinary payments.
|
||||
Whole No uses De Morgan; legal routes remain dynamic after actual payments.
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Whole AND/OR future ground moves
|
||||
|
||||
**Prototyped / Partial.** Full grouped questions bind actual future physical
|
||||
movement minima at named printed Arrakis destinations. One group must meet a
|
||||
leaf; maximum progress never adds smaller moves. Whole No negates the complete
|
||||
expression. The existing owned-action search jointly retains scalar/whole
|
||||
obligations, dynamic alternatives, original allowances and real preparations.
|
||||
No question grants a move or fulfills through shipment, marker, HMS relocation
|
||||
or Ambassador relocation. Physical advisors/elites retain native counting.
|
||||
Pending range decisions record no event until actual physical commitment.
|
||||
|
||||
Full16/depth4 editing, guarded preview/Ask, target-only answers, own maximum
|
||||
progress and exact event/phase native guides connect to the table/four minimal
|
||||
policies. Nested force records compare by content over JSON transport. Native
|
||||
Hajr timing and earlier funding/card preservation remain. New scalar and whole
|
||||
movement questions share accepted release order; old unstamped records precede
|
||||
new ones. No closes at actual owner finish; Yes cannot evade by Finish. The
|
||||
source-backed general AND/OR authority and provisional online interval/search
|
||||
composition remain explicit; no complete-mode gate opens.
|
||||
|
||||
Root integration with disjoint pure/search,control and native lanes passes
|
||||
**22 files,177/177**, types/lint/build and **eight native paths**,33 actual
|
||||
GameTable SSR renders/12 guide callbacks plus one editor meaning callback then
|
||||
native Ask. Genuine base33 Basic20/Advanced23-action prefixes add8: real move,
|
||||
physical Hajr and second move fulfill the whole AND. OR accepts a manually
|
||||
chosen alternate destination. No AND permits one qualifying move and rejects
|
||||
both; No OR rejects either. Two actual one-force moves leave2 tokens at Basin
|
||||
but maximum1, so no two-force leaf is fulfilled. Earlier Charity13 adds14 actual
|
||||
phase/readiness/shipment/movement steps. Advanced Fremen27 adds8: a live range
|
||||
response preserves original forces/progress, then actual elite commitment
|
||||
fulfills. All33 original cards remain physical. Four policies actually answer
|
||||
and complete AND on both Basic/Advanced sources.
|
||||
|
||||
Eleven pure,five adapter,eight controlled and13 native cases include strict
|
||||
validation, max-versus-sum, actual Hajr, unavailable second entitlement, prior
|
||||
exclusion, optimistic OR/prior-progress bounds, wire/stale nested guides and
|
||||
actual native counters. Initial quick passes types then flags one test-fixture
|
||||
template conversion; String(sector) repairs lint before the frozen v2 batch.
|
||||
Proof/report/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-movement-expression-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-movement-expression-runtime/`.
|
||||
No saved-game/database/server/reset/restart/deployment operation is performed.
|
||||
Dedicated wider funding/cross-family/current-fact/module composition, final
|
||||
rules/recovery/privacy/custody and browser/mobile/deployed acceptance remain.
|
||||
[Contract](TRUTHTRANCE_MOVEMENT_EXPRESSIONS.md).
|
||||
|
||||
## 26 October 2026 — Whole AND/OR ordinary-bribe commitments
|
||||
|
||||
**Prototyped / Partial.** A full grouped question binds actual cumulative future
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Rule decision index
|
||||
|
||||
## Whole AND/OR future ground moves — 26 October 2026
|
||||
|
||||
Compose the recorded publisher November2020 FAQp8 current-turn/AND/OR authority
|
||||
and supplied Advanced physical39 Truthtrance compliance/release text with the
|
||||
existing ordinary ground-movement interval. Each leaf requires one actual
|
||||
future physical group, not cumulative smaller moves; maximum progress records
|
||||
that event. Whole No negates the complete expression. Native owned entitlements,
|
||||
Hajr timing, preparations, counter responses and earlier funding remain.
|
||||
The online opportunity interval/search/accepted-order release composition stays
|
||||
provisional under standing14October priority. No mode or publication gate opens.
|
||||
[Contract and evidence](TRUTHTRANCE_MOVEMENT_EXPRESSIONS.md).
|
||||
|
||||
## Whole AND/OR ordinary bribe payments — 26 October 2026
|
||||
|
||||
Apply publisher November2020 FAQp8 current-turn/AND/OR authority and supplied
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Truthtrance combined future ground moves
|
||||
|
||||
## Whole AND/OR prototype — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** A structured question combines future ordinary
|
||||
physical ground moves to printed Arrakis territories. The packet is
|
||||
`{kind:'movementExpression',target,turn,claim}`. Native Basic/Advanced phases0–5
|
||||
use the same quiet unused opportunity and owned preparations as
|
||||
[scalar movement](TRUTHTRANCE_MOVEMENT_PROMISES.md). No start gate opens.
|
||||
|
||||
## Authority and provisional event meaning
|
||||
|
||||
The recorded [nonbattle source audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md#source-basis)
|
||||
identifies publisher [November2020 FAQp8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
|
||||
current-turn commitments and AND/OR authority. Supplied Advanced physical39/
|
||||
printed37 Truthtrance errata records public answers, compliance, later
|
||||
impossibility and unknown/save. Neither supplies an online solver. The existing
|
||||
owned-action, ordinary-opportunity interval and accepted-order release remain
|
||||
provisional composition under standing14October choose-recommended priority.
|
||||
|
||||
A leaf requires one actual matching physical ground group after this answer.
|
||||
Repeated same-destination leaves share the largest qualifying future group.
|
||||
Smaller separate moves never add together, even if the same tokens move twice.
|
||||
Prior moves are excluded. AND requires all children; OR at least one. **No
|
||||
negates the whole expression.** No to A AND B may permit either first group,
|
||||
while No to A OR B forbids either qualifying group. No route is stored.
|
||||
|
||||
The native committed event owns the physical amount: each elite/advisor retains
|
||||
its native physical counting, while a concealed No-Field marker contributes0.
|
||||
Shipment may prepare but cannot fulfill a movement leaf. HMS relocation and
|
||||
Ambassador relocation are separate producers. A pending Fremen/Ixian/CHOAM
|
||||
range response records nothing until the real movement is committed; prevention
|
||||
retains its native physical result and may release later impossibility.
|
||||
Actual nonmatching destinations remain legal, including native HMS moves;
|
||||
question leaves and recorded maxima remain printed Arrakis destinations.
|
||||
|
||||
## Search, funding and usable controls
|
||||
|
||||
`game/movement-expression-promises.ts` parses every subtree, evaluates the
|
||||
whole answer and immutably records maximum-per-destination physical progress.
|
||||
Sixteen leaves/depth4 are software input limits, not printed limits on questions.
|
||||
`game/movement-promise-search.ts` jointly searches scalar and whole obligations,
|
||||
including actual owned Hajr/Ornithopter/Ghola/Box/Kulon/native preparation paths.
|
||||
It checks whole No before projected movement, includes whole progress in memo
|
||||
state, and cannot finish while a positive whole answer remains incomplete.
|
||||
Optimistic bounds never discard an OR because a different leaf is impossible,
|
||||
or forget a leaf already fulfilled before later force losses.
|
||||
|
||||
The engine uses that same search for answers, own voluntary spending/card
|
||||
preservation, current private guides and completed opposing impossibility.
|
||||
Existing battle/bid/bribe/revival/shipment commitments keep their original
|
||||
budgets. Accepted order is shared across new scalar and whole movement answers;
|
||||
older unstamped scalar records precede new ones. No closes only at the actual
|
||||
owner opportunity finish; Yes cannot evade by Finish. Earlier Charity/Revival
|
||||
readiness reopens for original owned preparation. An answer moves nothing early
|
||||
and grants no new movement entitlement. Advanced Hajr keeps its immediate
|
||||
post-movement/required-arrival timing.
|
||||
|
||||
The full controlled editor adds/wraps/nests/removes/collapses groups and repairs
|
||||
invalid numeric/destination drafts. Preview and native Ask keep the full tree.
|
||||
Target-only answers, own maximum progress and one exact phase/event guide
|
||||
connect to the real table and all four minimal policies. Nested force records
|
||||
are compared by content across JSON transport; key order is immaterial. Stale,
|
||||
modified or foreign guides reject. An alternative legal OR route may be moved
|
||||
manually. The legacy scalar panel supplies the shared guide when both ledgers
|
||||
exist, avoiding duplicate visible actions. AI strategy/calibration stay deferred.
|
||||
|
||||
## Evidence and remaining gates
|
||||
|
||||
Pure/search cases: `tests/movement-expression-promises.test.ts`.
|
||||
Controlled editor/guide cases: `tests/truth-movement-expression-controls.test.tsx`.
|
||||
Original native sources/cases: `tests/fixture-truth-movement-expression.ts` and
|
||||
`tests/truth-movement-expression-runtime.test.ts`. Eleven pure,five search
|
||||
adapter,eight controlled and13 native cases are included in the frozen
|
||||
**22-file,177/177** union. Types/lint, production build and eight native paths
|
||||
pass on the same unchanged source. Actual GameTable SSR renders33 times;
|
||||
12 actual guide callbacks submit native packets. One actual editor meaning
|
||||
callback retains the full tree through native Ask. Four minimal policies
|
||||
actually answer and complete AND on both Basic/Advanced original sources.
|
||||
|
||||
| Original base33 source | Prefix actions | Further actions | Actual result |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Basic AND | 20 | 8 | Real move/Hajr/move and owner finish; physical Hajr consumed once |
|
||||
| Advanced AND | 23 | 8 | Same whole progress with original post-move Hajr timing |
|
||||
| Basic edited OR | 20 | 6 | Actual AND→OR editor then Ask; manual alternate fulfills |
|
||||
| Basic No AND | 20 | 7 | First qualifying move allowed; completing both rejects |
|
||||
| Basic No OR | 20 | 5 | Either qualifying move rejects; actual owner finish closes No |
|
||||
| Basic maximum versus sum | 20 | 8 | Two one-force groups place2 in Basin but record maximum1 |
|
||||
| Basic early Charity | 13 | 14 | Real later phases/paid shipment and two physical ground moves |
|
||||
| Advanced Fremen range | 27 | 8 | Pending response records nothing; actual elite commit fulfills |
|
||||
|
||||
A fixture-only template String conversion repairs initial lint feedback; the
|
||||
passing v2 source snapshot is
|
||||
`21af7718a11b4021df4b380d0d88613db71aa3ee47514bc9b95059da410cca9b`
|
||||
(3145 files). Final evidence prose is a separate three-document overlay.
|
||||
Frozen report/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-movement-expression-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-movement-expression-runtime/`.
|
||||
SSR/editor/guide callbacks are local engine/control evidence, not DOM,
|
||||
hydration, browser/mobile or deployed acceptance.
|
||||
|
||||
The source adapters inherit original Basic/Advanced base33 setup, printed
|
||||
armies, dealt Hajr/Truthtrance and paid Polar shipment. A separate earlier
|
||||
source replays the original selected unopened setup/Spice prefix to real Charity;
|
||||
a Fremen source retains its actual Advanced range response and typed forces.
|
||||
No played clock, purse, hand, army or promise is installed by these adapters.
|
||||
Controlled UI/search adapters retain those labels, not original acquisition proof.
|
||||
|
||||
Current-fact/cross-family expressions, optional modules, wider native card/
|
||||
faction/skill 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.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Truthtrance ground-movement promises
|
||||
|
||||
The [whole AND/OR movement follow-up](TRUTHTRANCE_MOVEMENT_EXPRESSIONS.md)
|
||||
adds full grouped future destinations/minima to the same owned-action search.
|
||||
Each leaf still requires one actual qualifying group, never a sum; the new
|
||||
ledger retains whole No and dynamic alternatives without changing scalar claims.
|
||||
|
||||
## Native early Advanced and CHOAM opening — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** Both Basic and Advanced now admit clean current-round
|
||||
|
||||
+12
-2
@@ -1,3 +1,4 @@
|
||||
import { liveMovementExpressionPromises } from './movement-expression-promises';
|
||||
import { discoveryBotActions } from './discovery-options';
|
||||
import { normalAuctionHandBlock } from './normal-auction';
|
||||
import { atreidesFullPlanAction } from './atreides-full-plan';
|
||||
@@ -1352,6 +1353,10 @@ function policyActions(g: GameView): Action[] {
|
||||
const answer = answers.find(answer => answer === 'yes') ?? answers[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'movementExpression') {
|
||||
const answer = g.truthMovementExpressionAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'movement') {
|
||||
const answer = g.truthMovementAnswers?.find(answer => answer !== 'unknown') ?? g.truthMovementAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
@@ -2768,7 +2773,8 @@ function policyActions(g: GameView): Action[] {
|
||||
];
|
||||
}
|
||||
if(d.kind==='advisor'&&!d.ambassadorEvent&&(liveShipmentPromises(g.shipmentPromises??[],me.id,g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises??[],me.id,g.turn).some(promise=>promise.answer)))
|
||||
(liveMovementPromises(g.movementPromises??[],me.id,g.turn).some(promise=>promise.answer) ||
|
||||
liveMovementExpressionPromises(g.movementExpressionPromises??[],me.id,g.turn).some(promise=>promise.answer))))
|
||||
return [{type:'decision',accept:false}];
|
||||
if (d.kind === 'advisor' && (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)) {
|
||||
const choices = (g.spiritualAdvisorChoices??g.guildAmbassadorAdvisorChoices)!.choices.filter(
|
||||
@@ -2787,7 +2793,8 @@ function policyActions(g: GameView): Action[] {
|
||||
];
|
||||
}
|
||||
if (d.kind === 'advisor' && (liveShipmentPromises(g.shipmentPromises ?? [], me.id, g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises ?? [], me.id, g.turn).some(promise => promise.answer)))
|
||||
(liveMovementPromises(g.movementPromises ?? [], me.id, g.turn).some(promise => promise.answer) ||
|
||||
liveMovementExpressionPromises(g.movementExpressionPromises ?? [], me.id, g.turn).some(promise => promise.answer))))
|
||||
// A waiting promise may need these reserves. Declining is the minimal
|
||||
// legal policy until optional accompaniment strategy is implemented.
|
||||
return [{ type: 'decision', accept: false }];
|
||||
@@ -4215,6 +4222,9 @@ export function botActions(g: GameView): Action[] {
|
||||
if (g.homeworldTupileBlocked) return [];
|
||||
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||
return tupileCleanupActions(g);
|
||||
if (g.movementExpressionPromiseGuide && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
|
||||
return [g.movementExpressionPromiseGuide.action];
|
||||
if (g.movementPromisePolicy && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
|
||||
return g.movementPromisePolicy;
|
||||
|
||||
+140
-47
@@ -1,3 +1,5 @@
|
||||
import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent,
|
||||
validateMovementExpressionPromises, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises';
|
||||
import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates,
|
||||
liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises,
|
||||
type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises';
|
||||
@@ -2161,6 +2163,8 @@ export type Game = {
|
||||
bribePromiseSequence?: string[];
|
||||
alliancePromises?: AlliancePromise[];
|
||||
movementPromises?: MovementPromise[];
|
||||
movementExpressionPromises?: MovementExpressionPromise[];
|
||||
movementPromiseSequence?: string[];
|
||||
/** Server-only certainty of movement-relevant cards still in the discard. */
|
||||
movementDiscardKnowledge?: MovementDiscardKnowledge;
|
||||
ixSetupCards?: Card[] | null;
|
||||
@@ -4829,7 +4833,7 @@ function finishNullentropy(
|
||||
movementPreview.discard = preview.discard;
|
||||
setMovementDiscardKnowledge(movementPreview, recordMovementBoxKnowledge(movementPreview.movementDiscardKnowledge,
|
||||
movementPreview.players.map(p => p.id), player, preview.discard));
|
||||
requireRule(!liveMovementPromises(g.movementPromises ?? [], player, g.turn).some(promise => promise.answer) ||
|
||||
requireRule(!hasMovementPromises(g, player, true) ||
|
||||
groundMovementCompletion(movementPreview, player) !== null,
|
||||
'Choose a card that preserves your accepted ground-movement promises. The paid search remains available.');
|
||||
// All rejection paths precede this single server shuffle. The paid record and
|
||||
@@ -13789,7 +13793,7 @@ function beginRichesePreCostBetrayal(g: Game, source:
|
||||
const owner=invoice.buyer;
|
||||
if (liveShipmentPromises(g.shipmentPromises ?? [],owner,g.turn).length ||
|
||||
g.battle?.truthPromises?.some(p=>p.player===owner && !p.released) ||
|
||||
liveMovementPromises(g.movementPromises ?? [],owner,g.turn).some(p=>p.answer)) {
|
||||
hasMovementPromises(g, owner, true)) {
|
||||
const projected=structuredClone(g);
|
||||
if (source.kind==='ordinary') {
|
||||
const p=getPlayer(projected,owner);
|
||||
@@ -15142,7 +15146,7 @@ function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?
|
||||
return null;
|
||||
}
|
||||
function bribeCompatibleLaterPromises(g: Game, p: Player,
|
||||
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'revivalExpressionPromises' | 'battle'>): boolean {
|
||||
later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'revivalExpressionPromises' | 'movementExpressionPromises' | 'battle'>): boolean {
|
||||
return !hasBribePromises(g, p.id, true) || !!bribePromiseCompletion({ ...g, ...later }, p);
|
||||
}
|
||||
function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no' | 'unknown')[] {
|
||||
@@ -15236,7 +15240,7 @@ function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, am
|
||||
const voluntary = { actor: p.id, action: { type: 'bribe' } };
|
||||
reconcileBattlePromises(trial, voluntary);
|
||||
reconcileShipmentPromises(trial, voluntary);
|
||||
if (liveMovementPromises(trial.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) &&
|
||||
if (hasMovementPromises(trial, p.id, true) &&
|
||||
!groundMovementCompletion(trial, p.id)) return false;
|
||||
reconcileRevivalPromises(trial, voluntary);
|
||||
return !hasBribePromises(trial, p.id) || !!bribePromiseCompletion(trial, actor);
|
||||
@@ -15247,7 +15251,7 @@ function projectedBribes(g: Game, p: Player): BribeOptions {
|
||||
const constrained = hasBribePromises(g, p.id) ||
|
||||
g.battle?.truthPromises?.some(promise => promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]) ||
|
||||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
hasMovementPromises(g, p.id, true) ||
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer);
|
||||
@@ -15458,7 +15462,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
|
||||
try {
|
||||
const action: Action = { type: 'bid', ...normalBidQuote(g, p, range.minimum) };
|
||||
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length ||
|
||||
hasMovementPromises(g, p.id) ||
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
|
||||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length ||
|
||||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length ||
|
||||
@@ -18376,12 +18380,13 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
|
||||
}
|
||||
}
|
||||
function groundMovementCompletion(source: Game, actor: string,
|
||||
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn)): MovementCompletion | null {
|
||||
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
|
||||
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn)): MovementCompletion | null {
|
||||
if (!movementPromiseModeSupported(source) || source.phase < 0 || source.phase > 5) return null;
|
||||
const game = groundMovementProbe(source, actor);
|
||||
if (source.phase === 5 && source.phaseOpening?.initialize) {
|
||||
resetGroundMovementForecast(game, actor);
|
||||
return groundMovementCompletionAtMovement(game, actor, promises);
|
||||
return groundMovementCompletionAtMovement(game, actor, promises, expressions);
|
||||
}
|
||||
if (source.phase < 5) {
|
||||
const paid = source.pendingNullentropy;
|
||||
@@ -18389,7 +18394,7 @@ function groundMovementCompletion(source: Game, actor: string,
|
||||
if (nullentropyIntegrity(source)) return null;
|
||||
for (const card of eligibleNullentropyCards(source.discard)) {
|
||||
const projected = projectMovementBoxRecovery(source, actor, paid.box, card.id, true);
|
||||
const completion = projected && groundMovementCompletion(projected, actor, promises);
|
||||
const completion = projected && groundMovementCompletion(projected, actor, promises, expressions);
|
||||
if (completion) return { ...completion,
|
||||
actions: [{ type: 'decision', event: paid.event, card: card.id }, ...completion.actions] };
|
||||
}
|
||||
@@ -18397,12 +18402,12 @@ function groundMovementCompletion(source: Game, actor: string,
|
||||
}
|
||||
for (const seed of earlyShipmentCompletionSeeds(game, source, actor)) {
|
||||
resetGroundMovementForecast(seed.game, actor);
|
||||
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises);
|
||||
const completion = groundMovementCompletionAtMovement(seed.game, actor, promises, expressions);
|
||||
if (completion) return { actions: [...seed.actions, ...completion.actions], early: seed.early };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return groundMovementCompletionAtMovement(game, actor, promises);
|
||||
return groundMovementCompletionAtMovement(game, actor, promises, expressions);
|
||||
}
|
||||
function resetGroundMovementForecast(game: Game, actor: string) {
|
||||
resetPlayerMovement(getPlayer(game, actor));
|
||||
@@ -18410,7 +18415,8 @@ function resetGroundMovementForecast(game: Game, actor: string) {
|
||||
game.ornithopter = null;
|
||||
game.movementRemaining = [actor];
|
||||
}
|
||||
function groundMovementCompletionAtMovement(game: Game, actor: string, promises: readonly MovementPromise[]): MovementCompletion | null {
|
||||
function groundMovementCompletionAtMovement(game: Game, actor: string, promises: readonly MovementPromise[],
|
||||
expressions: readonly MovementExpressionPromise[]): MovementCompletion | null {
|
||||
if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null;
|
||||
return findMovementCompletion(game, actor, promises, {
|
||||
allowance: movesAllowed,
|
||||
@@ -18508,22 +18514,29 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
|
||||
return { game: projectGroundMovement(copy, move), event: groundMovementEvent(move) };
|
||||
} catch (error) { if (error instanceof RuleError) return null; throw error; }
|
||||
},
|
||||
});
|
||||
}, expressions);
|
||||
}
|
||||
function hasMovementPromises(g: Game, player: string, positive = false): boolean {
|
||||
return [...liveMovementPromises(g.movementPromises ?? [], player, g.turn),
|
||||
...liveMovementExpressionPromises(g.movementExpressionPromises ?? [], player, g.turn)]
|
||||
.some(promise => !positive || promise.answer);
|
||||
}
|
||||
function movementExpressionIntegrity(g: Game) {
|
||||
try { validateMovementExpressionPromises(g.movementExpressionPromises ?? [], g.players.map(player => player.id)); }
|
||||
catch (error) { if (error instanceof MovementExpressionPromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function projectedGroundMovementPolicy(g: Game, player: Player): Action[] | null {
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], player.id, g.turn);
|
||||
const paid = g.pendingNullentropy?.player === player.id;
|
||||
if (!paid && g.phase < 5) return earlyGroundMovementPolicy(g, player);
|
||||
if (g.truthtrance || !live.length || (!paid && g.active !== player.id) ||
|
||||
(paid && !live.some(promise => promise.answer))) return null;
|
||||
if (g.truthtrance || !hasMovementPromises(g, player.id) || (!paid && g.active !== player.id) ||
|
||||
(paid && !hasMovementPromises(g, player.id, true))) return null;
|
||||
const available = paid ? groundMovementProbe(g, player.id) : g;
|
||||
if (!movementPromiseTargetAvailable(paid ? { ...available, decision: null } : available, player.id)) return null;
|
||||
const completion = groundMovementCompletion(g, player.id);
|
||||
return completion ? completion.actions.length ? completion.actions.slice(0, 1) : [{ type: 'endMovement' }] : null;
|
||||
}
|
||||
function earlyGroundMovementPolicy(g: Game, player: Player): Action[] | null {
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], player.id, g.turn);
|
||||
if (g.status !== 'playing' || !movementPromiseModeSupported(g) || !live.some(promise => promise.answer) ||
|
||||
if (g.status !== 'playing' || !movementPromiseModeSupported(g) || !hasMovementPromises(g, player.id, true) ||
|
||||
g.truthtrance || g.response || g.phaseOpening || g.pendingTreacheryDiscard ||
|
||||
(g.decision && !(g.decision.kind === 'auctionPayment' && g.decision.player === player.id))) return null;
|
||||
// Passing cannot spend the reserved route. In particular, a foreign Silent
|
||||
@@ -18558,38 +18571,99 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) {
|
||||
function reopenMovementPreparation(g: Game, p: Player) {
|
||||
if ((g.phase === 2 || (g.phase === 4 && !g.revivalOrder)) && g.ready.includes(p.id)) {
|
||||
g.ready = g.ready.filter(id => id !== p.id);
|
||||
log(g, `${p.name} has a new Truthtrance movement commitment and must confirm readiness again after any needed preparation.`);
|
||||
}
|
||||
(g.movementPromises ??= []).push({ ...claim, turn: g.turn, player: p.id, asker, answer });
|
||||
}
|
||||
function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) {
|
||||
reopenMovementPreparation(g, p);
|
||||
const promise: MovementPromise = { ...claim, event: crypto.randomUUID(), turn: g.turn, player: p.id, asker, answer };
|
||||
(g.movementPromises ??= []).push(promise);
|
||||
(g.movementPromiseSequence ??= []).push(promise.event!);
|
||||
}
|
||||
function movementExpressionTruthAnswers(g: Game, p: Player, claim: MovementExpressionClaim): TruthAnswer[] {
|
||||
if (claim.turn !== g.turn || !movementPromiseTargetAvailable(g, p.id)) return [];
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
const answers: TruthAnswer[] = [];
|
||||
for (const answer of [true, false]) {
|
||||
const promise: MovementExpressionPromise = { ...structuredClone(claim), event: 'candidate', player: p.id,
|
||||
asker: g.players.find(other => other.id !== p.id)!.id, answer, progress: [] };
|
||||
const expressions = [...(g.movementExpressionPromises ?? []), promise];
|
||||
const trial = { ...answerSource, movementExpressionPromises: expressions };
|
||||
if (groundMovementCompletion(trial, p.id) &&
|
||||
revivalCompatibleLaterPromises(answerSource, p, { movementExpressionPromises: expressions }) &&
|
||||
bribeCompatibleLaterPromises(answerSource, p, { movementExpressionPromises: expressions })) answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
function bindMovementExpressionTruth(g: Game, p: Player, claim: MovementExpressionClaim, answer: boolean, asker: string) {
|
||||
requireRule(movementExpressionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
|
||||
'Choose a whole movement answer compatible with owned actions and earlier answers.');
|
||||
reopenMovementPreparation(g, p);
|
||||
const promise: MovementExpressionPromise = { ...structuredClone(claim), event: crypto.randomUUID(),
|
||||
player: p.id, asker, answer, progress: [] };
|
||||
(g.movementExpressionPromises ??= []).push(promise);
|
||||
(g.movementPromiseSequence ??= []).push(promise.event);
|
||||
}
|
||||
/** Nested native force records survive JSON transport; record key order is
|
||||
* immaterial but fields, values and the originating guide event must match. */
|
||||
function canonicalMovementGuideValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonicalMovementGuideValue);
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
|
||||
.sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonicalMovementGuideValue(item)]));
|
||||
return value;
|
||||
}
|
||||
function movementGuideActionMatches(actual: Action, expected: Action): boolean {
|
||||
return Object.keys(actual).sort().join(',') === Object.keys(expected).sort().join(',') &&
|
||||
JSON.stringify(canonicalMovementGuideValue(actual)) === JSON.stringify(canonicalMovementGuideValue(expected));
|
||||
}
|
||||
function projectedMovementExpressionGuide(g: Game, player: Player): { phase: number; action: Action } | null {
|
||||
if (g.truthtrance) return null;
|
||||
const live = liveMovementExpressionPromises(g.movementExpressionPromises ?? [], player.id, g.turn);
|
||||
const promise = live.find(promise => promise.answer) ?? live[0];
|
||||
if (!promise) return null;
|
||||
const action = projectedGroundMovementPolicy(g, player)?.[0];
|
||||
return action ? { phase: g.phase, action: { ...structuredClone(action), movementExpressionPromiseEvent: promise.event } } : null;
|
||||
}
|
||||
function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
|
||||
if (!live.length) return;
|
||||
const event = groundMovementEvent(move);
|
||||
if (move.source === 'ambassador' || !hasMovementPromises(g, p.id)) return;
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn), event = groundMovementEvent(move);
|
||||
requireRule(movementEventAllowed(live, event), 'This movement would break your accepted No answer.');
|
||||
const projected = projectGroundMovement(g, move);
|
||||
projected.movementPromises = settleMovementEvent(g.movementPromises ?? [], p.id, g.turn, event);
|
||||
try { projected.movementExpressionPromises = recordMovementExpressionEvent(g.movementExpressionPromises ?? [], p.id, g.turn, event); }
|
||||
catch (error) { if (error instanceof MovementExpressionPromiseError) throw new RuleError(error.message); throw error; }
|
||||
requireRule(groundMovementCompletion(projected, p.id) !== null,
|
||||
'Leave a legal completion for your accepted ground-movement promises.');
|
||||
}
|
||||
function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
|
||||
if (move.source === 'ambassador') return;
|
||||
if (!g.movementPromises) return;
|
||||
const old = g.movementPromises;
|
||||
g.movementPromises = settleMovementEvent(old, p.id, g.turn, groundMovementEvent(move));
|
||||
g.movementPromises.forEach((promise, index) => {
|
||||
if (promise.fulfilled && !old[index].fulfilled)
|
||||
log(g, `${p.name} fulfilled a Truthtrance ground-movement promise with this committed move.`);
|
||||
});
|
||||
const old = g.movementPromises ?? [], expressions = g.movementExpressionPromises ?? [];
|
||||
if (g.movementPromises) {
|
||||
g.movementPromises = settleMovementEvent(old, p.id, g.turn, groundMovementEvent(move));
|
||||
g.movementPromises.forEach((promise, index) => {
|
||||
if (promise.fulfilled && !old[index].fulfilled)
|
||||
log(g, `${p.name} fulfilled a Truthtrance ground-movement promise with this committed move.`);
|
||||
});
|
||||
}
|
||||
if (g.movementExpressionPromises) {
|
||||
try { g.movementExpressionPromises = recordMovementExpressionEvent(expressions, p.id, g.turn, groundMovementEvent(move)); }
|
||||
catch (error) { if (error instanceof MovementExpressionPromiseError) throw new RuleError(error.message); throw error; }
|
||||
g.movementExpressionPromises.forEach((promise, index) => {
|
||||
if (promise.fulfilled && !expressions[index].fulfilled)
|
||||
log(g, `${p.name} fulfilled a whole Truthtrance movement answer with this committed move.`);
|
||||
});
|
||||
}
|
||||
}
|
||||
function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
|
||||
// A declared native movement is awaiting a real opponent choice. It is not
|
||||
// impossible and must not be fulfilled before physical commitment.
|
||||
movementExpressionIntegrity(g);
|
||||
type Promise = MovementPromise | MovementExpressionPromise;
|
||||
const isExpression = (promise: Promise): promise is MovementExpressionPromise => 'claim' in promise;
|
||||
for (const p of g.players) {
|
||||
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
|
||||
const live: Promise[] = [...liveMovementPromises(g.movementPromises ?? [], p.id, g.turn),
|
||||
...liveMovementExpressionPromises(g.movementExpressionPromises ?? [], p.id, g.turn)];
|
||||
if (!live.some(promise => promise.answer)) continue;
|
||||
if (g.pendingFremenMove?.order.player === p.id || g.pendingIxMove?.player === p.id ||
|
||||
g.pendingChoamMove?.player === p.id ||
|
||||
@@ -18609,13 +18683,16 @@ function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Actio
|
||||
(g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 ||
|
||||
g.richeseAuction && !g.richeseAuction.outcome));
|
||||
if (busy && !voluntary) continue;
|
||||
if (groundMovementCompletion(g, p.id, live)) continue;
|
||||
const sequence = g.movementPromiseSequence ?? [];
|
||||
const completion = (promises: Promise[]) => groundMovementCompletion(g, p.id,
|
||||
promises.filter((promise): promise is MovementPromise => !isExpression(promise)), promises.filter(isExpression));
|
||||
if (completion(live)) continue;
|
||||
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted ground-movement promise.');
|
||||
const kept = live.filter(promise => !promise.answer);
|
||||
// Keep still-feasible earlier answers and every No constraint; an opposing
|
||||
// loss does not erase independent promises that remain possible.
|
||||
for (const promise of live.filter(promise => promise.answer)) {
|
||||
if (groundMovementCompletion(g, p.id, [...kept, promise])) { kept.push(promise); continue; }
|
||||
const positive = live.filter(promise => promise.answer).sort((a, b) =>
|
||||
sequence.indexOf(a.event ?? '') - sequence.indexOf(b.event ?? ''));
|
||||
for (const promise of positive) {
|
||||
if (completion([...kept, promise])) { kept.push(promise); continue; }
|
||||
promise.released = true;
|
||||
log(g, `${p.name}'s Truthtrance ground-movement promise is released because no owned legal completion remains possible.`);
|
||||
}
|
||||
@@ -19035,6 +19112,10 @@ function finishMovementTurn(g: Game, id: string) {
|
||||
const p = getPlayer(g, id);
|
||||
try {
|
||||
if (g.movementPromises) g.movementPromises = finishMovementPromises(g.movementPromises, p.id, g.turn);
|
||||
for (const promise of liveMovementExpressionPromises(g.movementExpressionPromises ?? [], p.id, g.turn)) {
|
||||
requireRule(!promise.answer, 'Complete your whole Truthtrance movement Yes before finishing movement.');
|
||||
promise.fulfilled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof MovementClaimError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
@@ -30339,7 +30420,7 @@ function stagePromisedForceReturn(g: Game, p: Player, revival: PendingRevival) {
|
||||
}
|
||||
function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival): boolean {
|
||||
if (!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
|
||||
!liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length) return true;
|
||||
!hasMovementPromises(g, p.id)) return true;
|
||||
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
|
||||
trial.truthtrance = null;
|
||||
if (revival) stagePromisedForceReturn(trial, actor, revival);
|
||||
@@ -30462,7 +30543,7 @@ function revivalPromisePlan(g: Game, p: Player, promises = g.revivalPromises ??
|
||||
function revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromises ?? []): Action | null {
|
||||
return revivalPromisePlan(g, p, promises)?.action ?? null;
|
||||
}
|
||||
function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises'>): boolean {
|
||||
function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises' | 'movementExpressionPromises'>): boolean {
|
||||
if (!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length &&
|
||||
!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length &&
|
||||
!liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length) return true;
|
||||
@@ -30561,7 +30642,7 @@ function guardPromisedLeaderRevival(g: Game, p: Player, action: Action,
|
||||
!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length &&
|
||||
!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length &&
|
||||
!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
|
||||
!liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length &&
|
||||
!hasMovementPromises(g, p.id) &&
|
||||
!hasBribePromises(g, p.id, true)) return;
|
||||
if (!quote) {
|
||||
requireRule(leaderRevivalPromisesSatisfied(g.leaderRevivalPromises ?? [], p.id, g.turn, []),
|
||||
@@ -35594,6 +35675,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
||||
'This alliance commitment guide is stale or belongs to another Nexus choice.');
|
||||
}
|
||||
if (action.movementExpressionPromiseEvent !== undefined) {
|
||||
const guide = projectedMovementExpressionGuide(state, getPlayer(state, id));
|
||||
requireRule(guide && guide.phase === state.phase && movementGuideActionMatches(action, guide.action),
|
||||
'This whole movement guide is stale or belongs to another opportunity.');
|
||||
}
|
||||
if (action.bribePromiseEvent !== undefined) {
|
||||
const guide = projectedBribePromiseGuide(state, id);
|
||||
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
|
||||
@@ -36300,6 +36386,8 @@ function applyActionInner(
|
||||
bindShipment: bindShipmentTruth,
|
||||
movementAnswers: movementTruthAnswers,
|
||||
bindMovement: bindMovementTruth,
|
||||
movementExpressionAnswers: movementExpressionTruthAnswers,
|
||||
bindMovementExpression: bindMovementExpressionTruth,
|
||||
allianceAnswers: allianceTruthAnswers,
|
||||
bindAlliance: bindAllianceTruth,
|
||||
bribeAnswers: bribeTruthAnswers,
|
||||
@@ -36323,7 +36411,7 @@ function applyActionInner(
|
||||
)
|
||||
return g;
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof 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 TraitorDecisionError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -37853,7 +37941,7 @@ function applyActionInner(
|
||||
if(action.accept){
|
||||
beginSpiritualAdvisor(g,decision,action);
|
||||
if(liveShipmentPromises(g.shipmentPromises??[],p.id,g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises??[],p.id,g.turn).some(promise=>promise.answer)){
|
||||
hasMovementPromises(g, p.id, true)){
|
||||
const afterAdvisor=structuredClone(g);
|
||||
for(let step=0;step<4&&afterAdvisor.response&&(afterAdvisor.response.spiritualAdvisorSource ||
|
||||
afterAdvisor.response.automaticAdvisorFlip?.producer==='spiritualArrival');step++)
|
||||
@@ -37940,7 +38028,7 @@ function applyActionInner(
|
||||
amount,
|
||||
};
|
||||
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
|
||||
hasMovementPromises(g, p.id, true)) {
|
||||
// Accepting accompaniment is voluntary even though a later responder
|
||||
// commits its reserve withdrawal. Check that allowed outcome now.
|
||||
const afterAdvisor = structuredClone(g);
|
||||
@@ -38866,7 +38954,7 @@ function applyActionInner(
|
||||
'Not enough spice for these extra revivals.',
|
||||
);
|
||||
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
||||
liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
hasMovementPromises(g, p.id, true) ||
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
|
||||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
|
||||
@@ -39298,7 +39386,7 @@ function applyActionInner(
|
||||
// unsupported arrival combinations before saving its private intent.
|
||||
validateShipmentArrival(g, shipment);
|
||||
checkShipmentIncomeFunding(g, p, cost, allyPayment);
|
||||
if (liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
|
||||
if (hasMovementPromises(g, p.id, true)) {
|
||||
const afterShipment = structuredClone(g);
|
||||
commitShipment(afterShipment, shipment, undefined, true);
|
||||
reconcileGroundMovementPromises(afterShipment, p.id, action, g.phase);
|
||||
@@ -39497,7 +39585,7 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'endMovement') {
|
||||
requireRule(!liveMovementPromises(g.movementPromises ?? [], id, g.turn).some(promise => promise.answer),
|
||||
requireRule(!hasMovementPromises(g, id, true),
|
||||
'Complete your accepted ground-movement promises before ending movement.');
|
||||
finishShipmentPromises(g, p, null);
|
||||
requireRule(
|
||||
@@ -40107,6 +40195,11 @@ export function viewGame(state: Game, id: string) {
|
||||
? movementTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
movementPromises: g.movementPromises ?? [],
|
||||
movementPromisePolicy: projectedGroundMovementPolicy(g, me),
|
||||
truthMovementExpressionTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
|
||||
truthMovementExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'movementExpression' &&
|
||||
g.truthtrance.question.target === id ? movementExpressionTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
movementExpressionPromises: (g.movementExpressionPromises ?? []).filter(promise => promise.player === id).map(promise => structuredClone(promise)),
|
||||
movementExpressionPromiseGuide: projectedMovementExpressionGuide(g, me),
|
||||
truthShipmentSupported: g.players.some(p => shipmentPromiseModeSupported(g, p.id)),
|
||||
truthShipmentTargets: !g.response && !g.decision && !g.phaseOpening && !g.pendingShipment
|
||||
? g.players.filter(p => shipmentPromiseModeSupported(g, p.id) &&
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { TERRITORIES } from './board';
|
||||
import { MovementClaimError, movementClaimText, parseMovementClaim, type MovementEvent } from './movement-promises';
|
||||
|
||||
export { movementPromiseModeSupported as movementExpressionPromiseModeSupported } from './movement-promises';
|
||||
|
||||
export type MovementExpression = { kind: 'move'; territory: string; minimum: number } |
|
||||
{ kind: 'and' | 'or'; terms: MovementExpression[] };
|
||||
export type MovementExpressionClaim = { turn: number; claim: MovementExpression };
|
||||
export type MovementExpressionProgressEntry = { territory: string; maximum: number };
|
||||
export type MovementExpressionProgress = MovementExpressionProgressEntry[];
|
||||
export type MovementExpressionEvent = MovementEvent;
|
||||
export type MovementExpressionPromise = MovementExpressionClaim & {
|
||||
event: string; player: string; asker: string; answer: boolean;
|
||||
/** Largest actual physical ground move to each named destination after this answer. */
|
||||
progress: MovementExpressionProgress;
|
||||
fulfilled?: boolean; released?: boolean;
|
||||
};
|
||||
|
||||
export const MOVEMENT_EXPRESSION_MAX_DEPTH = 4;
|
||||
export const MOVEMENT_EXPRESSION_MAX_LEAVES = 16;
|
||||
|
||||
export class MovementExpressionPromiseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MovementExpressionPromiseError';
|
||||
}
|
||||
}
|
||||
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new MovementExpressionPromiseError(message);
|
||||
}
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
|
||||
const keys = (value: Record<string, unknown>, expected: string) => Object.keys(value).sort().join(',') === expected;
|
||||
const count = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 20;
|
||||
const printedTerritory = (value: unknown): value is string =>
|
||||
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
|
||||
|
||||
/** Validate every subtree before evaluation can short-circuit. The scalar
|
||||
* parser remains the authority for printed destinations and physical minima. */
|
||||
export function parseMovementExpression(value: unknown): MovementExpression {
|
||||
let leaves = 0;
|
||||
const parse = (input: unknown, depth: number): MovementExpression => {
|
||||
check(object(input) && depth <= MOVEMENT_EXPRESSION_MAX_DEPTH,
|
||||
'Use a ground-movement expression within four nested groups.');
|
||||
if (input.kind === 'and' || input.kind === 'or') {
|
||||
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
|
||||
input.terms.length <= MOVEMENT_EXPRESSION_MAX_LEAVES,
|
||||
'Join two or more ground-movement statements with AND or OR.');
|
||||
return { kind: input.kind, terms: input.terms.map(term => parse(term, depth + 1)) };
|
||||
}
|
||||
check(++leaves <= MOVEMENT_EXPRESSION_MAX_LEAVES,
|
||||
'Use at most sixteen ground-movement statements.');
|
||||
check(input.kind === 'move' && keys(input, 'kind,minimum,territory'),
|
||||
'Choose one physical ground-movement minimum and printed destination.');
|
||||
try { return { kind: 'move', ...parseMovementClaim(input) }; }
|
||||
catch (error) {
|
||||
if (error instanceof MovementClaimError) throw new MovementExpressionPromiseError(error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return parse(value, 0);
|
||||
}
|
||||
|
||||
export function parseMovementExpressionClaim(value: unknown): MovementExpressionClaim {
|
||||
check(object(value) && keys(value, 'claim,turn') && typeof value.turn === 'number' &&
|
||||
Number.isSafeInteger(value.turn) && value.turn >= 1,
|
||||
'Choose the current turn and a ground-movement expression.');
|
||||
return { turn: value.turn, claim: parseMovementExpression(value.claim) };
|
||||
}
|
||||
|
||||
export function movementExpressionText(expression: MovementExpression): string {
|
||||
const text = (node: MovementExpression): string => 'terms' in node ?
|
||||
`(${node.terms.map(text).join(node.kind === 'and' ? ' AND ' : ' OR ')})` : movementClaimText(node);
|
||||
return text(parseMovementExpression(expression));
|
||||
}
|
||||
|
||||
export function movementExpressionTerritories(expression: MovementExpression): string[] {
|
||||
const destinations = new Set<string>();
|
||||
const visit = (node: MovementExpression): void => {
|
||||
if ('terms' in node) node.terms.forEach(visit);
|
||||
else destinations.add(node.territory);
|
||||
};
|
||||
visit(parseMovementExpression(expression));
|
||||
return [...destinations];
|
||||
}
|
||||
|
||||
function validateProgress(value: unknown): asserts value is MovementExpressionProgress {
|
||||
check(Array.isArray(value), 'Retain the largest future physical ground move by named destination.');
|
||||
const destinations = new Set<string>();
|
||||
for (const entry of value) {
|
||||
check(object(entry) && keys(entry, 'maximum,territory') && printedTerritory(entry.territory) &&
|
||||
count(entry.maximum) && !destinations.has(entry.territory),
|
||||
'Retain unique printed destinations and physical ground-movement maxima from zero to twenty.');
|
||||
destinations.add(entry.territory);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluate(expression: MovementExpression, progress: readonly MovementExpressionProgressEntry[]): boolean {
|
||||
if ('terms' in expression) return expression.kind === 'and' ?
|
||||
expression.terms.every(term => evaluate(term, progress)) : expression.terms.some(term => evaluate(term, progress));
|
||||
return (progress.find(entry => entry.territory === expression.territory)?.maximum ?? 0) >= expression.minimum;
|
||||
}
|
||||
|
||||
export function evaluateMovementExpression(expression: MovementExpression,
|
||||
progress: readonly MovementExpressionProgressEntry[]): boolean {
|
||||
const parsed = parseMovementExpression(expression);
|
||||
validateProgress(progress);
|
||||
return evaluate(parsed, progress);
|
||||
}
|
||||
|
||||
export function liveMovementExpressionPromises<T extends MovementExpressionPromise>(promises: readonly T[], player: string,
|
||||
turn: number): T[] {
|
||||
return promises.filter(promise => promise.player === player && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released);
|
||||
}
|
||||
|
||||
function readPromise(promise: MovementExpressionPromise): MovementExpression {
|
||||
check(object(promise), 'The ground-movement expression commitment record is invalid.');
|
||||
const { claim } = parseMovementExpressionClaim({ turn: promise.turn, claim: promise.claim });
|
||||
validateProgress(promise.progress);
|
||||
const destinations = movementExpressionTerritories(claim);
|
||||
check(typeof promise.answer === 'boolean' && optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) &&
|
||||
!(promise.fulfilled && promise.released) && promise.progress.every(entry => destinations.includes(entry.territory)),
|
||||
'Retain a definite whole movement answer and its own matching future physical move progress.');
|
||||
return claim;
|
||||
}
|
||||
|
||||
/** Call only after a committed ordinary physical ground move. Shipments,
|
||||
* ambassador moves and marker pieces are not qualifying forces. A marker-only
|
||||
* event may have amount zero, which cannot advance a positive minimum. */
|
||||
export function recordMovementExpressionEvent<T extends MovementExpressionPromise>(promises: readonly T[], player: string,
|
||||
turn: number, event: MovementEvent): T[] {
|
||||
check(object(event) && keys(event, 'amount,territory') && identifier(event.territory) && count(event.amount),
|
||||
'Retain the exact destination and zero to twenty actual physical ground-moving forces.');
|
||||
const updates = new Map<T, T>();
|
||||
for (const promise of liveMovementExpressionPromises(promises, player, turn)) {
|
||||
const claim = readPromise(promise);
|
||||
if (!movementExpressionTerritories(claim).includes(event.territory)) continue;
|
||||
const old = promise.progress.find(entry => entry.territory === event.territory)?.maximum ?? 0;
|
||||
if (event.amount <= old) continue;
|
||||
const progress = promise.progress.map(entry => ({ ...entry }));
|
||||
const entry = progress.find(entry => entry.territory === event.territory);
|
||||
if (entry) entry.maximum = event.amount;
|
||||
else progress.push({ territory: event.territory, maximum: event.amount });
|
||||
const satisfied = evaluate(claim, progress);
|
||||
check(promise.answer || !satisfied,
|
||||
'This ground movement conflicts with your whole Truthtrance movement No answer.');
|
||||
updates.set(promise, { ...promise, progress, ...(promise.answer && satisfied ? { fulfilled: true } : {}) });
|
||||
}
|
||||
return promises.map(promise => updates.get(promise) ?? promise);
|
||||
}
|
||||
|
||||
export function validateMovementExpressionPromises(promises: readonly MovementExpressionPromise[], players: readonly string[]): void {
|
||||
check(Array.isArray(promises), 'The ground-movement expression commitment record is invalid.');
|
||||
const events = new Set<string>();
|
||||
for (const promise of promises) {
|
||||
const claim = readPromise(promise);
|
||||
const satisfied = evaluate(claim, promise.progress);
|
||||
check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.player) && identifier(promise.asker) &&
|
||||
players.includes(promise.player) && players.includes(promise.asker) && promise.player !== promise.asker &&
|
||||
(promise.answer || !satisfied) && (!promise.answer || promise.released || !!promise.fulfilled === satisfied),
|
||||
'The ground-movement expression lost its event, participants or whole-answer progress.');
|
||||
events.add(promise.event);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { gameTerritories, splitLocation } from './board';
|
||||
import { liveMovementPromises, movementEventAllowed, movementMatches, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises';
|
||||
import { evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories,
|
||||
MovementExpressionPromiseError, recordMovementExpressionEvent, type MovementExpression,
|
||||
type MovementExpressionPromise } from './movement-expression-promises';
|
||||
|
||||
export type MovementPromiseAdapter = {
|
||||
allowance: (game: Game, player: Player) => number;
|
||||
@@ -37,11 +40,16 @@ function* groups(player: Player, keys: string[], amount: number, index = 0,
|
||||
* The adapter shares authoritative movement validation/physical commitment.
|
||||
* Opposing arrival outcomes are not simulated or assumed. */
|
||||
export function findMovementCompletion(source: Game, actor: string,
|
||||
promises: readonly MovementPromise[], adapter: MovementPromiseAdapter): MovementCompletion | null {
|
||||
promises: readonly MovementPromise[], adapter: MovementPromiseAdapter,
|
||||
expressions: readonly MovementExpressionPromise[] = source.movementExpressionPromises ?? []): MovementCompletion | null {
|
||||
const failed = new Set<string>();
|
||||
const search = (game: Game, pending: readonly MovementPromise[], actions: Action[]): MovementCompletion | null => {
|
||||
const retainWhole = expressions.length > 0 || source.movementExpressionPromises !== undefined;
|
||||
const search = (game: Game, pending: readonly MovementPromise[], whole: readonly MovementExpressionPromise[],
|
||||
actions: Action[]): MovementCompletion | null => {
|
||||
const live = liveMovementPromises(pending, actor, game.turn);
|
||||
const needsMove = live.some(promise => promise.answer);
|
||||
const liveWhole = liveMovementExpressionPromises(whole, actor, game.turn);
|
||||
const positiveWhole = liveWhole.filter(promise => promise.answer);
|
||||
const needsMove = live.some(promise => promise.answer) || positiveWhole.length > 0;
|
||||
if (!needsMove && adapter.canFinish(game, actor)) return { actions };
|
||||
const player = game.players.find(candidate => candidate.id === actor)!;
|
||||
const physical = Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
|
||||
@@ -52,8 +60,12 @@ export function findMovementCompletion(source: Game, actor: string,
|
||||
const marker = player.faction === 'richese' ? player.noField?.deployed : null;
|
||||
const markerValue = marker ? player.noField!.tokens.find(token => token.id === marker.tokenId)?.value ?? 0 : 0;
|
||||
const revealable = Math.min(markerValue, player.reserves + recovery);
|
||||
if (live.some(promise => promise.answer && promise.minimum > physical +
|
||||
(player.shipped ? revealable : player.reserves + recovery))) return null;
|
||||
const possiblePhysical = physical + (player.shipped ? revealable : player.reserves + recovery);
|
||||
if (live.some(promise => promise.answer && promise.minimum > possiblePhysical) ||
|
||||
positiveWhole.some(promise => !evaluateMovementExpression(promise.claim,
|
||||
movementExpressionTerritories(promise.claim).map(territory => ({ territory,
|
||||
maximum: Math.max(promise.progress.find(entry => entry.territory === territory)?.maximum ?? 0,
|
||||
Math.min(20, possiblePhysical)) }))))) return null;
|
||||
const key = JSON.stringify([player.forces, player.elites, player.advisors, player.moved, player.shipped, player.reserves, player.tanks, player.hand.map(card => card.id),
|
||||
player.hajrPlays, player.hajrWindow, player.ornithopterExtraMoves, player.spice, game.choamMovement,
|
||||
player.fremenMovementBlocked, player.ixMovementBlocked, player.advisorFlipMovementBlocked, game.advisorMatchingDenied,
|
||||
@@ -62,11 +74,13 @@ export function findMovementCompletion(source: Game, actor: string,
|
||||
game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises,
|
||||
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
|
||||
game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
|
||||
live.map(promise => [promise.territory, promise.minimum, promise.answer])]);
|
||||
live.map(promise => [promise.territory, promise.minimum, promise.answer]),
|
||||
liveWhole.map(promise => [promise.claim, promise.answer, promise.progress])]);
|
||||
if (failed.has(key)) return null;
|
||||
const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:'));
|
||||
destinations.sort((a, b) => Number(live.some(p => p.answer && p.territory === b.id)) -
|
||||
Number(live.some(p => p.answer && p.territory === a.id)) ||
|
||||
const wanted = new Set([...live.filter(promise => promise.answer).map(promise => promise.territory),
|
||||
...positiveWhole.flatMap(promise => movementExpressionTerritories(promise.claim))]);
|
||||
destinations.sort((a, b) => Number(wanted.has(b.id)) - Number(wanted.has(a.id)) ||
|
||||
(marker ? Number(['arrakeen', 'carthag'].includes(b.id)) - Number(['arrakeen', 'carthag'].includes(a.id)) : 0));
|
||||
if (needsMove && (marker || !live.some(promise => promise.answer && promise.minimum > physical)) &&
|
||||
!game.pendingNullentropy && player.moved < adapter.allowance(game, player)) {
|
||||
@@ -90,12 +104,20 @@ export function findMovementCompletion(source: Game, actor: string,
|
||||
const total = keys.reduce((sum, key) => sum + player.forces[key], 0);
|
||||
const carriesMarker = marker?.location.territory === origin && !!player.noFieldEvent;
|
||||
const amounts = [...(carriesMarker ? [0] : []), ...Array.from({ length: total }, (_, i) => i + 1)];
|
||||
amounts.sort((a, b) => Number(live.some(p => p.answer && p.territory === destination.id && b >= p.minimum)) -
|
||||
Number(live.some(p => p.answer && p.territory === destination.id && a >= p.minimum)) || a - b);
|
||||
const helpsWhole = (expression: MovementExpression, amount: number): boolean => 'terms' in expression
|
||||
? expression.terms.some(term => helpsWhole(term, amount))
|
||||
: expression.territory === destination.id && amount >= expression.minimum;
|
||||
const helps = (amount: number) => live.some(p => p.answer && p.territory === destination.id && amount >= p.minimum) ||
|
||||
positiveWhole.some(promise => helpsWhole(promise.claim, amount));
|
||||
amounts.sort((a, b) => Number(helps(b)) - Number(helps(a)) || a - b);
|
||||
for (const amount of amounts) {
|
||||
const event = { territory: destination.id, amount };
|
||||
if (!movementEventAllowed(live, event)) continue;
|
||||
if (!mayMoveAgain && live.some(promise => promise.answer && !movementMatches(promise, event))) continue;
|
||||
let projectedWhole: MovementExpressionPromise[];
|
||||
try { projectedWhole = recordMovementExpressionEvent(whole, actor, game.turn, event); }
|
||||
catch (error) { if (error instanceof MovementExpressionPromiseError) continue; throw error; }
|
||||
if (!mayMoveAgain && liveMovementExpressionPromises(projectedWhole, actor, game.turn).some(promise => promise.answer)) continue;
|
||||
const markers: Partial<Action>[] = carriesMarker
|
||||
? [...(amount ? [{}] : []), { noField: marker!.tokenId, event: player.noFieldEvent }] : [{}];
|
||||
for (const group of groups(player, keys, amount)) for (const mode of modes) for (const stance of stances) for (const selected of markers) {
|
||||
@@ -103,19 +125,26 @@ export function findMovementCompletion(source: Game, actor: string,
|
||||
const next = adapter.project(game, actor, action);
|
||||
if (!next) continue;
|
||||
const settled = settleMovementEvent(pending, actor, game.turn, next.event);
|
||||
// The projection owns the exact physical amount (markers excluded).
|
||||
// Keep promise progress on detached states only.
|
||||
let settledWhole: MovementExpressionPromise[];
|
||||
try { settledWhole = recordMovementExpressionEvent(whole, actor, game.turn, next.event); }
|
||||
catch (error) { if (error instanceof MovementExpressionPromiseError) continue; throw error; }
|
||||
next.game.movementPromises = structuredClone(settled);
|
||||
const result = search(next.game, settled, [...actions, action]);
|
||||
if (retainWhole) next.game.movementExpressionPromises = structuredClone(settledWhole);
|
||||
const result = search(next.game, settled, settledWhole, [...actions, action]);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const prepared of adapter.preparations(game, actor)) {
|
||||
const result = search(prepared.game, live, [...actions, ...prepared.actions]);
|
||||
const result = search(prepared.game, live, whole, [...actions, ...prepared.actions]);
|
||||
if (result) return result;
|
||||
}
|
||||
failed.add(key); return null;
|
||||
};
|
||||
const initial = structuredClone(source); initial.movementPromises = structuredClone([...promises]);
|
||||
return search(initial, promises, []);
|
||||
if (retainWhole) initial.movementExpressionPromises = structuredClone([...expressions]);
|
||||
return search(initial, promises, expressions, []);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import type { Game } from './engine';
|
||||
export type MovementClaim = { territory: string; minimum: number };
|
||||
export type MovementEvent = { territory: string; amount: number };
|
||||
export type MovementPromise = MovementClaim & {
|
||||
/** New answers share accepted order with whole expressions; older saves omit it. */
|
||||
event?: string;
|
||||
turn: number;
|
||||
player: string;
|
||||
asker: string;
|
||||
|
||||
@@ -3382,6 +3382,31 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Original public H/A/E sources and actual callbacks cover Yes/No, payments, release, multiple original cards, stale ownership and earlier shipment funding. Broader rule/module, recovery and deployed acceptance remain required.', evidence: ['tests/fixture-truth-bid.ts', 'tests/truth-bid-runtime.test.ts', 'tests/truth-bid-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-movement-expression',
|
||||
title: 'Truthtrance: combined ground moves',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Combine future physical ground moves with full AND/OR while preserving whole No, actual entitlements and maximum qualifying group sizes.',
|
||||
steps: [
|
||||
'Choose Combine ground moves with AND/OR and add printed Arrakis destination/minimum conditions. AND requires all children; OR at least one. The editor supports sixteen conditions and depth four as software input limits.',
|
||||
'Each condition asks for one future qualifying physical ground group after this answer and before your Movement opportunity ends. Smaller moves never add together. Repeated same-destination conditions share the largest actual future group; earlier moves are excluded.',
|
||||
'No negates the whole expression. No to moving to A AND B may permit either first move while forbidding completing both. No to A OR B forbids either qualifying move. A Yes becomes fulfilled only when actual committed moves make the full expression true.',
|
||||
'The native owned-action search retains all alternatives after real progress. Several required moves need actual remaining entitlements or owned Hajr/Ornithopter preparations; the question grants none. Native range, storms, physical counters, elite allocations, shipment preparation and opponent responses remain authoritative.',
|
||||
'Only completed ordinary ground movement records progress. Shipment, concealed No-Field markers, HMS relocation and Ambassador relocation do not fulfill a condition. Physical advisors and elites retain their native physical counting. A pending range response records no movement until its real commitment.',
|
||||
'Persistent own maximum progress, private feasible answers and an exact event/phase guide connect to real native actions and all four minimal profiles. The guide may prepare cards, forces or payments at their original windows. Another legal OR route may be chosen manually. Wire force records retain exact native contents.',
|
||||
'Supported native Basic/Advanced phases0–5 retain their existing current-turn opportunity and owned preparation scope. Finishing movement cannot evade Yes; No closes at the actual owner opportunity end. Current-fact/cross-family expressions, optional modules and complete-mode acceptance remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-movement', 'truthtrance-shipment', 'truthtrance-revival-expression', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Strict whole-tree parsing, immutable maximum progress and joint native scalar/expression reachability preserve owned multi-move entitlements and whole No.', evidence: ['game/movement-expression-promises.ts', 'game/movement-promise-search.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Full expression editor, guarded preview/Ask, target-only answers, own progress and exact phase/event guide are connected.', evidence: ['components/movement-expression-fields.tsx', 'components/movement-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and follow owned native movement/preparation guides. Strategic initiation/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-movement-expression-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'Source composition, physical event meaning, opportunity/deadline, preparation and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MOVEMENT_EXPRESSIONS.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pure/search, controlled editor/guide and original Basic/Advanced/Hajr/Charity/Fremen cases. Wider composition and browser/deployed acceptance remain open.', evidence: ['tests/movement-expression-promises.test.ts', 'tests/fixture-truth-movement-expression.ts', 'tests/truth-movement-expression-runtime.test.ts', 'tests/truth-movement-expression-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-movement',
|
||||
title: 'Truthtrance: ground-movement promises',
|
||||
@@ -3545,6 +3570,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
|
||||
'A current-Nexus alliance question binds actual final membership with a named partner at that Nexus close. Yes uses existing partnership or an actual invitation; offers alone do not form alliances. No may require a later break before Ready. Unknown can save the card until consent exists. Other players retain their original withdrawal/break rights. See the alliance-promise guide for its provisional interval and scope.',
|
||||
'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.',
|
||||
'A grouped ground-movement question combines future physical group minima with full AND/OR. One move must meet each leaf; smaller moves never add together. Actual owned entitlements can fulfill several leaves over several moves, while No negates the whole expression. See the combined movement guide for its provisional opportunity and scope.',
|
||||
'A grouped ordinary-bribe question combines named future payment minima with full AND/OR. No negates the whole question; real payments rebuild legal funded alternatives, and next-opening recipient collection adds no new progress. See the combined bribe guide for its provisional interval and scope.',
|
||||
'A grouped Revival question combines future ordinary force minima and named native leader/Kwisatz returns with full AND/OR. No negates the whole expression, not every leaf. Legal alternatives are rebuilt after actual returns without fixing one branch. See the grouped Revival guide for input limits, source and provisional scope.',
|
||||
'A named leader-revival question binds an actual future personal native return before your Revival opportunity ends, including Kwisatz where eligible. Force/leader promises share costs and slots; prior/card/special/foreign-ghola returns do not count. See the named-leader guide for provisional scope and preparation.',
|
||||
@@ -3565,6 +3591,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-plan-expression',
|
||||
'truthtrance-shipment',
|
||||
'truthtrance-movement',
|
||||
'truthtrance-movement-expression',
|
||||
'truthtrance-bid',
|
||||
'truthtrance-revival',
|
||||
'truthtrance-leader-revival',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseMovementExpressionClaim, movementExpressionText, type MovementExpressionClaim } from './movement-expression-promises';
|
||||
import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionText,
|
||||
type BribeExpressionClaim } from './bribe-expression-promises';
|
||||
import { parseRevivalExpressionClaim, revivalExpressionText, revivalExpressionPromiseModeSupported, type RevivalExpressionClaim } from './revival-expression-promises';
|
||||
@@ -67,6 +68,7 @@ export type TruthFact =
|
||||
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type TruthQuestion =
|
||||
| ({ kind: 'movementExpression'; target: string } & MovementExpressionClaim)
|
||||
| ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim)
|
||||
| ({ kind: 'revivalExpression'; target: string } & RevivalExpressionClaim)
|
||||
| ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim)
|
||||
@@ -309,6 +311,14 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
'Choose the current revealed battle and a still-unsubmitted Traitor decision.');
|
||||
return { kind: 'traitorCall', target: v.target as string, battle: v.battle, territory: v.territory as string };
|
||||
}
|
||||
if (v.kind === 'movementExpression') {
|
||||
check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn',
|
||||
'Supply the current turn, complete movement expression and respondent.');
|
||||
const claim = parseMovementExpressionClaim({ turn: v.turn, claim: v.claim });
|
||||
check(claim.turn === g.turn && movementPromiseTargetAvailable(g, v.target as string),
|
||||
'Choose a supported unused ground-movement opportunity with pending decisions finished.');
|
||||
return { kind: 'movementExpression', target: v.target as string, ...claim };
|
||||
}
|
||||
if (v.kind === 'movement') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory',
|
||||
'Supply one ground-movement destination, minimum and respondent.');
|
||||
@@ -534,6 +544,7 @@ export function truthQuestionText(
|
||||
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
||||
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
||||
if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`;
|
||||
if (q.kind === 'movementExpression') return `Will you ${movementExpressionText(q.claim)} before your Shipment and Movement turn ends?`;
|
||||
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
|
||||
if (q.kind === 'shipment')
|
||||
return `Will you ${shipmentClaimText(q)} this turn?`;
|
||||
@@ -576,6 +587,8 @@ export function resolveTruthAction(
|
||||
asker: string,
|
||||
mixed?: MixedShipmentExpression,
|
||||
) => void;
|
||||
movementExpressionAnswers?: (g: Game, p: Player, claim: MovementExpressionClaim) => TruthAnswer[];
|
||||
bindMovementExpression?: (g: Game, p: Player, claim: MovementExpressionClaim, answer: boolean, asker: string) => void;
|
||||
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
|
||||
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
|
||||
allianceAnswers?: (g: Game, p: Player, claim: AllianceClaim) => TruthAnswer[];
|
||||
@@ -719,6 +732,9 @@ export function resolveTruthAction(
|
||||
if (w.question.kind === 'alliance')
|
||||
check(effects.allianceAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a current Nexus question with a compatible answer.');
|
||||
if (w.question.kind === 'movementExpression')
|
||||
check(effects.movementExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a supported whole ground-movement question.');
|
||||
if (w.question.kind === 'bribeExpression')
|
||||
check(effects.bribeExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a whole bribe expression compatible with earlier answers.');
|
||||
@@ -776,6 +792,12 @@ export function resolveTruthAction(
|
||||
effects.bindShipment(g, p, claim, a.answer === 'yes', current.player,
|
||||
q.kind === 'mixedShipment' ? q.mixed : undefined);
|
||||
}
|
||||
if (q.kind === 'movementExpression') {
|
||||
check(effects.movementExpressionAnswers && effects.bindMovementExpression &&
|
||||
effects.movementExpressionAnswers(g, p, q).includes(a.answer),
|
||||
'Choose a whole movement answer compatible with owned actions and earlier promises.');
|
||||
if (a.answer !== 'unknown') effects.bindMovementExpression(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'movement') {
|
||||
check(effects.movementAnswers && effects.bindMovement && effects.movementAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with legal ground movement and earlier promises.');
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { parseMovementExpression, type MovementExpression } from '../game/movement-expression-promises';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import {
|
||||
actTruthMovement, createTruthMovementFixture, truthMovementClean,
|
||||
truthMovementInventory, truthMovementPlayer, type TruthMovementStep,
|
||||
} from './fixture-truth-movement';
|
||||
import { createTruthMovementAdvancedFixture } from './fixture-truth-movement-advanced';
|
||||
|
||||
export type TruthMovementExpressionStep = TruthMovementStep;
|
||||
export const actTruthMovementExpression = actTruthMovement;
|
||||
export const truthMovementExpressionClean = truthMovementClean;
|
||||
export const truthMovementExpressionInventory = truthMovementInventory;
|
||||
export const truthMovementExpressionPlayer = truthMovementPlayer;
|
||||
|
||||
/** Every continuation uses the original native response/readiness controls.
|
||||
* A live grouped obligation uses the same exact action the player sees. */
|
||||
export function nextTruthMovementExpression(game: Game): TruthMovementExpressionStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
for (const player of game.players) {
|
||||
const guide = viewGame(game, player.id).movementExpressionPromiseGuide;
|
||||
if (guide?.phase === game.phase) return { actor: player.id, action: guide.action };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthMovementExpression(game: Game, until: (state: Game) => boolean,
|
||||
actions: TruthMovementExpressionStep[] = []): Game {
|
||||
for (let count = 0; count < 1200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original grouped movement source ended before its requested control');
|
||||
const next = nextTruthMovementExpression(game);
|
||||
assert.ok(next, 'Supply the original grouped Truthtrance question or answer');
|
||||
game = actTruthMovementExpression(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original grouped movement source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthMovementExpression(game: Game, actions: TruthMovementExpressionStep[] = []): Game {
|
||||
return advanceTruthMovementExpression(game, truthMovementExpressionClean, actions);
|
||||
}
|
||||
export function finishTruthMovementExpression(game: Game, target = game.players[0].id,
|
||||
actions: TruthMovementExpressionStep[] = []): Game {
|
||||
game = settleTruthMovementExpression(game, actions);
|
||||
assert.equal(game.phase, 5); assert.equal(game.active, target);
|
||||
game = actTruthMovementExpression(game, target, { type: 'endMovement' }, actions);
|
||||
game = settleTruthMovementExpression(game, actions);
|
||||
assert.ok(!game.movementRemaining?.includes(target), 'Only actual owner endMovement closes its interval');
|
||||
return game;
|
||||
}
|
||||
export function completeTruthMovementExpression(game: Game, target = game.players[0].id,
|
||||
actions: TruthMovementExpressionStep[] = []): Game {
|
||||
const live = () => game.movementExpressionPromises?.some(promise => promise.player === target &&
|
||||
promise.turn === game.turn && promise.answer && !promise.fulfilled && !promise.released);
|
||||
for (let count = 0; count < 12 && live(); count++) {
|
||||
game = settleTruthMovementExpression(game, actions);
|
||||
const guide = viewGame(game, target).movementExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.phase, game.phase);
|
||||
game = settleTruthMovementExpression(actTruthMovementExpression(game, target, guide.action, actions), actions);
|
||||
}
|
||||
assert.ok(!live(), 'The exact native movement guide fulfills the accepted whole expression');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Thin adapter over genuine public Basic/Advanced base33 starts. The optional
|
||||
* Charity source replays only the original setup prefix from its unopened deal,
|
||||
* selects the same still-unopened Spice cards, and stops at real phase2. */
|
||||
export function createTruthMovementExpressionFixture(options: {
|
||||
advanced?: boolean; hajr?: boolean; window?: 'active' | 'charity'; claim?: MovementExpression;
|
||||
} = {}) {
|
||||
const base = options.advanced
|
||||
? createTruthMovementAdvancedFixture({ hajr: options.hajr })
|
||||
: createTruthMovementFixture({ hajr: options.hajr });
|
||||
const window = options.window ?? 'active';
|
||||
let game = structuredClone(base.game), actions = structuredClone(base.actions);
|
||||
let afterSetup = structuredClone(base.afterSetup), afterSetupActionCount: number | null = null;
|
||||
let spiceOrder = structuredClone(base.spiceOrder);
|
||||
if (window === 'charity') {
|
||||
const start = base.actions.findIndex(step => step.action.type === 'start'); assert.ok(start >= 0);
|
||||
actions = structuredClone(base.actions.slice(0, start + 1));
|
||||
game = advanceTruthMovementExpression(structuredClone(base.beforeStartingDeal),
|
||||
state => state.status === 'playing', actions);
|
||||
afterSetup = structuredClone(game); afterSetupActionCount = actions.length;
|
||||
spiceOrder = base.spiceOrder.map(({ position, card }) => {
|
||||
const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
|
||||
JSON.stringify(candidate) === JSON.stringify(card));
|
||||
assert.ok(from >= position, 'Choose the same original unopened ordinary Spice card');
|
||||
const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
|
||||
return { position, from, card: structuredClone(selected) };
|
||||
});
|
||||
game = advanceTruthMovementExpression(game,
|
||||
state => state.turn === 1 && state.phase === 2 && truthMovementExpressionClean(state), actions);
|
||||
assert.equal(truthMovementExpressionPlayer(game, base.target).shipped, false);
|
||||
assert.equal(truthMovementExpressionPlayer(game, base.target).moved, 0);
|
||||
assert.deepEqual(truthMovementExpressionPlayer(game, base.target).forces, { 'arrakeen:10': 10 });
|
||||
}
|
||||
assert.equal(game.advanced, options.advanced ?? false); assert.deepEqual(game.expansions, []);
|
||||
assert.deepEqual(truthMovementExpressionInventory(game), base.originalCards);
|
||||
assert.ok(truthMovementExpressionPlayer(game, base.asker).hand.some(card => card.id === base.truth));
|
||||
assert.equal(game.movementExpressionPromises, undefined); assert.equal(game.truthtrance ?? null, null);
|
||||
const firstMove = { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 } satisfies Action;
|
||||
const secondMove = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action;
|
||||
const claim = parseMovementExpression(options.claim ?? { kind: 'and', terms: [
|
||||
{ kind: 'move', territory: 'imperial_basin', minimum: 1 },
|
||||
{ kind: 'move', territory: 'carthag', minimum: 1 },
|
||||
] });
|
||||
const question = { kind: 'movementExpression' as const, target: base.target, turn: game.turn, claim };
|
||||
return { ...base, base, game, source: structuredClone(game), window, claim, question,
|
||||
afterSetup, afterSetupActionCount, spiceOrder,
|
||||
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
|
||||
sourceCounts: { inheritedActive: base.sourceActionCount, setup: afterSetupActionCount, selected: actions.length },
|
||||
firstMove, secondMove, sameDestinationMove: { ...firstMove },
|
||||
hajrAction: base.hajr ? { type: 'card', card: base.hajr } satisfies Action : null,
|
||||
declaration: { type: 'card', card: base.truth } satisfies Action,
|
||||
questionAction: { type: 'truthAsk', question } satisfies Action,
|
||||
sourceDisclosure: `${base.staging.join(' ')} This grouped adapter changes only the structured question. ${window === 'charity'
|
||||
? 'Its recorded source excludes the original active-window suffix: the unopened deal is replayed, the same unopened Spice selection is preserved, and actual Storm/Spice controls stop at clean Charity.'
|
||||
: 'Its recorded source retains the exact original public setup, phase controls and paid one-force Polar Sink shipment.'} The future Imperial Basin9 and Carthag11 ground groups use the original Arrakeen10 army; Hajr remains the actually dealt physical card. No played clock, purse, hand, army, movement allowance, answer or progress is installed.` };
|
||||
}
|
||||
export type TruthMovementExpressionFixture = ReturnType<typeof createTruthMovementExpressionFixture>;
|
||||
|
||||
export function declareTruthMovementExpression(f: TruthMovementExpressionFixture, source = f.game) {
|
||||
const before = structuredClone(source), actions: TruthMovementExpressionStep[] = [];
|
||||
let game = actTruthMovementExpression(source, f.asker, f.declaration, actions);
|
||||
const opened = structuredClone(game);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthMovementExpression(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, before, opened, actions, actionCount: actions.length };
|
||||
}
|
||||
export function askTruthMovementExpression(game: Game, asker: string, target: string, claim: MovementExpression,
|
||||
actions: TruthMovementExpressionStep[] = []): Game {
|
||||
return actTruthMovementExpression(game, asker, { type: 'truthAsk', question: {
|
||||
kind: 'movementExpression', target, turn: game.turn, claim: parseMovementExpression(claim),
|
||||
} }, actions);
|
||||
}
|
||||
export function answerTruthMovementExpression(game: Game, target: string, answer: 'yes' | 'no',
|
||||
actions: TruthMovementExpressionStep[] = []): Game {
|
||||
return actTruthMovementExpression(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthMovementExpression(f: TruthMovementExpressionFixture, answer: 'yes' | 'no' = 'yes',
|
||||
claim = f.claim, source = f.game) {
|
||||
const declared = declareTruthMovementExpression(f, source), actions = [...declared.actions];
|
||||
const asked = askTruthMovementExpression(declared.game, f.asker, f.target, claim, actions);
|
||||
const game = answerTruthMovementExpression(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length };
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { territory } from '../game/board';
|
||||
import type { Action, Game } from '../game/engine';
|
||||
import {
|
||||
MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES, MovementExpressionPromiseError,
|
||||
evaluateMovementExpression, liveMovementExpressionPromises, movementExpressionTerritories, movementExpressionText,
|
||||
parseMovementExpression, parseMovementExpressionClaim, recordMovementExpressionEvent, validateMovementExpressionPromises,
|
||||
type MovementExpression, type MovementExpressionProgress, type MovementExpressionPromise,
|
||||
} from '../game/movement-expression-promises';
|
||||
import { findMovementCompletion, type MovementPromiseAdapter } from '../game/movement-promise-search';
|
||||
import type { MovementEvent, MovementPromise } from '../game/movement-promises';
|
||||
|
||||
const move = (destination = 'carthag', minimum = 3): MovementExpression => ({ kind: 'move', territory: destination, minimum });
|
||||
const group = (kind: 'and' | 'or', ...terms: MovementExpression[]): MovementExpression => ({ kind, terms });
|
||||
const progress = (destination: string, maximum: number): MovementExpressionProgress => [{ territory: destination, maximum }];
|
||||
const promise = (event = 'event', claim: MovementExpression = move(), extra: Partial<MovementExpressionPromise> = {}): MovementExpressionPromise =>
|
||||
({ event, turn: 2, player: 'mover', asker: 'asker', answer: true, progress: [], claim, ...extra });
|
||||
const atomic = (destination = 'carthag', minimum = 3, extra: Partial<MovementPromise> = {}): MovementPromise =>
|
||||
({ turn: 2, player: 'mover', asker: 'asker', answer: true, territory: destination, minimum, ...extra });
|
||||
const seats = ['mover', 'asker', 'other'];
|
||||
|
||||
void test('movement expressions parse all sixteen leaves and four nested groups through scalar printed-board rules', () => {
|
||||
assert.equal(MOVEMENT_EXPRESSION_MAX_DEPTH, 4); assert.equal(MOVEMENT_EXPRESSION_MAX_LEAVES, 16);
|
||||
const sixteen = group('and', ...Array.from({ length: 16 }, (_, index) => move('carthag', index + 1)));
|
||||
const parsed = parseMovementExpression(sixteen);
|
||||
assert.deepEqual(parsed, sixteen); assert.notEqual(parsed, sixteen);
|
||||
if ('terms' in parsed && 'terms' in sixteen) {
|
||||
assert.notEqual(parsed.terms, sixteen.terms); assert.notEqual(parsed.terms[0], sixteen.terms[0]);
|
||||
}
|
||||
const nested = (depth: number): MovementExpression => depth === 0 ? move() : group('or', move('arrakeen'), nested(depth - 1));
|
||||
assert.deepEqual(parseMovementExpression(nested(4)), nested(4));
|
||||
assert.throws(() => parseMovementExpression(nested(5)), MovementExpressionPromiseError);
|
||||
assert.throws(() => parseMovementExpression(group('or', ...Array.from({ length: 17 }, () => move()))), MovementExpressionPromiseError);
|
||||
assert.throws(() => parseMovementExpression(group('and',
|
||||
group('or', ...Array.from({ length: 9 }, () => move())),
|
||||
group('or', ...Array.from({ length: 8 }, () => move())))), MovementExpressionPromiseError);
|
||||
assert.deepEqual(parseMovementExpressionClaim({ turn: Number.MAX_SAFE_INTEGER, claim: move('polar_sink', 20) }),
|
||||
{ turn: Number.MAX_SAFE_INTEGER, claim: move('polar_sink', 20) });
|
||||
});
|
||||
|
||||
void test('strict movement parsing checks decisive later subtrees, exact fields and safe one-to-twenty minima', () => {
|
||||
const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] }, { kind: 'or', terms: [move()] },
|
||||
{ kind: 'not', terms: [move(), move()] }, { kind: 'move', territory: 'carthag', minimum: 1, progress: 0 },
|
||||
{ kind: 'and', terms: [move(), move('arrakeen')], branch: 0 },
|
||||
...['', ' carthag', 'homeworld:caladan', 'hidden_mobile_stronghold', 'missing'].map(destination => move(destination)),
|
||||
...[0, -1, 0.5, 21, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1, '3'].map(minimum =>
|
||||
({ kind: 'move', territory: 'carthag', minimum }))];
|
||||
for (const value of invalid) assert.throws(() => parseMovementExpression(value), MovementExpressionPromiseError);
|
||||
const decisive = group('or', move('carthag', 1), move('arrakeen', 0));
|
||||
assert.throws(() => evaluateMovementExpression(decisive, progress('carthag', 1)), MovementExpressionPromiseError);
|
||||
assert.throws(() => evaluateMovementExpression(group('and', move(), decisive), []), MovementExpressionPromiseError);
|
||||
for (const value of [{ turn: 0, claim: move() }, { turn: '2', claim: move() }, { turn: 2, claim: move(), branch: 0 },
|
||||
{ turn: 2 }, { claim: move() }])
|
||||
assert.throws(() => parseMovementExpressionClaim(value), MovementExpressionPromiseError);
|
||||
});
|
||||
|
||||
void test('nested movement text and unique destinations preserve the whole expression', () => {
|
||||
const claim = group('and', move('carthag', 3), group('or', move('arrakeen', 2), move('carthag', 6)));
|
||||
assert.deepEqual(movementExpressionTerritories(claim), ['carthag', 'arrakeen']);
|
||||
assert.equal(evaluateMovementExpression(claim, progress('carthag', 3)), false);
|
||||
assert.equal(evaluateMovementExpression(claim, progress('carthag', 6)), true);
|
||||
assert.equal(evaluateMovementExpression(claim, [...progress('carthag', 3), ...progress('arrakeen', 2)]), true);
|
||||
assert.equal(movementExpressionText(claim),
|
||||
'(make a ground movement of at least 3 physical forces to Carthag AND (make a ground movement of at least 2 physical forces to Arrakeen OR make a ground movement of at least 6 physical forces to Carthag))');
|
||||
});
|
||||
|
||||
void test('progress records one actual move maximum and never sums smaller arrivals or marker pieces', () => {
|
||||
const original = [promise('largest', move('carthag', 6))];
|
||||
let ledger = original;
|
||||
for (const amount of [0, 2, 4, 3, 5]) ledger = recordMovementExpressionEvent(ledger, 'mover', 2, { territory: 'carthag', amount });
|
||||
assert.deepEqual(ledger[0].progress, progress('carthag', 5)); assert.equal(ledger[0].fulfilled, undefined);
|
||||
const bySix = recordMovementExpressionEvent(ledger, 'mover', 2, { territory: 'carthag', amount: 6 });
|
||||
assert.equal(bySix[0].fulfilled, true); assert.deepEqual(bySix[0].progress, progress('carthag', 6));
|
||||
assert.deepEqual(original[0].progress, []); assert.deepEqual(ledger[0].progress, progress('carthag', 5));
|
||||
assert.equal(recordMovementExpressionEvent(original, 'mover', 2, { territory: 'carthag', amount: 0 })[0], original[0]);
|
||||
assert.equal(recordMovementExpressionEvent(original, 'mover', 2, { territory: 'hidden_mobile_stronghold', amount: 10 })[0], original[0]);
|
||||
});
|
||||
|
||||
void test('whole Yes keeps alternate OR routes after partial movement and shares a same-destination event across leaves', () => {
|
||||
const original = promise('alternate', group('or', move('carthag', 5), move('arrakeen', 3)));
|
||||
const partial = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'carthag', amount: 4 });
|
||||
assert.equal(partial[0].fulfilled, undefined);
|
||||
const alternate = recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'arrakeen', amount: 3 });
|
||||
assert.equal(alternate[0].fulfilled, true); assert.equal('branch' in alternate[0], false);
|
||||
assert.deepEqual(alternate[0].progress, [...progress('carthag', 4), ...progress('arrakeen', 3)]);
|
||||
const same = recordMovementExpressionEvent([promise('shared', group('and', move('carthag', 3), move('carthag', 5)))],
|
||||
'mover', 2, { territory: 'carthag', amount: 5 });
|
||||
assert.equal(same[0].fulfilled, true); assert.deepEqual(same[0].progress, progress('carthag', 5));
|
||||
assert.deepEqual(original.progress, []);
|
||||
});
|
||||
|
||||
void test('whole No to nested AND allows one conjunct and rejects the actual move completing the whole statement', () => {
|
||||
const original = promise('not-all', group('and', move('carthag', 2), group('or', move('arrakeen', 3), move('polar_sink', 4))),
|
||||
{ answer: false });
|
||||
const first = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'carthag', amount: 2 });
|
||||
assert.equal(first[0].fulfilled, undefined);
|
||||
for (const event of [{ territory: 'arrakeen', amount: 3 }, { territory: 'polar_sink', amount: 4 }])
|
||||
assert.throws(() => recordMovementExpressionEvent(first, 'mover', 2, event), MovementExpressionPromiseError);
|
||||
const reversed = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'arrakeen', amount: 3 });
|
||||
assert.throws(() => recordMovementExpressionEvent(reversed, 'mover', 2, { territory: 'carthag', amount: 2 }), MovementExpressionPromiseError);
|
||||
assert.deepEqual(first[0].progress, progress('carthag', 2));
|
||||
});
|
||||
|
||||
void test('whole No to nested OR forbids either completed branch while permitting an incomplete AND branch', () => {
|
||||
const original = promise('not-either', group('or', move('carthag', 2), group('and', move('arrakeen', 3), move('polar_sink', 4))),
|
||||
{ answer: false });
|
||||
assert.throws(() => recordMovementExpressionEvent([original], 'mover', 2, { territory: 'carthag', amount: 2 }), MovementExpressionPromiseError);
|
||||
const partial = recordMovementExpressionEvent([original], 'mover', 2, { territory: 'arrakeen', amount: 3 });
|
||||
assert.doesNotThrow(() => recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'carthag', amount: 1 }));
|
||||
assert.throws(() => recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'polar_sink', amount: 4 }), MovementExpressionPromiseError);
|
||||
});
|
||||
|
||||
void test('answer intervals exclude prior movements and retain other-player, old-turn, terminal and nonmatching records', () => {
|
||||
const earlier = recordMovementExpressionEvent([promise('earlier')], 'mover', 2, { territory: 'carthag', amount: 3 })[0];
|
||||
const later = promise('later');
|
||||
const records = [earlier, promise('released', move(), { released: true }), promise('old-turn', move(), { turn: 1 }),
|
||||
promise('other', move(), { player: 'other' }), promise('elsewhere', move('arrakeen')), later];
|
||||
assert.deepEqual(liveMovementExpressionPromises(records, 'mover', 2), [records[4], later]);
|
||||
const next = recordMovementExpressionEvent(records, 'mover', 2, { territory: 'carthag', amount: 2 });
|
||||
for (let index = 0; index < 5; index++) assert.equal(next[index], records[index]);
|
||||
assert.deepEqual(next[5].progress, progress('carthag', 2)); assert.equal(next[5].fulfilled, undefined);
|
||||
assert.deepEqual(later.progress, []);
|
||||
});
|
||||
|
||||
void test('rejected completion cannot partly update another answer or frozen input progress', () => {
|
||||
const records = [promise('yes'), promise('no', group('or', move(), move('arrakeen')), { answer: false })];
|
||||
const partial = recordMovementExpressionEvent(records, 'mover', 2, { territory: 'carthag', amount: 2 });
|
||||
const before = structuredClone(partial);
|
||||
partial.forEach(entry => { entry.progress.forEach(Object.freeze); Object.freeze(entry.progress); Object.freeze(entry); });
|
||||
Object.freeze(partial);
|
||||
assert.throws(() => recordMovementExpressionEvent(partial, 'mover', 2, { territory: 'carthag', amount: 3 }), MovementExpressionPromiseError);
|
||||
assert.deepEqual(partial, before); assert.equal(partial[0].fulfilled, undefined);
|
||||
});
|
||||
|
||||
void test('movement progress and events reject unsafe, overflowing, duplicate or extra-key entries', () => {
|
||||
const invalid: unknown[] = [null, {}, progress('carthag', -1), progress('carthag', 0.5), progress('carthag', 21),
|
||||
progress('carthag', Number.MAX_SAFE_INTEGER + 1), [...progress('carthag', 1), ...progress('carthag', 2)],
|
||||
progress('missing', 1), [{ territory: 'carthag', maximum: 1, sum: 1 }]];
|
||||
for (const value of invalid)
|
||||
assert.throws(() => evaluateMovementExpression(move(), value as MovementExpressionProgress), MovementExpressionPromiseError);
|
||||
for (const event of [{ territory: '', amount: 1 }, { territory: ' carthag', amount: 1 },
|
||||
{ territory: 'carthag', amount: -1 }, { territory: 'carthag', amount: 0.5 }, { territory: 'carthag', amount: 21 },
|
||||
{ territory: 'carthag', amount: Infinity }, { territory: 'carthag', amount: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ territory: 'carthag', amount: 1, marker: 1 }])
|
||||
assert.throws(() => recordMovementExpressionEvent([], 'mover', 2, event), MovementExpressionPromiseError);
|
||||
assert.throws(() => recordMovementExpressionEvent([promise('foreign-progress', move(), { progress: progress('arrakeen', 1) })],
|
||||
'mover', 2, { territory: 'carthag', amount: 1 }), MovementExpressionPromiseError);
|
||||
});
|
||||
|
||||
void test('movement ledger validation binds events, participants, matching maxima and whole-answer terminal flags', () => {
|
||||
const valid = [promise('live', move(), { progress: progress('carthag', 2) }),
|
||||
promise('fulfilled', move(), { progress: progress('carthag', 3), fulfilled: true }),
|
||||
promise('closed-no', move(), { answer: false, fulfilled: true }), promise('released', move(), { released: true })];
|
||||
const before = structuredClone(valid);
|
||||
assert.doesNotThrow(() => validateMovementExpressionPromises(valid, seats));
|
||||
assert.doesNotThrow(() => validateMovementExpressionPromises(JSON.parse(JSON.stringify(valid)) as MovementExpressionPromise[], seats));
|
||||
const base = promise();
|
||||
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
|
||||
...[{ event: '' }, { player: 'absent' }, { asker: 'absent' }, { asker: 'mover' }, { answer: 'yes' },
|
||||
{ fulfilled: 1 }, { released: 1 }, { fulfilled: true }, { fulfilled: true, released: true },
|
||||
{ progress: progress('carthag', 3) }, { progress: progress('arrakeen', 1) },
|
||||
{ answer: false, progress: progress('carthag', 3) }, { answer: false, progress: progress('carthag', 3), released: true },
|
||||
{ turn: 0 }].map(extra => [{ ...base, ...extra }])];
|
||||
for (const value of invalid)
|
||||
assert.throws(() => validateMovementExpressionPromises(value as MovementExpressionPromise[], seats), MovementExpressionPromiseError);
|
||||
assert.deepEqual(valid, before);
|
||||
});
|
||||
|
||||
/** Structural adapter seam only: native engine entitlement and physical-route
|
||||
* proof belongs to its runtime tests. This fixture consumes one real Hajr ID. */
|
||||
function searchSource(hajr = false): Game {
|
||||
return { turn: 2, advanced: false, players: [{ id: 'mover', faction: 'atreides',
|
||||
forces: { 'imperial_basin:9': 6 }, moved: 0, shipped: true, reserves: 0, tanks: 0, spice: 0,
|
||||
hand: hajr ? [{ id: 'owned-hajr', effect: 'hajr' }] : [] }], discard: [], hajr: [] } as unknown as Game;
|
||||
}
|
||||
function searchAdapter(projected: MovementEvent[] = []): MovementPromiseAdapter {
|
||||
return {
|
||||
allowance: game => game.hajr.includes('mover') ? 2 : 1,
|
||||
canFinish: () => true,
|
||||
*preparations(game) {
|
||||
const player = game.players[0], card = player.hand.find(card => card.effect === 'hajr');
|
||||
if (!card || game.hajr.includes(player.id)) return;
|
||||
const next = structuredClone(game);
|
||||
next.players[0].hand = next.players[0].hand.filter(held => held.id !== card.id);
|
||||
next.discard.push(card); next.hajr.push(player.id);
|
||||
yield { game: next, actions: [{ type: 'card', card: card.id }] };
|
||||
},
|
||||
reachable: (_game, _player, origin, destination, sector) => origin !== destination &&
|
||||
['carthag', 'arrakeen'].includes(destination) && sector === territory(destination).sectors[0],
|
||||
project(game, _actor, action: Action) {
|
||||
const next = structuredClone(game), player = next.players[0];
|
||||
const forces = action.forces as Record<string, number>;
|
||||
const amount = Object.values(forces).reduce((sum, value) => sum + value, 0);
|
||||
for (const [key, count] of Object.entries(forces)) {
|
||||
if (count > player.forces[key]) return null;
|
||||
player.forces[key] -= count;
|
||||
if (player.forces[key] === 0) delete player.forces[key];
|
||||
}
|
||||
const destination = `${String(action.territory)}:${String(action.sector)}`;
|
||||
player.forces[destination] = (player.forces[destination] ?? 0) + amount; player.moved++;
|
||||
const event = { territory: String(action.territory), amount }; projected.push(event);
|
||||
return { game: next, event };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void test('joint search uses an owned Hajr for two distinct AND moves and leaves the original state detached', () => {
|
||||
const source = searchSource(true), before = structuredClone(source);
|
||||
source.movementExpressionPromises = [promise('two-moves', group('and', move('arrakeen', 3), move('carthag', 3)))];
|
||||
const result = findMovementCompletion(source, 'mover', [], searchAdapter());
|
||||
assert.ok(result);
|
||||
assert.equal(result.actions.filter(action => action.type === 'move').length, 2);
|
||||
assert.equal(result.actions.filter(action => action.type === 'card' && action.card === 'owned-hajr').length, 1);
|
||||
assert.deepEqual(source.players, before.players); assert.deepEqual(source.discard, before.discard);
|
||||
assert.deepEqual(source.movementExpressionPromises[0].progress, []);
|
||||
assert.equal(findMovementCompletion(searchSource(), 'mover', [], searchAdapter(), source.movementExpressionPromises), null);
|
||||
});
|
||||
|
||||
void test('joint search retains alternate expression routes under scalar No and shares one move across positive answers', () => {
|
||||
const source = searchSource();
|
||||
const choice = promise('either', group('or', move('arrakeen', 3), move('carthag', 3)));
|
||||
const result = findMovementCompletion(source, 'mover', [atomic('arrakeen', 1, { answer: false }), atomic('carthag', 3)],
|
||||
searchAdapter(), [choice]);
|
||||
assert.ok(result); assert.equal(result.actions.length, 1); assert.equal(result.actions[0].territory, 'carthag');
|
||||
assert.equal(Object.values(result.actions[0].forces as Record<string, number>).reduce((sum, value) => sum + value, 0), 3);
|
||||
});
|
||||
|
||||
void test('joint search rejects whole-No completion before projecting physical pieces and respects earlier progress', () => {
|
||||
const projected: MovementEvent[] = [];
|
||||
const notBoth = promise('not-both', group('and', move('arrakeen', 3), move('carthag', 3)),
|
||||
{ answer: false, progress: progress('arrakeen', 3) });
|
||||
assert.equal(findMovementCompletion(searchSource(), 'mover', [atomic('carthag', 3)], searchAdapter(projected), [notBoth]), null);
|
||||
assert.deepEqual(projected, []);
|
||||
const permitted = findMovementCompletion(searchSource(), 'mover', [atomic('carthag', 2)], searchAdapter(), [notBoth]);
|
||||
assert.ok(permitted); assert.equal(permitted.actions[0].territory, 'carthag');
|
||||
assert.equal(findMovementCompletion(searchSource(), 'mover', [atomic('carthag', 3)], searchAdapter(),
|
||||
[promise('neither', group('or', move('arrakeen', 3), move('carthag', 3)), { answer: false })]), null);
|
||||
});
|
||||
|
||||
void test('whole-expression physical bounds retain reachable OR alternatives and previously satisfied larger leaves', () => {
|
||||
const source = searchSource();
|
||||
const choice = promise('smaller-alternate', group('or', move('arrakeen', 20), move('carthag', 3)));
|
||||
const byAlternate = findMovementCompletion(source, 'mover', [], searchAdapter(), [choice]);
|
||||
assert.ok(byAlternate); assert.equal(byAlternate.actions[0].territory, 'carthag');
|
||||
const partlyDone = promise('larger-earlier', group('and', move('arrakeen', 20), move('carthag', 3)),
|
||||
{ progress: progress('arrakeen', 20) });
|
||||
const remaining = findMovementCompletion(source, 'mover', [], searchAdapter(), [partlyDone]);
|
||||
assert.ok(remaining); assert.equal(remaining.actions.length, 1); assert.equal(remaining.actions[0].territory, 'carthag');
|
||||
assert.equal(findMovementCompletion(source, 'mover', [], searchAdapter(),
|
||||
[promise('impossible', group('or', move('arrakeen', 20), move('carthag', 7)))]), null);
|
||||
});
|
||||
|
||||
void test('explicit empty expression subsets preserve the legacy search contract even when the source has a pending expression', () => {
|
||||
const source = searchSource(); source.movementExpressionPromises = [promise('needs-both', group('and', move('arrakeen'), move('carthag')))];
|
||||
const result = findMovementCompletion(source, 'mover', [atomic()], {
|
||||
...searchAdapter(),
|
||||
project(game, actor, action) {
|
||||
assert.deepEqual(game.movementExpressionPromises, []);
|
||||
return searchAdapter().project(game, actor, action);
|
||||
},
|
||||
}, []);
|
||||
assert.ok(result); assert.equal(result.actions.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
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 { MovementExpressionFields, movementExpressionInputError } from '../components/movement-expression-fields';
|
||||
import { MovementExpressionPromises, MovementExpressionPromiseGuideButton } from '../components/movement-expression-promises';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { parseMovementExpression, type MovementExpression } from '../game/movement-expression-promises';
|
||||
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
const territories = [{ id: 'carthag', name: 'Carthag' }, { id: 'arrakeen', name: 'Arrakeen' }, { id: 'sietch_tabr', name: 'Sietch Tabr' }];
|
||||
const move = (minimum = 1, territory = 'carthag'): MovementExpression => ({ kind: 'move', territory, minimum });
|
||||
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-movement-expression-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-movement-expression-${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-movement-expression-${path}-minimum`);
|
||||
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
|
||||
}
|
||||
function editor(initial: MovementExpression, disabled = false) {
|
||||
let value = initial, changes = 0;
|
||||
return {
|
||||
get value() { return value; }, get changes() { return changes; },
|
||||
tree: () => MovementExpressionFields({ value, territories, disabled, onChange(next) { value = next; changes++; } }),
|
||||
html: () => renderToStaticMarkup(createElement(MovementExpressionFields, { value, territories, disabled, onChange() {} })),
|
||||
};
|
||||
}
|
||||
function nested(depth: number): MovementExpression {
|
||||
let value = move();
|
||||
for (let i = 0; i < depth; i++) value = { kind: 'and', terms: [value, move(2, 'arrakeen')] };
|
||||
return value;
|
||||
}
|
||||
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(MovementExpressionPromises, { game, busy: false, send() {} }));
|
||||
const event = 'controlled-movement-expression-event';
|
||||
|
||||
/** These controlled personalized projections test callbacks and labels only.
|
||||
* They do not claim source-earned cards, physical movement or feasibility. */
|
||||
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('TRUTHMOVEMENTEXPRESSIONCONTROLS', 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.ready = []; game.active = phase === 5 ? 'p' : null;
|
||||
game.movementExpressionPromises = [{ event, player: 'p', asker: 'a', turn: 2,
|
||||
claim: { kind: 'or', terms: [move(4), { kind: 'and', terms: [move(2, 'arrakeen'), move(3)] }] },
|
||||
answer: true, progress: [{ territory: 'carthag', maximum: 2 }, { territory: 'arrakeen', maximum: 1 }] }];
|
||||
game.movementExpressionPromiseGuide = { phase, action: { ...action, movementExpressionPromiseEvent: event } };
|
||||
game.movementPromises = [];
|
||||
game.auction = phase === 3 ? { bid: 4, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null } : null;
|
||||
return game;
|
||||
}
|
||||
function nativeProjected(method: 'silent' | 'onceAround') {
|
||||
const game = projected(3, { type: 'richeseBid' });
|
||||
const native = createRicheseAuction({ event: 'controlled-native-auction', cardId: 'controlled-card', owner: 'a',
|
||||
method, source: 'cache', eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] });
|
||||
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
|
||||
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
|
||||
game.movementExpressionPromiseGuide!.action = { type: 'richeseBid', event: native.event,
|
||||
amount: method === 'silent' ? 0 : null, allyPayment: 0, movementExpressionPromiseEvent: event };
|
||||
return game;
|
||||
}
|
||||
function paidBox(phase = 5) {
|
||||
const game = projected(phase, { type: 'decision', event: 'controlled-paid-box', card: 'controlled-hajr' });
|
||||
game.decision = { kind: 'nullentropy', player: 'p' };
|
||||
game.nullentropy = { card: { id: 'controlled-box', name: 'Nullentropy Box', kind: 'special', effect: 'nullentropyBox' },
|
||||
blocked: null, search: { event: 'controlled-paid-box', cards: [{ id: 'controlled-hajr', name: 'Hajr', kind: 'special', effect: 'hajr' }] } };
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('controlled movement editor retains sixteen exact leaves, arbitrary nested groups and singleton collapse', () => {
|
||||
const e = editor(move(2)); click(control(e.tree(), 'or-1'));
|
||||
for (let count = 3; count <= 16; count++) {
|
||||
click(control(e.tree(), 'add-1')); minimum(e.tree(), `1.${count}`, count);
|
||||
if (count % 3 === 0) select(e.tree(), `1.${count}`, 'territory', 'arrakeen');
|
||||
}
|
||||
const full = e.value; assert.ok(full.kind !== 'move'); assert.equal(full.terms.length, 16);
|
||||
assert.deepEqual(parseMovementExpression(full), full); assert.match(e.html(), /16 \/ 16 conditions/);
|
||||
assert.equal((e.html().match(/Ground-movement destination/g) ?? []).length, 16);
|
||||
const before = structuredClone(full), changes = e.changes;
|
||||
for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
|
||||
}
|
||||
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
|
||||
const grouped = editor({ kind: 'and', terms: [move(3), move(2, 'arrakeen')] });
|
||||
click(control(grouped.tree(), 'or-1.2')); select(grouped.tree(), '1.2', 'meaning', 'and');
|
||||
click(control(grouped.tree(), 'nested-or-1')); click(control(grouped.tree(), 'nested-and-1'));
|
||||
const groupedValue = grouped.value; assert.ok(groupedValue.kind !== 'move'); assert.equal(groupedValue.terms.length, 4);
|
||||
click(control(grouped.tree(), 'remove-1.2.2')); assert.deepEqual((grouped.value as typeof groupedValue).terms[1], move(2, 'arrakeen'));
|
||||
click(control(grouped.tree(), 'remove-1.4')); click(control(grouped.tree(), 'remove-1.3'));
|
||||
click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, move(2, 'arrakeen'));
|
||||
});
|
||||
|
||||
void test('depth-four limits and busy controls preserve structure while numeric and destination drafts stay repairable', () => {
|
||||
const deep = editor(nested(4)); assert.equal(movementExpressionInputError(deep.value), null);
|
||||
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
|
||||
for (const key of ['and-1', 'or-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); minimum(deep.tree(), '1.1.1.1.1', 20); assert.equal(deep.changes, 1);
|
||||
for (const invalid of [NaN, -1, 0, 0.5, 21, Infinity]) {
|
||||
const e = editor(move()); minimum(e.tree(), '1', invalid);
|
||||
const draft = e.value; assert.ok(draft.kind === 'move'); assert.ok(Object.is(draft.minimum, invalid));
|
||||
assert.ok(movementExpressionInputError(e.value)); assert.match(e.html(), /role="alert"/);
|
||||
select(e.tree(), '1', 'territory', ''); click(control(e.tree(), 'or-1'));
|
||||
const wrapped = e.value; assert.ok(wrapped.kind !== 'move'); assert.deepEqual(wrapped.terms[0], move(invalid, ''));
|
||||
minimum(e.tree(), '1.1', 2); assert.ok(movementExpressionInputError(e.value));
|
||||
select(e.tree(), '1.1', 'territory', 'arrakeen'); assert.equal(movementExpressionInputError(e.value), null);
|
||||
}
|
||||
for (const invalid of [nested(5), { kind: 'and', terms: [move()] }, { kind: 'or', terms: Array.from({ length: 17 }, () => move()) },
|
||||
move(1, 'homeworld:emperor'), move(1, 'hidden_mobile_stronghold')]) assert.ok(movementExpressionInputError(invalid));
|
||||
const frozen = editor({ kind: 'and', terms: [move(), move(2, 'arrakeen')] }, true), before = structuredClone(frozen.value);
|
||||
for (const key of ['add-1', 'nested-and-1', 'or-1', 'remove-1.2']) click(control(frozen.tree(), key));
|
||||
minimum(frozen.tree(), '1.1', 4); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'territory', 'carthag');
|
||||
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
|
||||
});
|
||||
|
||||
void test('persistent whole-No panel shows the full unpinned tree and destination maxima without summing smaller moves', () => {
|
||||
const game = projected(); game.movementExpressionPromiseGuide = null;
|
||||
game.movementExpressionPromises.push(
|
||||
{ ...game.movementExpressionPromises[0], event: 'no-expression', answer: false, claim: { kind: 'and', terms: [move(4), move(2, 'arrakeen')] } },
|
||||
{ ...game.movementExpressionPromises[0], event: 'fulfilled-expression', fulfilled: true },
|
||||
{ ...game.movementExpressionPromises[0], event: 'released-expression', released: true },
|
||||
{ ...game.movementExpressionPromises[0], event: 'foreign-expression', player: 'a', claim: move(19, 'sietch_tabr') },
|
||||
);
|
||||
const before = structuredClone(game), html = markup(game);
|
||||
assert.match(html, /Your movement expression promises/); assert.match(html, / OR /); assert.match(html, / AND /);
|
||||
assert.match(html, /Carthag: 2 physical forces; Arrakeen: 1 physical forces/);
|
||||
assert.match(html, /No negates the whole expression, not each condition separately/);
|
||||
assert.match(html, /Legal alternatives remain available; no fixed branch is selected for you/);
|
||||
assert.match(html, /Smaller moves never add together; progress records the maximum in one move/);
|
||||
assert.match(html, /Prior moves and shipments do not count/); assert.match(html, /before your Movement turn ends this round/);
|
||||
assert.match(html, /No remains binding through every later Hajr or Ornithopter move/);
|
||||
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
|
||||
assert.match(html, /href="\/rules#truthtrance-movement-expression"/);
|
||||
assert.doesNotMatch(html, /19 physical|controlled-movement-expression-event/); assert.deepEqual(game, before);
|
||||
for (const phase of [0, 1, 2, 3, 4]) { game.phase = phase; assert.match(markup(game), /Your Movement turn is still ahead/); }
|
||||
game.phase = 5; game.active = 'a'; assert.match(markup(game), /Your Movement turn is still ahead/);
|
||||
assert.equal(MovementExpressionPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
|
||||
});
|
||||
|
||||
void test('native movement, shipment, reveal, Hajr and Ornithopter guides forward every quoted field exactly', () => {
|
||||
const actions: Action[] = [
|
||||
projected().movementExpressionPromiseGuide!.action,
|
||||
{ type: 'move', forces: { 'arrakeen:10': 4 }, eliteForces: {}, territory: 'carthag', sector: 11,
|
||||
movementCard: 'controlled-ornithopter', ornithopter: 'twoGroups', ornithopterEvent: 'original-flight' },
|
||||
{ type: 'move', forces: {}, eliteForces: {}, noField: 'controlled-token', event: 'original-token-event', territory: 'carthag', sector: 11 },
|
||||
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0, noField: 'controlled-token' },
|
||||
{ type: 'revealNoField', event: 'original-token-event' }, { type: 'prepareAdvisors', territory: 'arrakeen', event: 'original-advisor-event' },
|
||||
{ type: 'card', card: 'controlled-hajr' }, { type: 'endMovement' },
|
||||
];
|
||||
for (const action of actions) {
|
||||
const game = projected(5, action), sent: Action[] = [], props = { game, busy: false, send(value: Action) { sent.push(value); } };
|
||||
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
|
||||
assert.equal(sent[0], game.movementExpressionPromiseGuide!.action); assert.deepEqual(sent, [game.movementExpressionPromiseGuide!.action]);
|
||||
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
|
||||
}
|
||||
assert.match(markup(projected()), /Move 4 forces to Carthag, sector 11/);
|
||||
assert.match(markup(projected(5, actions[1])), /Use Ornithopter and move 4 forces/);
|
||||
assert.match(markup(projected(5, actions[2])), /Move your concealed No-Field/);
|
||||
assert.match(markup(projected(5, actions[3])), /Ship a concealed No-Field/);
|
||||
assert.match(markup(projected(5, actions[4])), /Reveal your No-Field/);
|
||||
assert.match(markup(projected(5, actions[5])), /Prepare advisors in Arrakeen/);
|
||||
assert.match(markup(projected(5, actions[6])), /Play Hajr/);
|
||||
});
|
||||
|
||||
void test('owned current preparation guides preserve Charity, Revival, native auction and paid Box packets', () => {
|
||||
for (const [phase, action] of [[2, { type: 'charity' }], [2, { type: 'ready' }], [4, { type: 'revive', amount: 3, elite: 1 }],
|
||||
[4, { type: 'ready' }], [3, { type: 'passBid' }], [3, { type: 'decision', karama: false }]] satisfies [number, Action][]) {
|
||||
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);
|
||||
if (action.type === 'decision') game.decision!.player = 'a';
|
||||
else if (phase === 3) game.auction!.active = 'a'; else game.ready.push('p');
|
||||
press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
for (const method of ['silent', 'onceAround'] as const) {
|
||||
const game = nativeProjected(method), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
|
||||
assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/);
|
||||
game.richeseAuction!.event = 'later-event'; press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
for (const phase of [0, 2, 3, 4, 5]) {
|
||||
const game = paidBox(phase), sent: Action[] = []; game.active = 'a';
|
||||
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||||
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
|
||||
assert.match(markup(game), /Choose Hajr in your paid Box search/); assert.match(markup(game), /fee is paid only once/);
|
||||
game.nullentropy!.search!.event = 'later-paid-search'; press(button); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('stale callbacks reject changed phase, own event, busy windows and nested physical move mutation', () => {
|
||||
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!.phase = 4; }, game => { game.movementExpressionPromiseGuide!.action.territory = 'arrakeen'; },
|
||||
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.movementExpressionPromises[0].event = 'expired'; }, game => { game.movementExpressionPromises[0].player = 'a'; },
|
||||
game => { game.movementExpressionPromises[0].turn = 1; }, game => { game.movementExpressionPromises[0].fulfilled = true; },
|
||||
game => { game.movementExpressionPromises[0].released = true; }, game => { game.movementExpressionPromises = []; },
|
||||
game => { game.phaseOpening = { passed: [] }; }, game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
|
||||
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
|
||||
game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||||
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.techTokens = createTechTokens(); },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
|
||||
change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
|
||||
const button = guide(props); props.busy = true; press(button);
|
||||
const replaced = { game: projected(), busy: false, send() { assert.fail('A replaced view cannot dispatch'); } };
|
||||
const original = guide(replaced); replaced.game = { ...replaced.game }; press(original);
|
||||
});
|
||||
|
||||
void test('waiting and wrong-phase quotes cannot expose premature movement and scalar promises suppress a duplicate visible guide', () => {
|
||||
for (const [phase, action] of [[0, { type: 'ready' }], [1, { type: 'move' }], [2, { type: 'revive' }], [3, { type: 'ready' }],
|
||||
[4, { type: 'charity' }], [4, { type: 'ship' }], [5, { type: 'revive' }], [6, { type: 'move' }]] satisfies [number, Action][]) {
|
||||
const game = projected(phase, action), sent: Action[] = [];
|
||||
const button = guide({ game, busy: false, send(value) { sent.push(value); } });
|
||||
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const game = projected(); game.active = 'a'; game.movementExpressionPromiseGuide = null;
|
||||
assert.match(markup(game), /Your Movement turn is still ahead/); assert.doesNotMatch(markup(game), /<button/);
|
||||
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;
|
||||
assert.match(markup(game), /data-testid="movement-expression-promise-guide"/);
|
||||
});
|
||||
|
||||
void test('projected own card names label preparation while eligibility never examines unrelated secret or physical resources', () => {
|
||||
const cards = [
|
||||
{ id: 'controlled-ghola', name: 'Tleilaxu Ghola', kind: 'special' as const, effect: 'ghola' },
|
||||
{ id: 'controlled-box', name: 'Nullentropy Box', kind: 'special' as const, effect: 'nullentropyBox' },
|
||||
{ id: 'controlled-karama', name: 'Karama', kind: 'special' as const, effect: 'karama' },
|
||||
];
|
||||
const ghola = projected(5, { type: 'card', card: cards[0].id, amount: 2 });
|
||||
ghola.players.find(player => player.id === ghola.me)!.hand = cards;
|
||||
assert.match(markup(ghola), /Play Ghola to return 2 forces to reserves/);
|
||||
const box = projected(5, { type: 'card', card: cards[1].id }); box.players.find(player => player.id === box.me)!.hand = cards;
|
||||
assert.match(markup(box), /Pay 2 spice and search with Nullentropy Box/);
|
||||
const payment = projected(3, { type: 'decision', karama: true, card: cards[2].id });
|
||||
payment.decision = { kind: 'auctionPayment', player: 'p' }; payment.players.find(player => player.id === payment.me)!.hand = cards;
|
||||
assert.match(markup(payment), /Use Karama for the current auction payment/);
|
||||
for (const game of [projected(), ghola, box, payment, paidBox()]) {
|
||||
const sent: Action[] = [];
|
||||
for (const seat of game.players) for (const key of ['traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'reserves', 'tanks', 'elites', 'forces',
|
||||
...(seat.id !== game.me ? ['hand'] : [])]) Object.defineProperty(seat, key, {
|
||||
get() { throw Error(`No unrelated ${key} inspection`); }, configurable: true,
|
||||
});
|
||||
if (game.auction) Object.defineProperty(game.auction, 'card', { get() { throw Error('No unrevealed lot inspection'); } });
|
||||
for (const key of ['spiceDeck', 'deck', 'discard']) Object.defineProperty(game, key, {
|
||||
get() { throw Error(`No private ${key} inspection`); }, configurable: true,
|
||||
});
|
||||
assert.match(markup(game), /Your movement expression promises/);
|
||||
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
|
||||
assert.equal(sent[0], game.movementExpressionPromiseGuide!.action);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
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 type { MovementExpression } from '../game/movement-expression-promises';
|
||||
import { createTruthMovementAdvancedFixture } from './fixture-truth-movement-advanced';
|
||||
import {
|
||||
actTruthMovementExpression as act, advanceTruthMovementExpression as advance,
|
||||
answerTruthMovementExpression as answer, askTruthMovementExpression as ask,
|
||||
bindTruthMovementExpression as bind, completeTruthMovementExpression as complete,
|
||||
createTruthMovementExpressionFixture as fixture, declareTruthMovementExpression as declare,
|
||||
finishTruthMovementExpression as finish, settleTruthMovementExpression as settle,
|
||||
truthMovementExpressionClean as clean, truthMovementExpressionInventory as inventory,
|
||||
truthMovementExpressionPlayer as player,
|
||||
} from './fixture-truth-movement-expression';
|
||||
|
||||
const move = (territory = 'imperial_basin', minimum = 1): MovementExpression => ({ kind: 'move', territory, minimum });
|
||||
const both = (): MovementExpression => ({ kind: 'and', terms: [move(), move('carthag')] });
|
||||
const either = (): MovementExpression => ({ kind: 'or', terms: [move(), move('carthag')] });
|
||||
const promise = (game: Game, target = 'atreides') => game.movementExpressionPromises!.find(row => row.player === target)!;
|
||||
const maximum = (game: Game, territory: string, target = 'atreides') =>
|
||||
promise(game, target).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);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} movement AND uses two actual groups and the original Hajr`, () => {
|
||||
const f = fixture({ advanced }); assert.equal(f.setup.status, 'setup'); assert.equal(f.originalCards.length, 33);
|
||||
assert.deepEqual(f.game.expansions, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
|
||||
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 });
|
||||
const source = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, source);
|
||||
let game = bound.game; assert.deepEqual(promise(game).progress, []);
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
if (advanced) rejected(game, f.target, f.hajrAction!);
|
||||
game = settle(act(game, f.target, f.firstMove));
|
||||
assert.equal(player(game, f.target).moved, 1); assert.equal(maximum(game, 'imperial_basin'), 1);
|
||||
assert.equal(maximum(game, 'carthag'), 0); assert.ok(!promise(game).fulfilled && !promise(game).released);
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.phase, 5); 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.deepEqual(promise(game).progress, progress, 'An actual Hajr disposal grants movement without moving a force');
|
||||
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||
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).forces['arrakeen:10'], 8);
|
||||
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
|
||||
assert.equal(player(game, f.target).forces['carthag:11'], 1);
|
||||
assert.equal(player(game, f.target).moved, 2);
|
||||
game = finish(game, f.target); assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native movement OR Yes accepts the manually chosen alternate actual destination', () => {
|
||||
const f = fixture(); let game = bind(f, 'yes', either()).game;
|
||||
const guide = viewGame(game, f.target).movementExpressionPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'move');
|
||||
const alternate = guide.action.territory === 'imperial_basin' ? f.secondMove : f.firstMove;
|
||||
assert.notEqual(alternate.territory, guide.action.territory);
|
||||
game = settle(act(game, f.target, alternate));
|
||||
assert.equal(promise(game).fulfilled, true); assert.equal(maximum(game, String(alternate.territory)), 1);
|
||||
assert.ok(player(game, f.target).hand.some(card => card.id === f.hajr));
|
||||
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
for (const first of ['imperial_basin', 'carthag']) void test(`native movement AND No permits ${first} first and blocks only whole completion`, () => {
|
||||
const f = fixture(); let game = bind(f, 'no').game;
|
||||
const firstMove = first === 'imperial_basin' ? f.firstMove : f.secondMove;
|
||||
const secondMove = first === 'imperial_basin' ? f.secondMove : f.firstMove;
|
||||
game = settle(act(game, f.target, firstMove));
|
||||
assert.equal(maximum(game, first), 1); assert.ok(!promise(game).fulfilled);
|
||||
game = settle(act(game, f.target, f.hajrAction!)); rejected(game, f.target, secondMove);
|
||||
assert.equal(maximum(game, String(secondMove.territory)), 0);
|
||||
game = finish(game, f.target); assert.equal(promise(game).fulfilled, true);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native movement OR No rejects either actual ground group until owner finish', () => {
|
||||
const f = fixture(); let game = bind(f, 'no', either()).game;
|
||||
rejected(game, f.target, f.firstMove); rejected(game, f.target, f.secondMove);
|
||||
assert.deepEqual(promise(game).progress, []); assert.ok(!promise(game).fulfilled);
|
||||
game = finish(game, f.target); assert.equal(promise(game).fulfilled, true);
|
||||
assert.equal(player(game, f.target).moved, 0); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native repeated destination thresholds share the actual largest group and never sum two smaller Hajr moves', () => {
|
||||
const f = fixture(), claim: MovementExpression = { kind: 'and', terms: [move(), move('imperial_basin', 2)] };
|
||||
let game = bind(f, 'no', claim).game;
|
||||
game = settle(act(game, f.target, f.firstMove));
|
||||
assert.deepEqual(promise(game).progress, [{ territory: 'imperial_basin', maximum: 1 }]);
|
||||
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);
|
||||
let yes = bind(f, 'yes', claim).game;
|
||||
yes = settle(act(yes, f.target, { ...f.firstMove, amount: 2 }));
|
||||
assert.equal(promise(yes).fulfilled, true);
|
||||
assert.deepEqual(promise(yes).progress, [{ territory: 'imperial_basin', maximum: 2 }]);
|
||||
assert.ok(player(yes, f.target).hand.some(card => card.id === f.hajr));
|
||||
assert.deepEqual(inventory(finish(yes, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native prior ground movement is excluded from a newly answered whole expression', () => {
|
||||
const f = fixture(); let game = settle(act(f.game, f.target, f.firstMove));
|
||||
assert.equal(player(game, f.target).moved, 1); assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
|
||||
game = bind(f, 'yes', either(), 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)); assert.deepEqual(promise(game).progress, []);
|
||||
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);
|
||||
});
|
||||
|
||||
void test('native no-Hajr feasibility offers No for distinct-destination AND while OR retains either Yes route', () => {
|
||||
const f = fixture({ hajr: false }), declared = declare(f);
|
||||
let game = ask(declared.game, f.asker, f.target, both());
|
||||
assert.deepEqual(viewGame(game, f.target).truthMovementExpressionAnswers, ['no']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = answer(game, f.target, 'no'); game = settle(act(game, f.target, f.firstMove));
|
||||
assert.ok(!promise(game).fulfilled); assert.equal(promise(finish(game, f.target)).fulfilled, true);
|
||||
const orQuestion = ask(declared.game, f.asker, f.target, either());
|
||||
assert.deepEqual(viewGame(orQuestion, f.target).truthMovementExpressionAnswers, ['yes', 'no']);
|
||||
const yes = complete(answer(orQuestion, f.target, 'yes'), f.target);
|
||||
assert.equal(promise(yes).fulfilled, true); assert.deepEqual(inventory(finish(yes, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native exact nested movement guide accepts wire objects and rejects stale foreign or modified packets', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
assert.ok(viewGame(declared.game, f.asker).truthMovementExpressionTargets.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.claim);
|
||||
assert.equal(viewGame(asked, f.asker).truthMovementExpressionAnswers, 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.ok(guide.action.movementExpressionPromiseEvent);
|
||||
assert.deepEqual(viewGame(game, f.target).movementExpressionPromises, [promise(game)]);
|
||||
rejected(game, f.asker, guide.action);
|
||||
rejected(game, f.target, { ...guide.action, movementExpressionPromiseEvent: 'stale-whole-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.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all four minimal policies take the canonical native movement guide through real Hajr and complete AND', () => {
|
||||
const f = fixture(), bound = bind(f).game;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = structuredClone(bound);
|
||||
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); 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 whole movement promise follows actual Bidding Revival shipment and owner movement', () => {
|
||||
const f = fixture({ window: 'charity' }); assert.equal(f.game.phase, 2); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.sourceCount, f.sourceActions.length); assert.ok(f.afterSetupActionCount! < f.sourceCount);
|
||||
assert.ok(f.sourceActionCount < f.sourceCounts.inheritedActive);
|
||||
assert.equal(player(f.game, f.target).shipped, false); assert.equal(player(f.game, f.target).moved, 0);
|
||||
let game = bind(f).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.deepEqual(promise(game).progress, []); assert.equal(player(game, f.target).shipped, false);
|
||||
game = settle(act(game, f.target, f.shipmentStep.action, actions), actions);
|
||||
assert.equal(player(game, f.target).forces['polar_sink:0'], 1);
|
||||
assert.deepEqual(promise(game).progress, [], 'The native reserve shipment contributes no ground-movement progress');
|
||||
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);
|
||||
});
|
||||
|
||||
void test('native original Advanced Fremen grouped progress waits for the actual range response and physical commitment', () => {
|
||||
const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
|
||||
assert.ok(f.twoStepMove && f.twoStepDestination);
|
||||
let game = act(f.game, f.asker, f.declaration);
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, { type: 'truthPass' });
|
||||
}
|
||||
const claim: MovementExpression = { kind: 'or', terms: [
|
||||
move(f.twoStepDestination), move(f.twoStepDestination, 2),
|
||||
] };
|
||||
game = answer(ask(game, f.asker, f.target, claim), f.target, 'yes');
|
||||
const before = structuredClone(player(game, f.target));
|
||||
game = act(game, f.target, f.twoStepMove);
|
||||
assert.equal(game.response?.kind, 'fremenMovement');
|
||||
assert.deepEqual(promise(game, f.target).progress, []);
|
||||
assert.ok(!promise(game, f.target).fulfilled);
|
||||
assert.deepEqual(player(game, f.target).forces, before.forces);
|
||||
assert.deepEqual(player(game, f.target).elites, before.elites);
|
||||
game = settle(game);
|
||||
assert.deepEqual(promise(game, f.target).progress, [{ territory: f.twoStepDestination, maximum: 1 }]);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
const destination = `${f.twoStepDestination}:${String(f.twoStepMove.sector)}`;
|
||||
assert.equal(player(game, f.target).forces[destination], 1);
|
||||
assert.equal(player(game, f.target).elites!.forces[destination], 1);
|
||||
assert.deepEqual(inventory(finish(game, f.target)), f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user