Combine current-turn payments with shipment and movement promises

This commit is contained in:
Quintine committed 2026-10-26 21:09:49 +11:00
1 parent 8dbec05844
commit 0d72a33ee5
18 files changed
+2591 -7

No files matched your search

+2
View File
@@ -1,5 +1,6 @@
'use client';
import { TravelPromises } from './travel-promises';
import { TurnActionPromises } from './turn-action-promises';
import { MixedTraitorPromises } from './mixed-traitor-promises';
import { MixedBattlePromises } from './mixed-battle-promises';
import { TraitorDecisionPromises } from './traitor-decision-promises';
@@ -2066,6 +2067,7 @@ export function GameTable({
{g.status === 'playing' && <MixedBattlePromises game={g} />}
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
{g.status === 'playing' && <TurnActionPromises game={g} busy={busy} act={act} />}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
+24 -2
View File
@@ -3,6 +3,9 @@
import { TravelClaimFields, travelClaimInputError } from './travel-claim-fields';
import { TravelAnswers } from './travel-promises';
import type { TravelActionClaim } from '@/game/travel-expression-promises';
import { TurnActionFields, turnActionInputError } from './turn-action-fields';
import { TurnActionAnswers } from './turn-action-promises';
import type { TurnActionLeaf } from '@/game/turn-action-promises';
import { MixedTraitorFields, mixedTraitorInputError } from './mixed-traitor-fields';
import type { TraitorActionClaim } from '@/game/mixed-traitor-question';
@@ -93,7 +96,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -175,6 +178,12 @@ export function Truthtrance({
{ kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 1 } },
] });
const travelError = travelClaimInputError(travel, g, target);
const [turnActions, setTurnActions] = useState<MixedActionExpression<TurnActionLeaf>>({ kind: 'and', terms: [
{ kind: 'action', action: { kind: 'payment', recipient: g.me, minimum: 1 } },
{ kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 1 } },
{ kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 1 } },
] });
const turnActionsError = turnActionInputError(turnActions, g, target);
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState<MixedShipmentExpression>({
kind: 'and',
@@ -211,6 +220,7 @@ export function Truthtrance({
claim: planClaim,
}
: kind === 'mixedBattlePlan' ? { kind, target, battle: g.battle?.event ?? null, territory: g.battle?.territory ?? '', mixed: mixedBattle }
: kind === 'turnActions' ? { kind, target, turn: g.turn, mixed: turnActions }
: kind === 'travel' ? { kind, target, turn: g.turn, mixed: travel }
: kind === 'mixedTraitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '', mixed: mixedTraitor }
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
@@ -441,6 +451,8 @@ export function Truthtrance({
<div key={answer}>{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}</div>
))}
</>
) : w.question!.kind === 'turnActions' ? (
<TurnActionAnswers game={g} act={act} busy={busy} />
) : w.question!.kind === 'travel' ? (
<TravelAnswers game={g} act={act} busy={busy} />
) : w.question!.kind === 'traitorCall' || w.question!.kind === 'mixedTraitorCall' ? (
@@ -569,6 +581,7 @@ export function Truthtrance({
<option value="mixedBid">Combine current facts and ordinary bids</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
<option value="turnActions">Combine this turn’s payments, shipment and ground movements</option>
<option value="travel">Combine this turn’s shipment and ground movements</option>
<option value="shipment">
Bind a shipment from reserves
@@ -585,7 +598,15 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'travel' ? (
{kind === 'turnActions' ? (
<>
<TurnActionFields value={turnActions} game={g} target={target}
disabled={busy || !g.truthTurnActionTargets.includes(target)} onChange={setTurnActions} />
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with a supported payment, shipment or ground-movement opportunity this turn.
Ordinary payments may remain available after their Shipment and Movement opportunity ends.</p>}
<p className="notice" aria-label="Public turn-action question preview">{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'travel' ? (
<>
<TravelClaimFields value={travel} game={g} target={target}
disabled={busy || !g.truthTravelTargets.includes(target)} onChange={setTravel} />
@@ -833,6 +854,7 @@ export function Truthtrance({
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'turnActions' && (!!turnActionsError || !g.truthTurnActionTargets.includes(target))) ||
(kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) ||
(kind === 'mixedTraitorCall' && (!!mixedTraitorError || !g.truthMixedTraitorTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { TERRITORIES } from '@/game/board';
import type { GameView } from '@/game/engine';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, type TurnActionLeaf } from '@/game/turn-action-promises';
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
import { Input } from './ui/input';
type TurnActionExpression = MixedActionExpression<TurnActionLeaf>;
const recipients = (game: GameView, target: string) => {
const player = game.players.find(player => player.id === target);
return player ? game.players.filter(recipient => recipient.id !== target && recipient.id !== player.ally) : [];
};
const config: MixedActionConfig<TurnActionLeaf> = {
prefix: 'turn-action',
legend: 'Future payments, shipment and ground movements, with optional current facts',
actionLabel: 'payment, shipment or ground-movement condition',
actionTiming: 'Future ordinary payment, reserve shipment or ground move · this turn',
actionHelp: <>Ordinary payment conditions sum future real bribes to the same recipient after the answer this turn.
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
Payments made during Mentat Pause wait until next turn&apos;s Mentat opening; collection never counts twice.
Shipment conditions concern the single future original reserve shipment after the answer.
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
Shipment and movement close at your own Movement turn end, while payment conditions last until the actual turn ends.
AND requires every condition; OR keeps legal alternatives. No makes the whole expression false, not every condition separately.
Original costs, timing, cards, preparation and action rights still apply; this claim grants no extra action.</>,
fallbackError: 'Choose a future ordinary payment, reserve shipment or ground-movement condition, with optional current facts.',
maxLeaves: TURN_ACTION_MAX_LEAVES,
maxDepth: TURN_ACTION_MAX_DEPTH,
requireFact: false,
newAction: () => ({ kind: 'ship', territory: 'carthag', minimum: 1 }),
parseAction: (input, game, target) => {
const action = parseTurnActionLeaf(input);
if (action.kind === 'payment' && !recipients(game, target).some(recipient => recipient.id === action.recipient))
throw new Error('Choose a public non-allied recipient for the future ordinary payment.');
return action;
},
renderAction: ({ value, id, game, target, disabled, onChange }) => {
const choices = recipients(game, target);
return <>
<label htmlFor={`${id}-action`}>Future action</label>
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === value.kind) return;
if (kind === 'payment') onChange({ kind, recipient: '', minimum: value.minimum });
if (kind === 'ship' || kind === 'move') onChange({ kind,
territory: value.kind === 'payment' ? 'carthag' : value.territory, minimum: value.minimum });
}}>
<option value="payment">Make an ordinary payment</option>
<option value="ship">Ship from reserves</option>
<option value="move">Make ground movement</option>
</select>
{value.kind === 'payment' ? <>
<label htmlFor={`${id}-recipient`}>Ordinary payment recipient</label>
<select id={`${id}-recipient`} value={choices.some(recipient => recipient.id === value.recipient) ? value.recipient : ''}
disabled={disabled} aria-invalid={!choices.some(recipient => recipient.id === value.recipient)}
onChange={event => {
const recipient = event.currentTarget.value;
if (recipient === '' || choices.some(choice => choice.id === recipient)) onChange({ ...value, recipient });
}}>
<option value="">Choose a recipient</option>
{choices.map(recipient => <option key={recipient.id} value={recipient.id}>{recipient.name}</option>)}
</select>
</> : <>
<label htmlFor={`${id}-territory`}>{value.kind === 'ship' ? 'Reserve-shipment destination' : 'Ground-movement destination'}</label>
<select id={`${id}-territory`} value={TERRITORIES.some(destination => destination.id === value.territory) ? value.territory : ''}
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === value.territory)}
onChange={event => onChange({ ...value, 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`}>{value.kind === 'payment' ? 'Minimum future ordinary payments in spice'
: `Minimum physical forces in one ${value.kind === 'ship' ? 'reserve shipment' : 'ground move'}`}</label>
<Input id={`${id}-minimum`} type="number" min={1} max={value.kind === 'payment' ? Number.MAX_SAFE_INTEGER : 20}
step={1} required disabled={disabled} value={Number.isFinite(value.minimum) ? value.minimum : ''}
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1 || value.kind !== 'payment' && value.minimum > 20}
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
</>;
},
};
/** Public schema choices only; the native engine owns eligibility and funding. */
export function turnActionInputError(value: unknown, game: GameView, target: string): string | null {
return mixedActionInputError(value, game, target, config);
}
export function TurnActionFields(props: {
value: TurnActionExpression;
game: GameView;
target: string;
disabled: boolean;
onChange: (expression: TurnActionExpression) => void;
}) {
return MixedActionFields({ ...props, config });
}
+180
View File
@@ -0,0 +1,180 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { bribeAction } from '@/game/bribe-options';
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { Button } from './ui/button';
type TurnActionProps = { game: GameView; busy: boolean; act: (action: Action) => void };
/** One projected native packet; no client branch selection or completion solver. */
export function TurnActionGuideButton(props: TurnActionProps) {
const game = props.game, guide = game.turnActionGuide;
if (!guide) return null;
const actor = game.me, turn = game.turn, phase = game.phase, event = guide.event, action = guide.action;
const signature = JSON.stringify(action), auction = game.auction, decision = game.decision;
const native = game.richeseAuction, order = game.revivalOrder, revival = game.revival;
const auctionSource = (current: GameView) => current.auction && JSON.stringify({
bid: current.auction.bid, bidder: current.auction.bidder, active: current.auction.active,
remaining: current.auction.remaining, allyPayment: current.auction.allyPayment,
});
const nativeSource = (current: GameView) => current.richeseAuction && JSON.stringify({
event: current.richeseAuction.event, method: current.richeseAuction.method,
eligible: current.richeseAuction.eligible, submitted: current.richeseAuction.submitted,
active: current.richeseAuction.active, outcome: current.richeseAuction.outcome,
bid: current.richeseAuction.bid, bidder: current.richeseAuction.bidder,
passed: current.richeseAuction.passed, acted: current.richeseAuction.acted,
});
const auctionSignature = auctionSource(game), nativeSignature = nativeSource(game), orderSignature = JSON.stringify(order);
const box = game.nullentropy, search = box?.search;
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === actor);
const options = current.bribeOptions;
const target = action.type === 'bribe' && typeof action.target === 'string'
? current.players.find(player => player.id === action.target) : undefined;
const limit = options.targets.find(recipient => recipient.id === action.target);
// Ordinary bribes can remain legal during another player's native turn or
// after this payer has readied. The projected native timing stays decisive.
const payment = action.type === 'bribe' && !!me && !!target && target.id !== actor && target.id !== me.ally &&
options.blocked === null && Number.isSafeInteger(options.available) &&
typeof action.amount === 'number' && action.amount <= options.available &&
!!limit && Number.isSafeInteger(limit.maximum) && action.amount <= limit.maximum &&
!!bribeAction(options, target.id, action.amount);
const paidBox = action.type === 'decision' && current.decision === decision &&
current.decision?.kind === 'nullentropy' && current.nullentropy === box && current.nullentropy?.search === search &&
current.decision.player === actor && !!current.nullentropy?.search &&
current.nullentropy.search.event === action.event && !current.nullentropy.blocked;
const currentAuction = current.auction === auction && auctionSource(current) === auctionSignature && !!auction;
const invoice = phase === 3 && action.type === 'decision' && typeof action.karama === 'boolean' &&
(!action.karama || typeof action.card === 'string') &&
current.decision === decision && current.decision?.kind === 'auctionPayment' && current.decision.player === actor &&
!current.richeseAuction && currentAuction && current.auction?.bidder === actor;
const nativePass = phase === 3 && action.type === 'richeseBid' && current.richeseAuction === native &&
nativeSource(current) === nativeSignature && 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 requiredBid = phase === 3 && action.type === 'bid' && !current.richeseAuction && currentAuction &&
current.auction?.active === actor && typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount > 0;
const revivalTurn = current.revival === revival && !current.revival.pending && !current.revival.orderBlocked &&
current.revivalOrder === order && JSON.stringify(current.revivalOrder) === orderSignature &&
(!order || order.turn === turn && order.remaining[0] === actor && current.active === actor);
const opportunity = payment || paidBox || (phase === 2
? !current.ready.includes(actor) && ['charity', 'ready', 'card'].includes(action.type)
: phase === 3
? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
: phase === 4
? !current.ready.includes(actor) && revivalTurn && ['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready', 'card'].includes(action.type)
: phase === 5
? current.active === actor && (action.type === 'guildShip' && action.from === 'reserves' ||
['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type))
: (phase === 1 && current.nexus && !current.spiceWindow || phase === 7 || phase === 8) &&
action.type === 'ready' && !current.ready.includes(actor));
return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor &&
current.turnActionGuide === guide && guide.event === event && guide.action === action &&
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity &&
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released) &&
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || invoice) &&
!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 = game.combatLocations.find(location => location.id === action.territory)?.name ?? 'the quoted destination';
const recipient = game.players.find(player => player.id === action.target)?.name ?? 'the quoted recipient';
const sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : '';
const label = action.type === 'bribe' ? `Pay ${amount} spice to ${recipient} while honoring your whole answer`
: action.type === 'charity' ? 'Claim Charity while preserving your whole answer'
: action.type === 'ready' ? phase === 8 ? 'Ready to end this turn while honoring your whole answer'
: 'Finish this preparation phase while preserving your whole answer'
: action.type === 'passBid' ? 'Pass this bid while preserving your whole answer'
: action.type === 'bid' ? `Place the required bid of ${amount} while preserving your whole answer`
: action.type === 'richeseBid' ? action.amount === 0 ? 'Submit zero in this Silent auction while preserving your whole answer'
: 'Pass this Richese bid while preserving your whole answer'
: action.type === 'revive' ? `Revive ${amount} forces while preserving your whole answer`
: action.type === 'reviveLeader' ? 'Revive the quoted leader while preserving your whole answer'
: action.type === 'acceptLeaderRevival' ? 'Accept the quoted leader revival while preserving your whole answer'
: action.type === 'reviveKwisatz' ? 'Revive your Kwisatz Haderach while preserving your whole answer'
: action.type === 'ship' || action.type === 'guildShip' ? `Ship ${amount} physical forces from reserves to ${destination}${sector}${action.type === 'guildShip' ? ' using Guild transport' : ''} while honoring your whole answer`
: action.type === 'move' ? `Move ${amount} physical forces to ${destination}${sector} while honoring your whole answer`
: action.type === 'endMovement' ? 'Finish movement while preserving any later payment conditions'
: action.type === 'decision' ? game.decision?.kind === 'auctionPayment'
? action.karama === true ? 'Use the quoted Karama for the current auction payment while preserving your whole answer'
: 'Pay the current auction price while preserving your whole answer'
: 'Complete your paid Box search while preserving your whole answer'
: action.type === 'prepareAdvisors' ? `Prepare advisors in ${destination} while preserving your whole answer`
: action.type === 'revealNoField' ? 'Reveal your No-Field while preserving your whole answer'
: 'Use the offered native card preparation while preserving your whole answer';
return <Button className="game-action min-h-11 whitespace-normal" data-testid="turn-action-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.act(action); }}>{label}</Button>;
}
/** Own original whole questions and aggregate status only. */
export function TurnActionPromises(props: TurnActionProps) {
const game = props.game, own = game.turnActionPromises.filter(promise => promise.player === game.me);
if (!own.length) return null;
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
return <section className="notice space-y-3" aria-label="Your Truthtrance turn-action promises" id="turn-action-promise-guidance">
<h3>Your payment, shipment and movement promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'turnActions', target: promise.player,
turn: promise.turn, mixed: promise.mixed }, id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
: game.allLeaders.find(leader => leader.id === id)?.name ?? id,
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
id => game.players.find(player => player.id === id)?.name ?? id)}.
{promise.released ? ' Released: this whole commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding until its remaining conditions close this turn.'}
</li>)}</ul>
<p>AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
Separate No answers each create their own restriction. Legal alternatives remain available; no fixed branch is selected for you.
Current facts freeze at the answer and create no promise to keep cards, spice or forces afterward.
Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private.</p>
<p className="fine">Count the sum of future real ordinary payments to each named recipient after the answer.
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
Payments made during Mentat Pause wait until next turn&apos;s Mentat opening; collection never counts twice.
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
never the sum of smaller moves. Earlier shipment and movement do not count.
Shipment and movement close at your own Movement turn end; ordinary payments remain possible until the actual turn ends.
Original costs, timing, cards, preparation and action rights still apply. This claim grants no extra action.</p>
{live && <p>{game.phase === 8 ? 'Complete any remaining ordinary payment before the actual turn ends. Late recipients collect next turn.'
: game.phase < 5 ? 'Your Movement turn is still ahead. Preserve the needed forces, spice and cards and complete any offered native preparation.'
: 'Use the current native controls to honor your whole answer. Payment conditions can remain after your Movement turn ends.'}</p>}
{live && <TurnActionGuideButton {...props} />}
</section>;
}
/** Render only the current respondent's private native whole-answer choices. */
export function TurnActionAnswers(props: TurnActionProps) {
const game = props.game, window = game.truthtrance, question = window?.question;
if (window?.stage !== 'answer' || question?.kind !== 'turnActions' || question.target !== game.me || !game.truthTurnActionAnswers) return null;
const actor = game.me, turn = game.turn, phase = game.phase, source = window.queue[0], answers = game.truthTurnActionAnswers;
const signature = JSON.stringify(question), sourceSignature = JSON.stringify(source);
const canAnswer = (answer: TruthAnswer) => {
const current = props.game, me = current.players.find(player => player.id === actor);
return !props.busy && current === game && current.status === 'playing' &&
Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn &&
current.me === actor && question.turn === turn && current.truthtrance === window && window.stage === 'answer' &&
window.question === question && question.target === actor && JSON.stringify(question) === signature &&
!!source && window.queue[0] === source && JSON.stringify(source) === sourceSignature &&
current.truthTurnActionAnswers === answers && answers.includes(answer) &&
!!me && !me.autopilot && !current.response && !current.decision && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
return <section aria-label="Answer the whole payment, shipment and movement question" className="space-y-3">
<p className="notice">Answer the whole expression. Current facts freeze now; OR keeps legal alternatives and No keeps the whole expression false.
Future real ordinary payments add together per recipient. Only one future original reserve shipment and actual future ground-movement groups count.
Shipment and movement close at your own Movement turn end; payment conditions last until the actual turn ends.
Original rights, preparation, timing and costs still apply. Only your whole answer is shared.</p>
{answers.map(answer => {
const action: Action = { type: 'truthAnswer', answer };
return <Button key={answer} className="game-action min-h-11 whitespace-normal" data-turn-action-answer={answer}
disabled={!canAnswer(answer)} onClick={() => { if (canAnswer(answer)) props.act(action); }}>
{answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`}
</Button>;
})}
</section>;
}
+30
View File
@@ -16,6 +16,36 @@ open mode gates.
## Current checkpoint and work
**Truthtrance / combined payments, shipment and movement — 26 October. Prototyped / Partial.**
One whole current-turn AND/OR question now combines ordinary cumulative payer
payments, one future original reserve shipment and physical ground-group maxima,
with optional frozen facts. Private signed alternatives share native funding,
fees, pieces and prior obligations without installing a chosen route. Transport
closes at actual own Movement end; a funded payment alternative can remain
through Mentat. Actual payer payments count once, independently of delayed
recipient receipts. Full controls, persistent own history, exact phase/event
native guides and four minimal profiles connect.
Root integration with disjoint helper/controls/native lanes and bounded review
passes **23 files,234/234**, types/lint/build and **three native paths**,11
GameTable SSR renders/three actual answer callbacks/five guide callbacks.
Original two-Truth source27 adds9 for frozen facts, real pay2, paid ship1/move1;
source27 adds14 for own transport close followed by late Mentat payment.
Original Advanced Mentat35 adds9 for late payment and actual turn continuation.
Original physical inventories remain; no played source state is installed.
Four genuine funding cases reject incompatible Bid/payment and Revival/payment
answers in both acceptance orders while preserving and fulfilling earlier rows.
A controlled public Inflation Double variant checks the real payment fence.
Cumulative partial payments retain their remaining funded alternative.
Proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-action-proof-v1/`;
journeys/HTML: adjacent `20261026-truth-turn-action-runtime/`.
Other action families, broader preparations/modules and final rules/browser/
deployed/recovery/privacy/custody acceptance remain. No database/save/server/
reset/restart/deployment operation is performed; no complete mode is certified.
[Contract, authority and evidence](TRUTHTRANCE_TURN_ACTIONS.md).
**Truthtrance / earlier combined shipment and movement — 26 October. Prototyped / Partial.**
The combined travel question now uses the existing clean native phases0–5
forecast in Basic/Advanced. Original current Charity/Revival/auction obligations
+32
View File
@@ -1,5 +1,37 @@
# Dune implementation status
## 26 October 2026 — Combined current-turn payments and native transport
**Truthtrance / combined payments, shipment and movement — 26 October. Prototyped / Partial.**
One whole current-turn AND/OR question now combines ordinary cumulative payer
payments, one future original reserve shipment and physical ground-group maxima,
with optional frozen facts. Private signed alternatives share native funding,
fees, pieces and prior obligations without installing a chosen route. Transport
closes at actual own Movement end; a funded payment alternative can remain
through Mentat. Actual payer payments count once, independently of delayed
recipient receipts. Full controls, persistent own history, exact phase/event
native guides and four minimal profiles connect.
Root integration with disjoint helper/controls/native lanes and bounded review
passes **23 files,234/234**, types/lint/build and **three native paths**,11
GameTable SSR renders/three actual answer callbacks/five guide callbacks.
Original two-Truth source27 adds9 for frozen facts, real pay2, paid ship1/move1;
source27 adds14 for own transport close followed by late Mentat payment.
Original Advanced Mentat35 adds9 for late payment and actual turn continuation.
Original physical inventories remain; no played source state is installed.
Four genuine funding cases reject incompatible Bid/payment and Revival/payment
answers in both acceptance orders while preserving and fulfilling earlier rows.
A controlled public Inflation Double variant checks the real payment fence.
Cumulative partial payments retain their remaining funded alternative.
Proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-action-proof-v1/`;
journeys/HTML: adjacent `20261026-truth-turn-action-runtime/`.
Other action families, broader preparations/modules and final rules/browser/
deployed/recovery/privacy/custody acceptance remain. No database/save/server/
reset/restart/deployment operation is performed; no complete mode is certified.
[Contract, authority and evidence](TRUTHTRANCE_TURN_ACTIONS.md).
## 26 October 2026 — Earlier combined shipment and movement commitments
**Prototyped / Partial.**
+15
View File
@@ -1,5 +1,20 @@
# Rule decision index
## Combined payments and native transport — 26 October 2026
Compose the existing publisher November2020 FAQp8 current-turn AND/OR and
supplied Advanced physical39 compliance/release authority. Ordinary payer
payments are cumulative future events; shipment and physical ground groups
retain their separate native meaning. One whole answer preserves alternatives
without installing a selected branch. Transport closes at actual own Movement
end; a funded payment alternative can remain through current-turn Mentat.
Earlier obligations share native resources and actual payment intervals.
This extends the provisional funded-current-purse/owned-stage interpretation,
not the complete printed range of questions. No fees, gifts or later recipient
receipts count as ordinary payer progress. Uniform definite-answer readiness
reopening preserves the existing private boundary. Broader action/module and
complete-mode acceptance remain. [Contract/evidence](TRUTHTRANCE_TURN_ACTIONS.md).
## Earlier combined travel and native preparation — 26 October 2026
Extend the recorded combined shipment/ground prototype through the existing
+105
View File
@@ -0,0 +1,105 @@
# Truthtrance combined current-turn actions
## Payments, reserve shipment and physical movement prototype
**Prototyped / Partial.** New raw question:
`{kind:'turnActions',target,turn,mixed}`. Action leaves are ordinary
`{kind:'payment',recipient,minimum}` or native
`{kind:'ship'|'move',territory,minimum}`. Optional current facts and full AND/OR
share sixteen logical leaves/four grouping levels. Old travel/payment question
shapes and mode gates remain.
## Authority and meaning
Reuse publisher November2020 FAQp8 current-turn AND/OR/compliance and supplied
Advanced physical39/printed37 authority recorded in RULE_DECISIONS. This is the
next provisional composition of existing native funded-payment and owned-stage
travel prototypes, not a printed exhaustive promise solver. Native scope and
current timing remain authoritative; optional modules retain their fences.
All facts freeze at actual answer, with the existing guaranteed aggregate
unknown interpretation. No negates the whole expression, not each action.
Alternative native routes are never fixed publicly or stored as separate
answers. The raw question is public; compiled facts/branches stay private.
Only actual ordinary payer bribes after this answer add cumulative payment
progress. Shipment fees, gifts, subsidies, earlier payments and later Mentat
receipts do not add that progress. The recipient's existing receipt/late-next-
opening mechanism stays unchanged. Each shipment leaf describes the same one
future original reserve shipment. Each movement minimum requires one physical
ground group; smaller moves never sum. Previous-turn physical events never count.
Transport closes at the respondent's actual Movement end. A whole OR answer
can retain a funded payment alternative after that close; an AND requiring a
missing transport event cannot evade it. Residual payments remain binding through
the current full turn, including Mentat. A whole Yes fulfills on actual events;
No lasts until the relevant native opportunities have closed. Constant answers
need no event. Earlier current-turn payments have separate intervals for each
answer; one future payment can satisfy compatible minima without double reserve.
## Native integration contract
The focused helper owns strict trees, signed branches and actual progress only.
Detached branch witnesses use the original scalar shipment/movement/payment
ledgers and native fee/funding/completion functions. No selected branch is
installed in the real ledgers. Existing bids, Revival, bribes, travel and battle
obligations retain their native resource protections and actual receipts.
Current funds support ordinary payments under the existing bribe prototype;
unknown future gifts/income/unowned cards are not invented. Existing owned
Charity/Revival/auction/card preparations remain the travel forecast. Mandatory
native progress or completed opposing effects can release impossibility; own
spending or closing an opportunity must leave a completion. Pending foreign
invoice/income outcomes retain original timing.
A persistent own raw aggregate history and one exact current native guide connect
human controls and four minimal profiles. Guide packets carry event/phase source
metadata, preserve actual recipients/forces/cost splits and native modal priority.
Every definite question uniformly reopens applicable concurrent preparation/
Mentat readiness, including constants, preserving the existing private boundary.
No new action rights or turn allowance is granted.
## Evidence and remaining scope
Helper: game/turn-action-promises.ts and its focused tests. Human controls:
components/turn-action-fields.tsx and components/turn-action-promises.tsx.
Native sources/tests: tests/fixture-truth-turn-action.ts and
tests/truth-turn-action-runtime.test.ts. Genuine prior Bid/Revival funding sources
are in tests/truth-turn-action-funding.test.ts.
The frozen source passes **23 files,234/234**, types/lint, production build and
three native paths with11 GameTable SSR renders, three actual answer callbacks
and five actual guide callbacks. Source27 +9 performs frozen facts, pay2,
original paid shipment1 and ground1. A second source27 +14 closes transport,
then pays in late Mentat and advances the turn. Original Advanced Mentat35 +9
pays after opening and retains the recipient's next-opening receipt timing.
All original physical inventories remain. These are local engine/SSR/callback
journeys, not browser/hydration or deployed acceptance.
Fourteen helper and fourteen control cases supplement eighteen native cases and
four genuine two-Truth funding conflicts. Compatible scalar/aggregate intervals
share an actual payment once; incompatible Bid/payment and Revival/payment Yes
answers reject in both acceptance orders. Earlier history/receipts survive and
the original accepted answer is fulfilled by an actual action. Current facts
freeze, movement maxima never sum, OR retains manual alternatives, whole No
survives own transport close, all four minimal profiles complete, and Basic/
Advanced late recipient receipts do not add payer progress again. A controlled
Inflation Double token variant, explicitly distinguished from original native
sources, rejects a payment-only Yes while allowing truthful No.
Initial focused feedback repairs exact stored metadata and a test fact-call
signature. Bounded review adds actual payment timing and later native Bid/Revival
resource preflight. A proposed payment gap is rejected as an incorrect test
assumption: pay9 toward a cumulative10 minimum leaves progress9 plus one funded
spice. The corrected regression proves partial9 then1, full10 and shipment routes.
Proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-turn-action-proof-v1/`;
journeys and HTML: adjacent `20261026-truth-turn-action-runtime/`.
Other action families, broader owned preparations/optional compositions,
unknown-fact correlations and complete rules/browser/deployed/recovery/privacy/
custody acceptance remain. Full strategy/calibration stays deferred. Preserve
all games and the hosting project; this batch performs no save/database/server/
reset/restart/deployment operations and certifies no complete mode.
+7
View File
@@ -1374,6 +1374,10 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'turnActions') {
const answer = g.truthTurnActionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'travel') {
const answer = g.truthTravelAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
@@ -4260,6 +4264,9 @@ export function botActions(g: GameView): Action[] {
if (g.homeworldTupileBlocked) return [];
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
return tupileCleanupActions(g);
if (g.turnActionGuide && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return [g.turnActionGuide.action];
if (g.movementExpressionPromiseGuide && g.status === 'playing' &&
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending)
return [g.movementExpressionPromiseGuide.action];
+285 -5
View File
@@ -1,3 +1,6 @@
import { TurnActionError, liveTurnActionPromises, turnActionBranches, recordTurnActionPayment,
recordTurnActionShipment, recordTurnActionMovement, closeTurnActionTransport, finishTurnActionTurn,
validateTurnActionPromises, type TurnActionClaim, type TurnActionPromise, type TurnActionBranchLeaf } from './turn-action-promises';
import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShipment, recordTravelMovement,
finishTravelTurn, validateTravelPromises, type TravelClaim, type TravelPromise, type TravelBranchLeaf } from './travel-expression-promises';
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
@@ -2165,8 +2168,9 @@ export type Game = {
truthtrance?: TruthWindow | null;
truthHistory?: TruthRecord[];
travelPromises?: TravelPromise[];
turnActionPromises?: TurnActionPromise[];
/** Accepted order shared by phase-five travel, shipment and movement answers. */
travelPromiseOrder?: { kind: 'shipment' | 'movement' | 'movementExpression' | 'travel'; index: number }[];
travelPromiseOrder?: { kind: 'shipment' | 'movement' | 'movementExpression' | 'travel' | 'bribe' | 'bribeExpression' | 'turnActions'; index: number }[];
mixedTraitorQuestions?: (MixedTraitorClaim & {
event: string; turn: number; player: string; asker: string; answer: boolean;
binding: boolean; fulfilled: boolean; released: boolean;
@@ -6948,7 +6952,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
...(g.travelPromiseOrder ? { travelPromiseOrder: g.travelPromiseOrder, travelPromises: g.travelPromises } : {}),
...(next.record.question.kind === 'travel' ? { movementExpressionPromises: g.movementExpressionPromises } : {}),
...(['travel', 'turnActions'].includes(next.record.question.kind) ? { movementExpressionPromises: g.movementExpressionPromises } : {}),
...(g.turnActionPromises ? { turnActionPromises: g.turnActionPromises, bribePromises: g.bribePromises,
bribeExpressionPromises: g.bribeExpressionPromises, bribePromiseSequence: g.bribePromiseSequence } : {}),
...(next.record.question.kind === 'mixedTraitorCall' ? { mixedTraitorQuestions: g.mixedTraitorQuestions } : {}),
...(next.record.question.kind === 'mixedBattlePlan' ? { mixedBattleQuestions: g.mixedBattleQuestions } : {}),
...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? {
@@ -15282,6 +15288,8 @@ function hasBribePromises(g: Game, player: string, positive = false): boolean {
}
function recordBribePayment(g: Game, player: string, recipient: string, amount: number) {
if (amount === 0) return;
if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionPayment(g.turnActionPromises!, player, g.turn, { recipient, amount }));
bribePromiseRule(() => {
if (g.bribePromises?.length) g.bribePromises = recordPromisedBribe(g.bribePromises, player, g.turn, { recipient, amount });
if (g.bribeExpressionPromises?.length) g.bribeExpressionPromises = recordBribeExpressionPayment(
@@ -15301,6 +15309,8 @@ function bribeQuestionWindow(g: Game): boolean {
function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ?? [],
expressions = g.bribeExpressionPromises ?? []): Action[] | null {
if (!bribePromiseTurn(g)) return null;
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length)
return turnActionCompletion({ ...g, bribePromises: promises, bribeExpressionPromises: expressions }, p.id)?.payments ?? null;
for (const required of bribePromiseRule(() => bribeCompletionCandidates(promises, expressions, p.id, g.turn))) {
if (!required.length) return [];
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
@@ -15360,6 +15370,7 @@ function bindBribeTruth(g: Game, p: Player, claim: BribeClaim, answer: boolean,
const promise: BribePromise = { ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 };
(g.bribePromises ??= []).push(promise);
(g.bribePromiseSequence ??= []).push(promise.event);
if (g.turnActionPromises) appendTravelOrder(g, 'bribe', g.bribePromises!.length - 1);
reopenBribePayer(g, p, answer);
}
function bindBribeExpressionTruth(g: Game, p: Player, claim: BribeExpressionClaim, answer: boolean, asker: string) {
@@ -15368,6 +15379,7 @@ function bindBribeExpressionTruth(g: Game, p: Player, claim: BribeExpressionClai
const promise: BribeExpressionPromise = { ...structuredClone(claim), event: crypto.randomUUID(), player: p.id, asker, answer, progress: [] };
(g.bribeExpressionPromises ??= []).push(promise);
(g.bribePromiseSequence ??= []).push(promise.event);
if (g.turnActionPromises) appendTravelOrder(g, 'bribeExpression', g.bribeExpressionPromises!.length - 1);
reopenBribePayer(g, p, answer);
}
function compileMixedBribe(g: Game, p: Player, claim: MixedBribeClaim, answer: boolean) {
@@ -15419,6 +15431,7 @@ function projectedMixedBribePromises(g: Game, player: string) {
});
}
function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Action }) {
if (g.turnActionPromises) return; // Joint accepted-order reconciliation owns these rows.
type Promise = BribePromise | BribeExpressionPromise;
const pendingPromises: Promise[] = [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])]
.filter(promise => !promise.fulfilled && !promise.released);
@@ -15476,12 +15489,13 @@ function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, am
if (hasMovementPromises(trial, p.id, true) &&
!groundMovementCompletion(trial, p.id)) return false;
reconcileRevivalPromises(trial, voluntary);
return !hasBribePromises(trial, p.id) || !!bribePromiseCompletion(trial, actor);
return liveTurnActionPromises(trial.turnActionPromises ?? [], p.id, trial.turn).length
? !!turnActionCompletion(trial, p.id) : !hasBribePromises(trial, p.id) || !!bribePromiseCompletion(trial, actor);
} catch (error) { if (error instanceof BribeExpressionPromiseError || error instanceof BribePromiseError || error instanceof RuleError) return false; throw error; }
}
function projectedBribes(g: Game, p: Player): BribeOptions {
const available = Math.max(0, uncommittedSpice(g, p)), blocked = bribeTimingBlock(g);
const constrained = hasBribePromises(g, p.id) ||
const constrained = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length > 0 || 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 ||
hasMovementPromises(g, p.id, true) ||
@@ -18657,6 +18671,234 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
throw error;
}
}
function turnActionRule<T>(operation: () => T): T {
try { return operation(); }
catch (error) { if (error instanceof TurnActionError) throw new RuleError(error.message); throw error; }
}
function turnActionTargetAvailable(g: Game, _actor: string): boolean {
return bribeQuestionWindow(g) && movementPromiseModeSupported(g);
}
function turnActionTransportOpen(g: Game, actor: string): boolean {
return g.phase < 5 || g.phase === 5 && !!g.movementRemaining?.includes(actor);
}
function initializeTurnActionOrder(g: Game) {
initializeTravelOrder(g);
const payments = [
...(g.bribePromises ?? []).map((promise, index) => ({ kind: 'bribe' as const, index, event: promise.event })),
...(g.bribeExpressionPromises ?? []).map((promise, index) => ({ kind: 'bribeExpression' as const, index, event: promise.event })),
];
const sequence = g.bribePromiseSequence ?? [];
payments.sort((a, b) => sequence.indexOf(a.event) - sequence.indexOf(b.event));
for (const { kind, index } of payments)
if (!g.travelPromiseOrder!.some(entry => entry.kind === kind && entry.index === index))
g.travelPromiseOrder!.push({ kind, index });
}
type TurnActionCompletion = MovementCompletion & { payments: Action[] };
/** Whole-answer alternatives stay private. Only detached native rows quote a
* route, sharing actual interval progress and the remaining physical purse. */
function turnActionCompletion(source: Game, actor: string): TurnActionCompletion | null {
const records = [
...liveTravelPromises(source.travelPromises ?? [], actor, source.turn).map(promise =>
() => travelRule(() => travelBranches(promise))),
...liveTurnActionPromises(source.turnActionPromises ?? [], actor, source.turn).map(promise =>
() => turnActionRule(() => turnActionBranches(promise))),
];
const search = (index: number, leaves: TurnActionBranchLeaf[]): TurnActionCompletion | null => {
if (index < records.length) {
for (const branch of records[index]()) {
const result = search(index + 1, [...leaves, ...branch]);
if (result) return result;
}
return null;
}
const trial = structuredClone(source);
trial.truthtrance = null;
delete trial.turnActionPromises;
delete trial.travelPromises;
delete trial.travelPromiseOrder;
const owner = getPlayer(trial, actor);
trial.bribePromises ??= [];
trial.shipmentPromises ??= [];
trial.movementPromises ??= [];
leaves.forEach((leaf, i) => {
const metadata = { event: `turn-action-witness:${i}`, player: actor, turn: source.turn,
asker: source.players.find(player => player.id !== actor)!.id, answer: leaf.answer };
if (leaf.kind === 'payment') trial.bribePromises!.push({ ...metadata,
recipient: leaf.recipient, minimum: leaf.minimum, progress: leaf.progress });
else if (leaf.kind === 'ship') trial.shipmentPromises!.push({ ...metadata,
territory: leaf.territory, minimum: leaf.minimum });
else trial.movementPromises!.push({ ...metadata, territory: leaf.territory, minimum: leaf.minimum });
});
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
// A quoted payment follows only a currently owned modal continuation;
// permanent restrictions such as Inflation Double remain authoritative.
const ownedInvoice = trial.decision?.kind === 'auctionPayment' && trial.decision.player === actor;
const ownedBox = trial.decision?.kind === 'nullentropy' && trial.pendingNullentropy?.player === actor;
const paymentWindow = ownedInvoice || ownedBox ? { ...trial, decision: null,
pendingNullentropy: ownedBox ? undefined : trial.pendingNullentropy } : trial;
if (required.length && bribeTimingBlock(paymentWindow)) continue;
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
if (!Number.isSafeInteger(total) || total > uncommittedSpice(trial, owner) ||
required.some(payment => payment.recipient === actor || payment.recipient === owner.ally ||
!trial.players.some(player => player.id === payment.recipient))) continue;
const funded = structuredClone(trial), payer = getPlayer(funded, actor);
try {
for (const payment of required) recordBribePayment(funded, actor, payment.recipient, payment.amount);
payer.spice -= total;
const voluntary = { actor, action: { type: 'bribe' } };
for (const payment of required)
checkBureaucratContributors(funded, payment.recipient, [{ payer: actor, amount: payment.amount }]);
reconcileBattlePromises(funded, voluntary);
reconcileBidPromises(funded, voluntary);
reconcileRevivalPromises(funded, voluntary);
const transport = [
...liveShipmentPromises(funded.shipmentPromises ?? [], actor, source.turn),
...liveMovementPromises(funded.movementPromises ?? [], actor, source.turn),
...liveMovementExpressionPromises(funded.movementExpressionPromises ?? [], actor, source.turn),
];
const completion = transport.length ? groundMovementCompletionNative(funded, actor) : { actions: [] };
if (completion) return { ...completion, payments: required.map(payment =>
({ type: 'bribe', target: payment.recipient, amount: payment.amount })) };
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
return null;
};
return turnActionRule(() => travelRule(() => search(0, [])));
}
function turnActionPromise(g: Game, p: Player, claim: TurnActionClaim, answer: boolean,
event: string, asker: string): TurnActionPromise {
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
const open = turnActionTransportOpen(g, p.id);
return { turn: claim.turn, mixed: structuredClone(claim.mixed), event, player: p.id, asker, answer, compiled,
shipment: { pending: open && (g.phase < 5 || shipmentAvailable(g, p)), event: null },
progress: [], payments: [], transportClosed: !open, paymentClosed: false,
fulfilled: compiled.kind === 'constant' && compiled.value === answer, released: false };
}
function turnActionAnswerSource(g: Game, player: string): Game {
return g.phase === 2 || g.phase === 4 && !g.revivalOrder || g.phase === 8 ||
g.phase === 7 && bribesCloseBeforeNextMentat(g)
? { ...g, ready: g.ready.filter(id => id !== player) } : g;
}
function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !turnActionTargetAvailable(g, p.id)) return [];
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const promise = turnActionPromise(g, p, claim, answer, 'candidate', g.players.find(other => other.id !== p.id)!.id);
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
const trial = { ...turnActionAnswerSource(g, p.id),
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
if (turnActionCompletion(trial, p.id)) answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
function bindTurnActionTruth(g: Game, p: Player, claim: TurnActionClaim, answer: boolean, asker: string) {
requireRule(turnActionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a whole turn-action answer compatible with current funding and native actions.');
const promise = turnActionPromise(g, p, claim, answer, crypto.randomUUID(), asker);
g.ready = turnActionAnswerSource(g, p.id).ready;
g.turnActionPromises ??= [];
initializeTurnActionOrder(g);
g.turnActionPromises.push(promise);
appendTravelOrder(g, 'turnActions', g.turnActionPromises.length - 1);
}
function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
turnActionRule(() => validateTurnActionPromises(g.turnActionPromises!, g.players.map(player => player.id)));
for (const p of g.players) {
for (const turn of new Set(g.turnActionPromises!.filter(promise => promise.player === p.id &&
!promise.fulfilled && !promise.released && (promise.turn !== g.turn || g.status !== 'playing')).map(promise => promise.turn))) {
const finished = turnActionRule(() => finishTurnActionTurn(g.turnActionPromises!, p.id, turn));
requireRule(actor !== p.id || !g.turnActionPromises!.some((promise, index) =>
!promise.released && !promise.fulfilled && finished[index].released),
'You cannot voluntarily close an unfulfilled whole Truthtrance answer.');
g.turnActionPromises = finished;
}
if (g.phase > 5) g.turnActionPromises = turnActionRule(() =>
closeTurnActionTransport(g.turnActionPromises!, p.id, g.turn));
}
for (const promise of g.travelPromises ?? []) if (!promise.fulfilled && !promise.released &&
(promise.turn !== g.turn || g.phase > 5 || g.status !== 'playing')) promise.released = true;
for (const promise of [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])])
if (!promise.fulfilled && !promise.released && (promise.turn !== g.turn || !bribePromiseTurn(g))) {
requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.');
if (promise.answer) promise.released = true; else promise.fulfilled = true;
}
if (!bribePromiseTurn(g) || g.truthtrance) return;
initializeTurnActionOrder(g);
for (const p of g.players) {
const early = g.phase < 5 || sourcePhase < 5;
const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action &&
(['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive',
'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) ||
action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 ||
action.type === 'ready' && [2, 4, 7, 8].includes(sourcePhase)));
if (g.phaseOpening || g.response || g.decision || g.pendingTreacheryDiscard || g.pendingAmbassador ||
g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length ||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove || g.pendingAdvisorMatching ||
g.pendingAutomaticAdvisorFlips?.length || g.phase === 3 && !g.currentAuctionSale &&
(g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 ||
g.richeseAuction && !g.richeseAuction.outcome)) continue;
const trial = { ...g, shipmentPromises: [] as ShipmentPromise[], movementPromises: [] as MovementPromise[],
movementExpressionPromises: [] as MovementExpressionPromise[], travelPromises: [] as TravelPromise[],
bribePromises: [] as BribePromise[], bribeExpressionPromises: [] as BribeExpressionPromise[],
turnActionPromises: [] as TurnActionPromise[] };
for (const entry of g.travelPromiseOrder!) {
const key = ({ shipment: 'shipmentPromises', movement: 'movementPromises', movementExpression: 'movementExpressionPromises',
travel: 'travelPromises', bribe: 'bribePromises', bribeExpression: 'bribeExpressionPromises',
turnActions: 'turnActionPromises' } as const)[entry.kind];
const promise = g[key]?.[entry.index];
if (!promise || promise.player !== p.id || promise.turn !== g.turn || promise.fulfilled || promise.released) continue;
// Each key and row came from the same discriminated accepted-order entry.
(trial[key] as (typeof promise)[]).push(promise);
if (turnActionCompletion(trial, p.id)) continue;
requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted whole turn-action commitment.');
promise.released = true;
log(g, `${p.name} can no longer fulfill an accepted Truthtrance commitment; that answer is no longer binding.`);
}
}
}
function projectedTurnActionPromises(g: Game, player: string) {
return (g.turnActionPromises ?? []).filter(promise => promise.player === player).map(promise => ({
event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn,
mixed: structuredClone(promise.mixed), answer: promise.answer, fulfilled: promise.fulfilled, released: promise.released,
}));
}
function projectedTurnActionGuide(g: Game, player: Player): { event: string; action: Action } | null {
const promise = liveTurnActionPromises(g.turnActionPromises ?? [], player.id, g.turn)[0];
const ownedBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id;
const ownedInvoice = g.decision?.kind === 'auctionPayment' && g.decision.player === player.id;
if (!promise || g.truthtrance || g.response || g.phaseOpening || g.pendingTreacheryDiscard ||
g.decision && !ownedBox && !ownedInvoice) return null;
const completion = turnActionCompletion(g, player.id);
if (!completion) return null;
let action = !bribeTimingBlock(g) ? completion.payments[0] : undefined;
if (!action && g.phase <= 5) {
const lot = g.phase === 3 && !g.decision ? bidOpportunity(g) : null;
if (lot && bidRequired(g.bidPromises ?? [], player.id, lot)) action = projectedBidPromiseGuide(g, player.id)?.action;
const revival = [
...liveRevivalPromises(g.revivalPromises ?? [], player.id, g.turn),
...liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], player.id, g.turn),
...liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player.id, g.turn),
].some(row => row.answer);
if (!action && revival && !ownedBox) action = (projectedRevivalExpressionPromiseGuide(g, player.id) ??
projectedLeaderRevivalPromiseGuide(g, player.id) ?? projectedRevivalPromiseGuide(g, player.id))?.action;
if (!action && g.phase < 5) {
const current = completion.early?.find(step => step.phase === g.phase)?.action;
if (g.phase === 3) {
if (ownedInvoice) action = current?.type === 'decision' ? current : { type: 'decision', karama: false };
else if (g.auction?.active === player.id) action = { type: 'passBid' };
} else if ([2, 4].includes(g.phase) && !g.ready.includes(player.id) &&
!(g.phase === 4 && nativeRevivalOrderBlock(g, player.id))) action = current ?? { type: 'ready' };
} else if (!action && g.phase === 5 && g.active === player.id && !promise.transportClosed)
action = completion.actions[0] ?? { type: 'endMovement' };
}
if (!action && [7, 8].includes(g.phase) && !g.ready.includes(player.id)) action = { type: 'ready' };
if (!action) return null;
const packet = { ...structuredClone(action) };
for (const key of ['bidPromiseEvent', 'revivalExpressionPromiseEvent', 'leaderRevivalPromiseEvent', 'revivalPromiseEvent']) delete packet[key];
return { event: promise.event, action: { ...packet, turnActionPromiseEvent: promise.event, turnActionPromisePhase: g.phase } };
}
function travelRule<T>(operation: () => T): T {
try { return operation(); }
catch (error) { if (error instanceof TravelPromiseError) throw new RuleError(error.message); throw error; }
@@ -18688,6 +18930,8 @@ function travelCompletion(source: Game, actor: string,
movements = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn),
shipments = liveShipmentPromises(source.shipmentPromises ?? [], actor, source.turn)): MovementCompletion | null {
if (liveTurnActionPromises(source.turnActionPromises ?? [], actor, source.turn).length)
return turnActionCompletion({ ...source, movementPromises: [...movements], movementExpressionPromises: [...expressions], shipmentPromises: [...shipments] }, actor);
const travel = liveTravelPromises(source.travelPromises ?? [], actor, source.turn);
const search = (index: number, leaves: TravelBranchLeaf[]): MovementCompletion | null => {
if (index < travel.length) {
@@ -18746,6 +18990,7 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean
appendTravelOrder(g, 'travel', g.travelPromises!.length - 1);
}
function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) {
if (g.turnActionPromises) { reconcileTurnActionPromises(g, actor, action, sourcePhase); return; }
if (!g.travelPromises?.length) return;
travelRule(() => validateTravelPromises(g.travelPromises!, g.players.map(player => player.id)));
for (const promise of g.travelPromises) if (!promise.fulfilled && !promise.released &&
@@ -18827,6 +19072,8 @@ function projectedTravelGuide(g: Game, player: Player): { event: string; action:
function groundMovementCompletion(source: Game, actor: string,
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn),
expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn)): MovementCompletion | null {
if (liveTurnActionPromises(source.turnActionPromises ?? [], actor, source.turn).length)
return turnActionCompletion({ ...source, movementPromises: [...promises], movementExpressionPromises: [...expressions] }, actor);
return liveTravelPromises(source.travelPromises ?? [], actor, source.turn).length
? travelCompletion(source, actor, promises, expressions) : groundMovementCompletionNative(source, actor, promises, expressions);
}
@@ -18975,7 +19222,10 @@ 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) ||
liveTravelPromises(g.travelPromises ?? [], player, g.turn).some(promise => !positive || promise.answer);
liveTravelPromises(g.travelPromises ?? [], player, g.turn).some(promise => !positive || promise.answer) ||
// Whole turn answers share resource/preflight checks, but may retain a
// payment alternative after transport: they do not force movement at end.
!positive && liveTurnActionPromises(g.turnActionPromises ?? [], player, g.turn).length > 0;
}
function movementExpressionIntegrity(g: Game) {
try { validateMovementExpressionPromises(g.movementExpressionPromises ?? [], g.players.map(player => player.id)); }
@@ -19140,6 +19390,8 @@ function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
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);
if (projected.turnActionPromises) projected.turnActionPromises = turnActionRule(() =>
recordTurnActionMovement(projected.turnActionPromises!, p.id, g.turn, event));
if (projected.travelPromises) projected.travelPromises = travelRule(() =>
recordTravelMovement(projected.travelPromises!, p.id, g.turn, event));
projected.movementPromises = settleMovementEvent(g.movementPromises ?? [], p.id, g.turn, event);
@@ -19150,6 +19402,8 @@ function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
}
function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
if (move.source === 'ambassador') return;
if (g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionMovement(g.turnActionPromises!, p.id, g.turn, groundMovementEvent(move)));
if (g.travelPromises) g.travelPromises = travelRule(() =>
recordTravelMovement(g.travelPromises!, p.id, g.turn, groundMovementEvent(move)));
const old = g.movementPromises ?? [], expressions = g.movementExpressionPromises ?? [];
@@ -19622,6 +19876,10 @@ function finishBasicAlliedShipment(g: Game, p: Player) {
/** A played movement card is already retired before queue advancement. */
function finishMovementTurn(g: Game, id: string) {
const p = getPlayer(g, id);
if (g.turnActionPromises) {
g.turnActionPromises = turnActionRule(() => closeTurnActionTransport(g.turnActionPromises!, id, g.turn));
requireRule(!!turnActionCompletion(g, id), 'Complete your whole Truthtrance transport or retain a funded payment alternative.');
}
if (g.travelPromises) g.travelPromises = travelRule(() => finishTravelTurn(g.travelPromises!, id, g.turn));
try {
if (g.movementPromises) g.movementPromises = finishMovementPromises(g.movementPromises, p.id, g.turn);
@@ -27657,6 +27915,8 @@ function finishShipmentPromises(
shipment: { territory: string; amount: number } | null,
) {
checkShipmentPromises(g, p, shipment);
if (g.phase === 5 && g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionShipment(g.turnActionPromises!, p.id, g.turn, shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn,
shipment ? { territory: shipment.territory, amount: shipment.amount } : null));
for (const promise of liveShipmentPromises(
@@ -27679,6 +27939,8 @@ function ownGuildStopPromiseBlock(g: Game, p: Player): string | null {
* the respondent's promise cannot veto the opponent's printed power. */
function settleStoppedShipmentPromises(g: Game, p: Player, actor: string) {
if (actor === p.id) checkShipmentPromises(g, p, null);
if (g.phase === 5 && g.turnActionPromises) g.turnActionPromises = turnActionRule(() =>
recordTurnActionShipment(g.turnActionPromises!, p.id, g.turn, null));
if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn, null));
for (const promise of liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn)) {
if (matchesShipment(promise, null) === promise.answer) promise.fulfilled = true;
@@ -36357,6 +36619,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.turnActionPromiseEvent !== undefined || action.turnActionPromisePhase !== undefined) {
const guide = projectedTurnActionGuide(state, getPlayer(state, id));
requireRule(guide && action.turnActionPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
'This turn-action guide is stale or belongs to another opportunity.');
}
if (action.travelPromiseEvent !== undefined || action.travelPromisePhase !== undefined) {
const guide = projectedTravelGuide(state, getPlayer(state, id));
requireRule(guide && action.travelPromisePhase === state.phase && movementGuideActionMatches(action, guide.action),
@@ -37104,6 +37371,8 @@ function applyActionInner(
bindMixedBid: bindMixedBidTruth,
bidAnswers: bidTruthAnswers,
bindBid: bindBidTruth,
turnActionAnswers: turnActionTruthAnswers,
bindTurnAction: bindTurnActionTruth,
travelAnswers: travelTruthAnswers,
bindTravel: bindTravelTruth,
mixedTraitorAnswers: mixedTraitorTruthAnswers,
@@ -40745,6 +41014,12 @@ function applyActionInner(
if (t === 'ready') {
if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p);
const bribeWindowCloses = g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g);
if (bribeWindowCloses && g.turnActionPromises) {
const closed = turnActionRule(() => finishTurnActionTurn(g.turnActionPromises!, p.id, g.turn));
requireRule(!g.turnActionPromises.some((promise, index) => promise.player === p.id && promise.turn === g.turn &&
!promise.fulfilled && !promise.released && closed[index].released),
'Complete your whole Truthtrance payment answer before closing this turn’s payments.');
}
if (bribeWindowCloses) requireRule(!hasBribePromises(g, p.id, true),
g.phase === 8 ? 'Complete your Truthtrance bribe payments before finishing Mentat Pause.'
: 'Pay your Truthtrance bribes before the known Inflation Double opening prevents them.');
@@ -40897,6 +41172,11 @@ export function viewGame(state: Game, id: string) {
...(g.advanced&&g.decision?.kind==='advisor'&&g.decision.player===id
? {spiritualAdvisorChoices:spiritualAdvisorChoices(g,id)} : {}),
truthMovementTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id),
truthTurnActionTargets: g.players.filter(p => turnActionTargetAvailable(g, p.id)).map(p => p.id),
truthTurnActionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'turnActions' &&
g.truthtrance.question.target === id ? turnActionTruthAnswers(g, me, g.truthtrance.question) : null,
turnActionPromises: projectedTurnActionPromises(g, id),
turnActionGuide: projectedTurnActionGuide(g, me),
truthTravelTargets: g.players.filter(p => travelTargetAvailable(g, p.id)).map(p => p.id),
truthTravelAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'travel' &&
g.truthtrance.question.target === id ? travelTruthAnswers(g, me, g.truthtrance.question) : null,
+23
View File
@@ -3185,6 +3185,28 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, compound privacy, bots, JSON and production SQLite restoration; broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] },
],
},
{
id: 'truthtrance-turn-actions',
title: 'Truthtrance: combined payments, shipment and movements',
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine ordinary future payments and native physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
'Choose Combine this turn’s payments, shipment and ground movements. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of the native funded-payment and owned-stage travel prototypes; wider action families and optional combinations remain unfinished.',
'Only actual ordinary bribes paid by the respondent after answering add payment progress. Payments to the same recipient accumulate. Shipment fees, gifts and later receipt of incoming bribes do not count. Native recipient restrictions, costs and receipt timing remain.',
'Shipment clauses describe the same one future original reserve shipment. Each ground-movement minimum requires one actual physical group, never the sum of smaller moves. Existing allowances, cards, pieces and preparation remain authoritative.',
'Yes requires the whole expression and fulfills on qualifying actual events. OR preserves legal alternatives; No negates the whole expression. Current facts freeze at answer. A private native quotation shares funding with earlier commitments without storing or publishing a selected branch.',
'Transport closes at your actual Movement end. A payment alternative may remain after that close, through the full current turn including Mentat. Complete a required payment before finishing the turn. No payment caps remain until their native opportunity closes.',
'Review the persistent own history and current source-bound native guide, or choose another legal manual route. Every definite answer uniformly reopens applicable concurrent preparation or final payment readiness. Four minimal AI profiles participate; complete strategy and final rules/deployed acceptance remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-travel', 'truthtrance-bribe-expression', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Whole signed alternatives reuse native payment and transport constraints with separate actual intervals and deadlines.', evidence: ['game/turn-action-promises.ts', 'game/mixed-action-question.ts', 'game/engine.ts', 'game/truthtrance.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full editor, raw question, private answer, persistent own history and exact current-phase native guide.', evidence: ['components/turn-action-fields.tsx', 'components/turn-action-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-turn-action-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume native guides; full strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-turn-action-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Current funding, physical event meaning, private alternatives, separate deadlines and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TURN_ACTIONS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, control and original native sources; broader composition and final acceptance remain.', evidence: ['tests/turn-action-promises.test.ts', 'tests/truth-turn-action-controls.test.tsx', 'tests/fixture-truth-turn-action.ts', 'tests/truth-turn-action-runtime.test.ts', 'tests/truth-turn-action-funding.test.ts'] },
],
},
{
id: 'truthtrance-travel',
title: 'Truthtrance: combined shipment and ground movements',
@@ -3815,6 +3837,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-plan-expression',
'truthtrance-shipment',
'truthtrance-movement',
'truthtrance-turn-actions',
'truthtrance-travel',
'truthtrance-movement-expression',
'truthtrance-mixed-movement',
+30
View File
@@ -1,4 +1,6 @@
import { parseTravelActionClaim, type TravelClaim } from './travel-expression-promises';
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES,
type TurnActionClaim } from './turn-action-promises';
import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question';
import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH,
MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question';
@@ -79,6 +81,7 @@ export type TruthFact =
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
export type TruthQuestion =
| ({ kind: 'turnActions'; target: string } & TurnActionClaim)
| ({ kind: 'travel'; target: string } & TravelClaim)
| ({ kind: 'mixedTraitorCall'; target: string } & MixedTraitorClaim)
| ({ kind: 'mixedBattlePlan'; target: string } & MixedBattleClaim)
@@ -372,6 +375,20 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Choose the current ordinary bid lot in a supported table and finish pending decisions.');
return { kind: 'bid', target: v.target, ...claim };
}
if (v.kind === 'turnActions') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
bribePromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 8 &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
'Supply the current turn, complete turn-action question and a supported quiet native window.');
const respondent = g.players.find(player => player.id === v.target)!;
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
const action = parseTurnActionLeaf(input);
if (action.kind === 'payment') check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
return action;
}, { requireFact: false, maxDepth: TURN_ACTION_MAX_DEPTH, maxLeaves: TURN_ACTION_MAX_LEAVES });
return { kind: 'turnActions', target: v.target as string, turn: g.turn, mixed };
}
if (v.kind === 'travel') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && g.status === 'playing' &&
Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 5 && v.turn === g.turn,
@@ -681,6 +698,9 @@ export function truthQuestionText(
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
};
if (q.kind === 'turnActions') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => action.kind === 'payment' ? `you ${bribeClaimText({ turn: q.turn, ...action }, playerName)}` :
`you ${action.kind === 'ship' ? shipmentClaimText(action) : movementClaimText(action)} before your Shipment and Movement turn ends`)}?`;
if (q.kind === 'travel') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${action.kind === 'ship' ? shipmentClaimText(action) : movementClaimText(action)}`)} this turn?`;
if (q.kind === 'mixedTraitorCall') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
@@ -754,6 +774,8 @@ export function resolveTruthAction(
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
travelAnswers?: (g: Game, p: Player, claim: TravelClaim) => TruthAnswer[];
bindTravel?: (g: Game, p: Player, claim: TravelClaim, answer: boolean, asker: string) => void;
turnActionAnswers?: (g: Game, p: Player, claim: TurnActionClaim) => TruthAnswer[];
bindTurnAction?: (g: Game, p: Player, claim: TurnActionClaim, answer: boolean, asker: string) => void;
mixedTraitorAnswers?: (g: Game, p: Player, claim: MixedTraitorClaim) => TruthAnswer[];
bindMixedTraitor?: (g: Game, p: Player, claim: MixedTraitorClaim, answer: boolean, asker: string) => void;
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
@@ -924,6 +946,9 @@ export function resolveTruthAction(
if (w.question.kind === 'travel')
check(effects.travelAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose an unused or active native Shipment and Movement opportunity.');
if (w.question.kind === 'turnActions')
check(effects.turnActionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole turn-action question compatible with native opportunities and earlier answers.');
if (w.question.kind === 'mixedTraitorCall')
check(effects.mixedTraitorAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole mixed Traitor question for an actual current voter.');
@@ -1053,6 +1078,11 @@ export function resolveTruthAction(
'Choose a whole travel answer compatible with native actions and earlier commitments.');
if (a.answer !== 'unknown') effects.bindTravel(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'turnActions') {
check(effects.turnActionAnswers && effects.bindTurnAction && effects.turnActionAnswers(g, p, q).includes(a.answer),
'Choose a whole turn-action answer compatible with current facts, native actions and earlier commitments.');
if (a.answer !== 'unknown') effects.bindTurnAction(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'mixedTraitorCall') {
check(effects.mixedTraitorAnswers && effects.bindMixedTraitor && effects.mixedTraitorAnswers(g, p, q).includes(a.answer),
'Choose an aggregate answer compatible with this mixed question and current native declaration.');
+408
View File
@@ -0,0 +1,408 @@
import { TERRITORIES } from './board';
import { BribePromiseError, parseBribeClaim } from './bribe-promises';
import {
MixedActionQuestionError,
parseMixedActionExpression,
type CompiledMixedAction,
type MixedActionExpression,
} from './mixed-action-question';
import {
parseTravelActionClaim,
TravelPromiseError,
type TravelActionClaim,
type TravelBranchLeaf,
type TravelEvent,
} from './travel-expression-promises';
import type { TruthFact } from './truthtrance';
export const TURN_ACTION_MAX_LEAVES = 16;
export const TURN_ACTION_MAX_DEPTH = 4;
export type TurnActionLeaf = TravelActionClaim | { kind: 'payment'; recipient: string; minimum: number };
export type TurnActionClaim = { turn: number; mixed: MixedActionExpression<TurnActionLeaf> };
export type TurnActionPayment = { recipient: string; amount: number };
export type TurnActionBranchLeaf = TravelBranchLeaf |
{ kind: 'payment'; recipient: string; minimum: number; progress: number; answer: boolean };
export type TurnActionPromise = TurnActionClaim & {
event: string;
player: string;
asker: string;
answer: boolean;
/** Facts are frozen by the game-aware compiler when this answer is accepted. */
compiled: CompiledMixedAction<TurnActionLeaf>;
shipment: { pending: boolean; event: TravelEvent | null };
/** Largest physical ground group after this answer, never a sum of groups. */
progress: { territory: string; maximum: number }[];
/** Actual completed ordinary payer actions after this answer. */
payments: TurnActionPayment[];
transportClosed: boolean;
paymentClosed: boolean;
fulfilled: boolean;
released: boolean;
};
export class TurnActionError extends Error {
constructor(message: string) {
super(message);
this.name = 'TurnActionError';
}
}
function check(value: unknown, message: string): asserts value {
if (!value) throw new TurnActionError(message);
}
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const keys = (value: Record<string, unknown>, expected: string): boolean =>
Object.keys(value).sort().join(',') === expected;
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const whole = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const positive = (value: unknown): value is number => whole(value) && value > 0;
const physical = (value: unknown): value is number => whole(value) && value <= 20;
const printedTerritory = (value: unknown): value is string =>
typeof value === 'string' && TERRITORIES.some(destination => destination.id === value);
/** Public leaves carry only their native destination/recipient and minimum. */
export function parseTurnActionLeaf(value: unknown): TurnActionLeaf {
check(object(value), 'Choose one future reserve shipment, ground movement or ordinary bribe payment.');
try {
if (value.kind !== 'payment') return parseTravelActionClaim(value);
check(keys(value, 'kind,minimum,recipient'), 'Supply only the ordinary payment recipient and minimum.');
const claim = parseBribeClaim({ turn: 1, recipient: value.recipient, minimum: value.minimum });
return { kind: 'payment', recipient: claim.recipient, minimum: claim.minimum };
} catch (error) {
if (error instanceof TravelPromiseError || error instanceof BribePromiseError)
throw new TurnActionError(error.message);
throw error;
}
}
/** The engine owns game-aware fact decoding; retain and bound its original tree. */
function storedFact(value: unknown, depth = 0): TruthFact {
check(object(value) && identifier(value.kind) && depth <= TURN_ACTION_MAX_DEPTH &&
value.kind !== 'constant' && value.kind !== 'action' && value.kind !== 'fact',
'Retain the original current-fact statement.');
if (value.kind === 'and' || value.kind === 'or') {
check(keys(value, 'kind,terms') && Array.isArray(value.terms) &&
value.terms.length >= 2 && value.terms.length <= TURN_ACTION_MAX_LEAVES,
'Retain a bounded original current-fact group.');
return { kind: value.kind, terms: value.terms.map(term => storedFact(term, depth + 1)) };
}
return value as unknown as TruthFact;
}
function actionKey(action: TurnActionLeaf): string {
return JSON.stringify(action.kind === 'payment'
? [action.kind, action.recipient, action.minimum] : [action.kind, action.territory, action.minimum]);
}
function readCompiled(value: unknown, originals: ReadonlySet<string>): CompiledMixedAction<TurnActionLeaf> {
let leaves = 0;
const read = (input: unknown, depth: number): CompiledMixedAction<TurnActionLeaf> => {
check(object(input) && depth <= TURN_ACTION_MAX_DEPTH,
'Retain the bounded private answer-time turn-action expression.');
if (input.kind === 'and' || input.kind === 'or') {
check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 &&
input.terms.length <= TURN_ACTION_MAX_LEAVES,
'Retain the private answer-time AND/OR turn-action groups.');
return { kind: input.kind, terms: input.terms.map(term => read(term, depth + 1)) };
}
check(++leaves <= TURN_ACTION_MAX_LEAVES, 'Retain at most sixteen private turn-action clauses.');
if (input.kind === 'constant') {
check(keys(input, 'kind,value') && typeof input.value === 'boolean',
'Retain a definite private frozen fact answer.');
return { kind: 'constant', value: input.value };
}
check(input.kind === 'action' && keys(input, 'action,kind'),
'Retain a private frozen fact or original future action.');
const action = parseTurnActionLeaf(input.action);
check(originals.has(actionKey(action)), 'A private future-action clause must come from the original question.');
return { kind: 'action', action };
};
return read(value, 0);
}
function validateEvent(event: unknown): asserts event is TravelEvent {
check(object(event) && keys(event, 'amount,territory') && printedTerritory(event.territory) && physical(event.amount),
'Retain the printed destination and zero to twenty actual physical forces.');
}
function readPromise(promise: TurnActionPromise): CompiledMixedAction<TurnActionLeaf> {
check(object(promise) && keys(promise,
'answer,asker,compiled,event,fulfilled,mixed,paymentClosed,payments,player,progress,released,shipment,transportClosed,turn') &&
positive(promise.turn) && identifier(promise.event) && identifier(promise.player) && identifier(promise.asker),
'Retain the complete original turn-action commitment and its metadata.');
let mixed: MixedActionExpression<TurnActionLeaf>;
try {
mixed = parseMixedActionExpression(promise.mixed, storedFact, parseTurnActionLeaf, {
requireFact: false, maxDepth: TURN_ACTION_MAX_DEPTH, maxLeaves: TURN_ACTION_MAX_LEAVES,
});
} catch (error) {
if (error instanceof MixedActionQuestionError) throw new TurnActionError(error.message);
throw error;
}
const originals = new Set<string>();
const destinations = new Set<string>();
const recipients = new Set<string>();
const visit = (node: MixedActionExpression<TurnActionLeaf>): void => {
if (node.kind === 'action') {
originals.add(actionKey(node.action));
if (node.action.kind === 'move') destinations.add(node.action.territory);
if (node.action.kind === 'payment') recipients.add(node.action.recipient);
} else if (node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
const compiled = readCompiled(promise.compiled, originals);
check(typeof promise.answer === 'boolean' && typeof promise.fulfilled === 'boolean' &&
typeof promise.released === 'boolean' && typeof promise.transportClosed === 'boolean' &&
typeof promise.paymentClosed === 'boolean' && !(promise.fulfilled && promise.released),
'Retain a definite whole turn-action answer and lifecycle.');
check(object(promise.shipment) && keys(promise.shipment, 'event,pending') &&
typeof promise.shipment.pending === 'boolean' && (!promise.shipment.pending || promise.shipment.event === null) &&
(!promise.transportClosed || !promise.shipment.pending),
'Retain this answer’s single pending or received reserve shipment.');
if (promise.shipment.event !== null) validateEvent(promise.shipment.event);
check(Array.isArray(promise.progress), 'Retain this answer’s physical ground-movement maxima.');
const seenDestinations = new Set<string>();
for (const entry of promise.progress) {
check(object(entry) && keys(entry, 'maximum,territory') && printedTerritory(entry.territory) &&
destinations.has(entry.territory) && physical(entry.maximum) && !seenDestinations.has(entry.territory),
'Retain unique named movement destinations and actual physical group maxima.');
seenDestinations.add(entry.territory);
}
check(Array.isArray(promise.payments), 'Retain this answer’s cumulative ordinary payments.');
const seenRecipients = new Set<string>();
for (const payment of promise.payments) {
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) &&
recipients.has(payment.recipient) && whole(payment.amount) && !seenRecipients.has(payment.recipient),
'Retain unique named recipients and safe whole cumulative ordinary payment amounts.');
seenRecipients.add(payment.recipient);
}
return compiled;
}
function actionValue(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
if (action.kind === 'payment') return (promise.payments.find(payment =>
payment.recipient === action.recipient)?.amount ?? 0) >= action.minimum;
if (action.kind === 'ship') return promise.shipment.event !== null &&
promise.shipment.event.territory === action.territory && promise.shipment.event.amount >= action.minimum;
return (promise.progress.find(entry => entry.territory === action.territory)?.maximum ?? 0) >= action.minimum;
}
function evaluate(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnActionPromise): boolean {
if (node.kind === 'constant') return node.value;
if (node.kind === 'action') return actionValue(node.action, promise);
return node.kind === 'and' ? node.terms.every(term => evaluate(term, promise)) :
node.terms.some(term => evaluate(term, promise));
}
function actionClosed(action: TurnActionLeaf, promise: TurnActionPromise): boolean {
return action.kind === 'payment' ? promise.paymentClosed : action.kind === 'ship'
? !promise.shipment.pending : promise.transportClosed;
}
function relevantClosed(node: CompiledMixedAction<TurnActionLeaf>, promise: TurnActionPromise): boolean {
if (node.kind === 'constant') return true;
if (node.kind === 'action') return actionClosed(node.action, promise);
return node.terms.every(term => relevantClosed(term, promise));
}
/** Uses only this answer interval's actual events and private frozen facts. */
export function turnActionValue(promise: TurnActionPromise): boolean {
return evaluate(readPromise(promise), promise);
}
/** Whether the original question names payments, including folded alternatives. */
export function turnActionHasPayments(promise: TurnActionPromise): boolean {
readPromise(promise);
const visit = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'payment' : node.kind !== 'fact' && node.terms.some(visit);
return visit(promise.mixed);
}
export function liveTurnActionPromises<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
check(Array.isArray(promises) && identifier(player) && positive(turn),
'Choose the turn-action respondent and turn.');
for (const promise of promises) check(object(promise), 'Retain an original turn-action record.');
return promises.filter(promise => promise.player === player && promise.turn === turn &&
!promise.fulfilled && !promise.released);
}
function sameAction(left: TurnActionBranchLeaf, right: TurnActionBranchLeaf): boolean {
return left.kind === right.kind && (left.kind === 'payment'
? right.kind === 'payment' && left.recipient === right.recipient && left.progress === right.progress
: right.kind !== 'payment' && left.territory === right.territory);
}
/** Called within one record only: payment progress belongs to that interval. */
function mergeBranches(left: TurnActionBranchLeaf[], right: TurnActionBranchLeaf[]): TurnActionBranchLeaf[] | null {
const merged = left.map(leaf => ({ ...leaf }));
for (const leaf of right) {
const same = merged.find(other => sameAction(other, leaf) && other.answer === leaf.answer);
if (same) same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum);
else merged.push({ ...leaf });
const yes = merged.find(other => sameAction(other, leaf) && other.answer);
const no = merged.find(other => sameAction(other, leaf) && !other.answer);
if (yes && no && yes.minimum >= no.minimum) return null;
if (leaf.kind === 'ship' && leaf.answer && merged.some(other =>
other.kind === 'ship' && other.answer && other.territory !== leaf.territory)) return null;
}
return merged;
}
/** Lazy signed DNF; native ledgers retain costs, entitlements and feasibility. */
export function* turnActionBranches(promise: TurnActionPromise): Generator<TurnActionBranchLeaf[]> {
const compiled = readPromise(promise);
if (promise.fulfilled || promise.released) { yield []; return; }
function* alternatives(node: CompiledMixedAction<TurnActionLeaf>, answer: boolean): Generator<TurnActionBranchLeaf[]> {
if (node.kind === 'constant') { if (node.value === answer) yield []; return; }
if (node.kind === 'action') {
const action = node.action;
const achieved = actionValue(action, promise);
if (achieved || actionClosed(action, promise)) { if (achieved === answer) yield []; return; }
yield [action.kind === 'payment' ? { ...action, answer,
progress: promise.payments.find(payment => payment.recipient === action.recipient)?.amount ?? 0 }
: { ...action, answer }];
return;
}
const conjunction = node.kind === 'and' ? answer : !answer;
if (!conjunction) {
for (const term of node.terms) yield* alternatives(term, answer);
return;
}
const terms = node.terms;
function* combine(index: number, prefix: TurnActionBranchLeaf[]): Generator<TurnActionBranchLeaf[]> {
if (index === terms.length) { yield prefix; return; }
for (const suffix of alternatives(terms[index], answer)) {
const merged = mergeBranches(prefix, suffix);
if (merged) yield* combine(index + 1, merged);
}
}
yield* combine(0, []);
}
yield* alternatives(compiled, promise.answer);
}
function settle<T extends TurnActionPromise>(promise: T, update: T): T {
const compiled = readPromise(update);
const satisfied = evaluate(compiled, update);
check(promise.answer || !satisfied, 'This action conflicts with your whole Truthtrance turn-action No answer.');
return promise.answer && satisfied || !promise.answer && relevantClosed(compiled, update)
? { ...update, fulfilled: true } : update;
}
/** Exactly one original completed future reserve shipment or actual skip. */
export function recordTurnActionShipment<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: TravelEvent | null): T[] {
if (event !== null) validateEvent(event);
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
if (!promise.shipment.pending) continue;
updates.set(promise, settle(promise, { ...promise,
shipment: { pending: false, event: event === null ? null : { ...event } } }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Only an ordinary physical ground group counts, never a shipment or marker. */
export function recordTurnActionMovement<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, event: TravelEvent): T[] {
validateEvent(event);
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
if (promise.transportClosed || event.amount === 0) continue;
const named = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'move' && node.action.territory === event.territory
: node.kind !== 'fact' && node.terms.some(named);
if (!named(promise.mixed)) 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(candidate => candidate.territory === event.territory);
if (entry) entry.maximum = event.amount;
else progress.push({ territory: event.territory, maximum: event.amount });
updates.set(promise, settle(promise, { ...promise, progress }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Call only at the actual completed ordinary payer action, never at receipt. */
export function recordTurnActionPayment<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number, payment: TurnActionPayment): T[] {
check(object(payment) && keys(payment, 'amount,recipient') && identifier(payment.recipient) && positive(payment.amount),
'Retain the exact recipient and positive safe whole ordinary payment.');
const updates = new Map<T, T>();
for (const promise of liveTurnActionPromises(promises, player, turn)) {
readPromise(promise);
if (promise.paymentClosed) continue;
const named = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind === 'payment' && node.action.recipient === payment.recipient
: node.kind !== 'fact' && node.terms.some(named);
if (!named(promise.mixed)) continue;
const old = promise.payments.find(entry => entry.recipient === payment.recipient)?.amount ?? 0;
const amount = old + payment.amount;
check(Number.isSafeInteger(amount), 'The cumulative ordinary payment exceeds the safe integer range.');
const payments = promise.payments.map(entry => ({ ...entry }));
const entry = payments.find(candidate => candidate.recipient === payment.recipient);
if (entry) entry.amount = amount;
else payments.push({ recipient: payment.recipient, amount });
updates.set(promise, settle(promise, { ...promise, payments }));
}
return promises.map(promise => updates.get(promise) ?? promise);
}
/** Close transport only; a later ordinary payment can still complete the tree.
* The native joint solver, rather than this ledger, rejects own illegal closure. */
export function closeTurnActionTransport<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
const live = new Set(liveTurnActionPromises(promises, player, turn));
return promises.map(promise => {
if (!live.has(promise)) return promise;
readPromise(promise);
return settle(promise, { ...promise, transportClosed: true,
shipment: { ...promise.shipment, pending: false } });
});
}
/** Actual turn expiry closes every family. The engine owns own-expiry preflight;
* an externally impossible unsatisfied answer becomes a released terminal row. */
export function finishTurnActionTurn<T extends TurnActionPromise>(promises: readonly T[], player: string,
turn: number): T[] {
const live = new Set(liveTurnActionPromises(promises, player, turn));
return promises.map(promise => {
if (!live.has(promise)) return promise;
readPromise(promise);
const closed = { ...promise, transportClosed: true, paymentClosed: true,
shipment: { ...promise.shipment, pending: false } };
const fulfilled = turnActionValue(closed) === promise.answer;
return { ...closed, fulfilled, released: !fulfilled };
});
}
/** Structure/progress only: root retains fact authority, nonally targeting and
* native payment/force legality. Impossible live Yes routes are not released here. */
export function validateTurnActionPromises(promises: readonly TurnActionPromise[], players: readonly string[]): void {
check(Array.isArray(promises) && Array.isArray(players), 'Retain the turn-action commitments and participants.');
const events = new Set<string>();
for (const promise of promises) {
const compiled = readPromise(promise);
const satisfied = evaluate(compiled, promise);
const recipientsValid = (node: MixedActionExpression<TurnActionLeaf>): boolean => node.kind === 'action'
? node.action.kind !== 'payment' || players.includes(node.action.recipient) && node.action.recipient !== promise.player
: node.kind === 'fact' || node.terms.every(recipientsValid);
check(!events.has(promise.event) && players.includes(promise.player) && players.includes(promise.asker) &&
promise.player !== promise.asker && recipientsValid(promise.mixed) &&
(promise.released || (promise.answer || !satisfied) &&
(!promise.answer || promise.fulfilled === satisfied) &&
(!promise.fulfilled || satisfied === promise.answer && (promise.answer || relevantClosed(compiled, promise))) &&
(!(promise.transportClosed && promise.paymentClosed) || promise.fulfilled)),
'The turn-action commitment lost its event, participants or whole-answer progress.');
events.add(promise.event);
}
}
+153
View File
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import {
actTruthBid, nextTruthBid, truthBidClean, truthBidInventory, truthBidPlayer, type TruthBidStep,
} from './fixture-truth-bid';
import { createTruthBribeExpressionFixture } from './fixture-truth-bribe-expression';
import { nextTruthBribe } from './fixture-truth-bribe';
import { createTruthTravelFixture } from './fixture-truth-travel';
import { createTruthTravelOrderFixture } from './fixture-truth-travel-order';
export type TruthTurnActionClaim = { kind: 'ship' | 'move'; territory: string; minimum: number }
| { kind: 'payment'; recipient: string; minimum: number };
export type TruthTurnActionExpression = MixedActionExpression<TruthTurnActionClaim>;
export type TruthTurnActionStep = TruthBidStep;
export const actTruthTurnAction = actTruthBid;
export const truthTurnActionClean = truthBidClean;
export const truthTurnActionInventory = truthBidInventory;
export const truthTurnActionPlayer = truthBidPlayer;
export const truthTurnActionFact = (fact: TruthFact): TruthTurnActionExpression => ({ kind: 'fact', fact });
export const truthTurnActionPayment = (recipient: string, minimum = 2): TruthTurnActionExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnActionShip = (territory = 'polar_sink', minimum = 1): TruthTurnActionExpression =>
({ kind: 'action', action: { kind: 'ship', territory, minimum } });
export const truthTurnActionMove = (territory = 'imperial_basin', minimum = 1): TruthTurnActionExpression =>
({ kind: 'action', action: { kind: 'move', territory, minimum } });
export const truthTurnActionGroup = (kind: 'and' | 'or', ...terms: TruthTurnActionExpression[]): TruthTurnActionExpression =>
({ kind, terms });
/** These adapters select existing genuine original public sources only. The
* default keeps Harkonnen's two opening Truthtrances and Atreides' unused
* movement turn; Hajr keeps its actually dealt card, and Mentat earns the late
* payment window through original native actions. No played state is set. */
export function createTruthTurnActionFixture(options: {
source?: 'twoTruths' | 'hajr' | 'mentat'; advanced?: boolean; mixed?: TruthTurnActionExpression;
} = {}) {
const sourceKind = options.source ?? 'twoTruths';
const orderSource = sourceKind === 'twoTruths' ? createTruthTravelOrderFixture() : null;
const hajrSource = sourceKind === 'hajr' ? createTruthTravelFixture() : null;
const mentatSource = sourceKind === 'mentat' ? createTruthBribeExpressionFixture({ phase: 8, advanced: options.advanced }) : null;
const original = orderSource ?? hajrSource ?? mentatSource!;
const game = structuredClone(original.game), asker = original.asker, target = original.target;
const observer = game.players.find(player => player.id !== asker && player.id !== target)?.id ?? asker;
const truths = orderSource ? structuredClone(orderSource.truths) : mentatSource
? structuredClone(mentatSource.truths) : [hajrSource!.truth];
const recipient = observer;
const mixed = structuredClone(options.mixed ?? (sourceKind === 'mentat'
? truthTurnActionGroup('or', truthTurnActionPayment(recipient), truthTurnActionMove())
: truthTurnActionGroup('and', truthTurnActionPayment(recipient), truthTurnActionShip(), truthTurnActionMove())));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const actions = structuredClone(original.sourceActions);
const shipment = orderSource?.shipment ?? hajrSource?.shipmentAction ??
{ type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action;
const move = orderSource?.move ?? hajrSource?.firstMove ??
{ type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 } satisfies Action;
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: truthBidPlayer(game, target).spice };
assert.ok(truthBidClean(game)); assert.equal(game.pendingShipment ?? null, null);
assert.equal(truthBidPlayer(game, recipient).ally, null);
assert.ok(truths.every(truth => truthBidPlayer(game, asker).hand.some(card => card.id === truth)));
assert.deepEqual(truthBidInventory(game), original.originalCards);
assert.equal(game.turnActionPromises?.length ?? 0, 0);
return {
game, source: structuredClone(game), sourceKind, orderSource, hajrSource, mentatSource,
asker, target, observer, recipient, truths, truth: truths[0], mixed, question, spiceFact,
setup: original.setup, afterSetup: original.afterSetup, beforeStartingDeal: original.beforeStartingDeal,
originalCards: original.originalCards, originalSpice: original.originalSpice,
dealOrder: original.dealOrder, spiceOrder: original.spiceOrder,
actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length,
sourceCounts: { selected: actions.length,
inherited: orderSource?.sourceCounts.inheritedBidding ?? hajrSource?.sourceCounts.inherited ?? mentatSource!.sourceActionCount,
nativeContinuation: orderSource?.sourceCounts.nativeContinuation ?? 0 },
shipment: structuredClone(shipment), move: structuredClone(move), shipmentCost: 2,
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
end: { type: 'endMovement' } satisfies Action,
hajr: hajrSource?.hajr ?? null, hajrAction: hajrSource?.hajrAction ?? null,
sourceDisclosure: `${original.sourceDisclosure} This turn-action adapter changes only the raw question. ` +
'Future ordinary payments, the original reserve shipment and physical movement groups retain their real native actions. ' +
'All counts derive from recorded arrays; no extra card, hand, wallet, army, clock, promise, receipt or compiled fact is installed.',
};
}
export type TruthTurnActionFixture = ReturnType<typeof createTruthTurnActionFixture>;
export function declareTruthTurnAction(f: TruthTurnActionFixture, source = f.game, copy: 0 | 1 = 0,
actions: TruthTurnActionStep[] = []) {
assert.ok(f.truths[copy], 'The source must actually hold this distinct printed Truthtrance');
const before = structuredClone(source), from = actions.length;
let game = actTruthTurnAction(source, f.asker, { type: 'card', card: f.truths[copy] }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthTurnAction(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, actions, steps: structuredClone(actions.slice(from)), actionCount: actions.length - from };
}
export function askTruthTurnAction(game: Game, asker: string, target: string, mixed: TruthTurnActionExpression,
actions: TruthTurnActionStep[] = []): Game {
return actTruthTurnAction(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnAction(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthTurnActionStep[] = []): Game {
return actTruthTurnAction(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnAction(f: TruthTurnActionFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, copy: 0 | 1 = 0, actions: TruthTurnActionStep[] = []) {
const from = actions.length, declared = declareTruthTurnAction(f, source, copy, actions);
const asked = askTruthTurnAction(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthTurnAction(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length - from };
}
export function truthTurnActionPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
const row = viewGame(game, target).turnActionPromises.find(promise => promise.player === target);
assert.ok(row, 'The respondent retains the original whole turn-action question and status'); return row;
}
export function nextTruthTurnAction(game: Game): TruthTurnActionStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).turnActionGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthBribe(game);
}
export function advanceTruthTurnAction(game: Game, until: (state: Game) => boolean,
actions: TruthTurnActionStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine source ended before its requested native boundary');
const next = nativeOnly ? nextTruthBid(game) : nextTruthTurnAction(game);
assert.ok(next, 'Supply the actual original Truthtrance question, answer or owned payment choice');
game = actTruthTurnAction(game, next.actor, next.action, actions);
}
throw Error('The genuine turn-action source did not reach its finite boundary');
}
export function settleTruthTurnAction(game: Game, actions: TruthTurnActionStep[] = []): Game {
return advanceTruthTurnAction(game, state => truthBidClean(state) && !state.pendingShipment, actions);
}
export function completeTruthTurnAction(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id,
actions: TruthTurnActionStep[] = []): Game {
for (let count = 0; count < 24; count++) {
const promise = truthTurnActionPromise(game, target);
if (promise.fulfilled || promise.released) return game;
game = settleTruthTurnAction(game, actions);
const guide = viewGame(game, target).turnActionGuide; assert.ok(guide, 'The accepted whole answer has an actual native guide');
game = settleTruthTurnAction(actTruthTurnAction(game, target, guide.action, actions), actions);
}
throw Error('The exact native turn-action guide did not settle the accepted whole answer');
}
export function finishTruthTurnAction(game: Game, actions: TruthTurnActionStep[] = []): Game {
const turn = game.turn;
return advanceTruthTurnAction(game, state => state.turn === turn + 1 && state.phase === 0 && truthBidClean(state), actions);
}
+411
View File
@@ -0,0 +1,411 @@
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 { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import { createRevivalOrder } from '../game/revival-order';
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import type { TurnActionLeaf } from '../game/turn-action-promises';
import type { TruthFact } from '../game/truthtrance';
type Expression = MixedActionExpression<TurnActionLeaf>;
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
type Props = Parameters<typeof TurnActionGuideButton>[0];
const event = 'controlled-turn-action';
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
const physical = (kind: 'ship' | 'move' = 'ship', minimum = 1, territory = 'carthag'): Expression =>
({ kind: 'action', action: { kind, territory, minimum } });
const payment = (minimum = 3, recipient = 'r'): Expression => ({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function find(tree: ReactNode, key: string, value: string): Element {
const found = elements(tree).find(element => element.props[key] === value);
assert.ok(found, `${key}=${value}`); return found;
}
const control = (tree: ReactNode, key: string) => find(tree, 'data-turn-action-control', key);
const click = (button: Element) => (button.props.onClick as () => void)();
function select(tree: ReactNode, path: string, field: string, value: string) {
(find(tree, 'id', `truth-turn-action-${path}-${field}`).props.onChange as
(event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
(find(tree, 'id', `truth-turn-action-${path}-minimum`).props.onChange as
(event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
}
function editFact(tree: ReactNode, path: string, value: TruthFact) {
const scope = find(tree, 'data-turn-action-fact-path', path);
const fields = elements(scope).find(element => element.type === TruthFactFields); assert.ok(fields);
(fields.props.onChange as (next: TruthFact) => void)(value);
}
/** Controlled projections verify current UI ownership and exact dispatch only;
* they do not establish native answer feasibility, payment or force receipts. */
function projected(phase = 5, next: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 },
eliteForces: { 'arrakeen:10': 1 }, territory: 'carthag', sector: 11 }): GameView {
const source = createGame('TURNACTIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
joinGame(source, newPlayer('r', 'Recipient', 'harkonnen'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'p'; game.ready = [];
game.bribeOptions = { available: 8, incoming: 0, blocked: null,
targets: [{ id: 'r', maximum: 6, limitation: null }, { id: 'a', maximum: 8, limitation: null }] };
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 2, mixed: claim(), answer: true, fulfilled: false, released: false }];
game.turnActionGuide = { event, action: { ...next, turnActionPromiseEvent: event, turnActionPromisePhase: phase } };
game.truthTurnActionAnswers = null;
return game;
}
function auction(next: Action = { type: 'passBid' }): GameView {
const game = projected(3, next);
game.auction = { bid: 7, bidder: next.type === 'decision' ? 'p' : 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
if (next.type === 'decision') { game.auction.active = 'a'; game.decision = { kind: 'auctionPayment', player: 'p' }; }
return game;
}
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
const game = projected(4, next);
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a', 'r'], ready: [] });
return game;
}
function richese(method: 'silent' | 'onceAround'): GameView {
const native = createRicheseAuction({ event: 'controlled-native-lot', cardId: 'controlled-native-card', owner: 'a', source: 'cache',
method, eligible: ['p', 'a', 'r'], order: ['p', 'a', 'r'], tieOrder: ['p', 'a', 'r'] });
const game = projected(3, { type: 'richeseBid', event: native.event, amount: method === 'silent' ? 0 : null, allyPayment: 0 });
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
return game;
}
function editor(initial: Expression, disabled = false, game = projected()) {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => TurnActionFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(TurnActionFields, { value, game, target: 'p', disabled, onChange() {} })),
error: () => turnActionInputError(value, game, 'p'),
};
}
function guide(props: Props) { const button = TurnActionGuideButton(props); assert.ok(button); return button; }
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView) => renderToStaticMarkup(createElement(TurnActionPromises, { game, busy: false, act() {} }));
function answering(phase = 5): GameView {
const game = projected(phase); game.turnActionGuide = null;
game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth-card' }], passed: [],
question: { kind: 'turnActions', target: 'p', turn: 2, mixed: claim() } };
game.truthTurnActionAnswers = ['yes', 'no', 'unknown'];
return game;
}
void test('turn-action editor keeps all action families and optional facts in the original whole expression', () => {
const e = editor(physical('ship', 3)); assert.equal(e.error(), null);
select(e.tree(), '1', 'action', 'move'); select(e.tree(), '1', 'territory', 'arrakeen'); minimum(e.tree(), '1', 5);
assert.deepEqual(e.value, physical('move', 5, 'arrakeen'));
select(e.tree(), '1', 'action', 'payment'); assert.ok(e.error()); select(e.tree(), '1', 'recipient', 'r');
assert.deepEqual(e.value, payment(5));
click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'timing', 'action');
select(e.tree(), '1.2', 'action', 'move'); minimum(e.tree(), '1.2', 4);
assert.deepEqual(e.value, { kind: 'or', terms: [payment(5), physical('move', 4)] }); assert.equal(e.error(), null);
const html = e.html();
for (const text of [/Make an ordinary payment/, /Ship from reserves/, /Make ground movement/, /sum future real bribes/,
/single future original reserve shipment/, /smaller moves never add together/, /No makes the whole expression false/,
/payment conditions last until the actual turn ends/]) assert.match(html, text);
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, physical('move', 4));
select(e.tree(), '1', 'timing', 'fact'); assert.ok(e.error(), 'The expression still requires a future action.');
});
void test('turn-action editor preserves sixteen clauses and four shared grouping levels without truncation', () => {
const e = editor({ kind: 'or', terms: [payment(), physical('move', 4)] });
for (let count = 3; count <= 16; count++) { click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count); }
assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 conditions/);
const full = structuredClone(e.value), changes = e.changes;
for (const key of ['add-action-1', 'add-fact-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.deepEqual(e.value, full); assert.equal(e.changes, changes);
const deep = editor({ kind: 'and', terms: [fact(), payment()] });
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
assert.match(deep.html(), /4 \/ 4 levels of grouping/);
assert.equal(control(deep.tree(), 'and-1.2.1.1.1').props.disabled, true);
editFact(deep.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 3 }); assert.equal(deep.error(), null);
click(control(deep.tree(), 'remove-1.2.1.1.2')); assert.match(deep.html(), /3 \/ 4 levels of grouping/);
});
void test('turn-action inputs keep invalid drafts repairable and reject disabled callbacks', () => {
for (const expression of [physical(), payment()]) {
for (const invalid of [NaN, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const e = editor(expression); minimum(e.tree(), '1', invalid); assert.ok(e.error());
if (!Number.isFinite(invalid)) assert.match(e.html(), /value=""/);
minimum(e.tree(), '1', 20); assert.equal(e.error(), null);
}
}
const e = editor(physical()); minimum(e.tree(), '1', 21); assert.ok(e.error());
select(e.tree(), '1', 'action', 'payment'); select(e.tree(), '1', 'recipient', 'r'); assert.equal(e.error(), null);
minimum(e.tree(), '1', Number.MAX_SAFE_INTEGER); assert.equal(e.error(), null);
select(e.tree(), '1', 'action', 'ship'); assert.ok(e.error()); minimum(e.tree(), '1', 1);
select(e.tree(), '1', 'territory', 'invented-place'); assert.ok(e.error()); assert.match(e.html(), /aria-invalid="true"/);
const frozen = editor(claim(), true), original = structuredClone(frozen.value);
select(frozen.tree(), '1', 'meaning', 'and'); minimum(frozen.tree(), '1.1', 7);
select(frozen.tree(), '1.1', 'action', 'move'); select(frozen.tree(), '1.1', 'recipient', 'a');
editFact(frozen.tree(), '1.2.1', { kind: 'spice', compare: 'gte', value: 9 });
for (const key of ['and-1', 'add-action-1', 'remove-1.1', 'nested-or-1']) click(control(frozen.tree(), key));
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, original);
});
void test('turn-action fields use public recipients and printed destinations without private eligibility reads', () => {
const game = projected(); game.players.find(player => player.id === 'p')!.ally = 'a';
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
for (const expression of [payment(), physical('ship', 20, 'arrakeen'), { kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), physical('move')] } as Expression]) {
const e = editor(expression, false, game); assert.equal(e.error(), null);
assert.match(e.html(), /Only the aggregate answer is shared/);
}
const e = editor(payment(), false, game); const choices = find(e.tree(), 'id', 'truth-turn-action-1-recipient');
assert.deepEqual(elements(choices).filter(element => element.type === 'option').map(element => element.props.value), ['', 'r']);
select(e.tree(), '1', 'recipient', 'a'); assert.deepEqual(e.value, payment());
for (const recipient of ['p', 'a', 'missing']) assert.ok(turnActionInputError(payment(3, recipient), game, 'p'));
assert.match(editor(physical(), false, game).html(), /Carthag/);
});
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
const game = projected(); game.turnActionGuide = null;
game.turnActionPromises.push(
{ ...game.turnActionPromises[0], event: 'controlled-no', answer: false },
{ ...game.turnActionPromises[0], event: 'controlled-complete', turn: 1, fulfilled: true },
{ ...game.turnActionPromises[0], event: 'controlled-release', released: true },
{ ...game.turnActionPromises[0], event: 'controlled-foreign', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), physical('ship', 19, 'sietch_tabr')] } },
);
for (const promise of game.turnActionPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route', 'payments', 'progress'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
for (const text of [/Turn 1/, /You answered Yes/, /You answered No/, /you hold Shield/, / OR /, / AND /,
/Fulfilled: this whole commitment is complete/, /Released: this whole commitment is no longer possible/,
/No negates the whole expression, not each condition separately/, /Separate No answers each create their own restriction/,
/maximum physical forces in one actual future move/, /Individual fact answers and any reduced future branch remain private/,
/ordinary payments remain possible until the actual turn ends/]) assert.match(html, text);
assert.doesNotMatch(html, /Poison Blade|19 physical|controlled-foreign|controlled-complete|factAnswers/);
assert.equal(TurnActionPromises({ game: { ...game, me: 'missing' }, busy: false, act() {} }), null);
game.turnActionPromises = game.turnActionPromises.filter(promise => promise.fulfilled || promise.released);
assert.doesNotMatch(markup(game), /<button|Your Movement turn is still ahead/);
});
void test('turn-action guide forwards exact shipment, movement, preparation and own movement-end packets', () => {
const actions: Action[] = [
projected().turnActionGuide!.action,
{ type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, elite: 1, allyPayment: 0 },
{ type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 6, elite: 0, allyPayment: 3 },
{ type: 'move', territory: 'carthag', sector: 11, forces: { 'arrakeen:10': 4 }, eliteForces: {},
movementCard: 'controlled-ornithopter', ornithopter: 'twoGroups', ornithopterEvent: 'controlled-flight' },
{ type: 'card', card: 'controlled-hajr' }, { type: 'prepareAdvisors', territory: 'arrakeen' },
{ type: 'revealNoField', territory: 'arrakeen', sector: 10 }, { type: 'endMovement' },
];
for (const action of actions) {
const game = projected(5, action), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.turnActionGuide!.action]); assert.equal(sent[0], game.turnActionGuide!.action);
assert.match(markup(game), /data-testid="turn-action-promise-guide"/);
}
const game = projected(5, { type: 'endMovement' });
game.turnActionPromises[0].mixed = { kind: 'and', terms: [physical('move', 4), payment()] };
assert.match(markup(game), /Finish movement while preserving any later payment conditions/);
game.active = 'a'; game.turnActionGuide = { event, action: { type: 'bribe', target: 'r', amount: 3,
turnActionPromiseEvent: event, turnActionPromisePhase: 5 } };
assert.doesNotMatch(markup(game), /Your Movement turn is still ahead/);
const sent: Action[] = []; press(guide({ game, busy: false, act(value) { sent.push(value); } }));
assert.equal(sent[0], game.turnActionGuide.action);
});
void test('turn-action ordinary-payment guide retains native out-of-turn timing and quiet Mentat readiness', () => {
for (let phase = 0; phase <= 8; phase++) {
const game = projected(phase, { type: 'bribe', target: 'r', amount: 3 }); game.active = 'a'; game.ready = ['p'];
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.match(markup(game), /Pay 3 spice to Recipient while honoring your whole answer/);
if (phase === 8) assert.match(markup(game), /Late recipients collect next turn/);
}
const game = projected(8, { type: 'ready' }); game.active = null;
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.match(markup(game), /Ready to end this turn while honoring your whole answer/);
game.ready.push('p'); press(button); assert.equal(sent.length, 1);
for (const phase of [1, 7]) {
const current = projected(phase, { type: 'ready' }); current.nexus = true; current.spiceWindow = null;
const dispatched: Action[] = [], ready = guide({ game: current, busy: false, act(value) { dispatched.push(value); } });
assert.equal(ready.props.disabled, false); press(ready); assert.equal(dispatched[0], current.turnActionGuide!.action);
if (phase === 1) { current.nexus = false; press(ready); assert.equal(dispatched.length, 1); }
}
for (const change of [
(current: GameView) => { current.bribeOptions.blocked = 'Complete the current decision.'; },
(current: GameView) => { current.bribeOptions.blocked = ''; },
(current: GameView) => { current.bribeOptions.available = 2; },
(current: GameView) => { current.bribeOptions.available = NaN; },
(current: GameView) => { current.bribeOptions.targets[0].maximum = 2; },
(current: GameView) => { current.bribeOptions.targets = []; },
(current: GameView) => { current.players.find(player => player.id === 'p')!.ally = 'r'; },
(current: GameView) => { current.players = current.players.filter(player => player.id !== 'r'); },
]) {
const current = projected(3, { type: 'bribe', target: 'r', amount: 3 }), dispatched: Action[] = [];
const original = guide({ game: current, busy: false, act(value) { dispatched.push(value); } }); change(current); press(original);
assert.deepEqual(dispatched, []);
}
});
void test('turn-action current early native opportunities retain exact preparation, bids and owned cash or Karama invoices', () => {
const cases: GameView[] = [
projected(2, { type: 'charity' }), projected(2, { type: 'ready' }), projected(2, { type: 'card', card: 'controlled-preparation', amount: 3 }),
auction({ type: 'bid', amount: 8, allyPayment: 0 }), auction(),
auction({ type: 'decision', karama: false }), auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' }),
revival(), revival({ type: 'ready' }), revival({ type: 'reviveLeader', leader: 'controlled-leader', early: true, allyPayment: 2 }),
revival({ type: 'acceptLeaderRevival', leaderRevivalPrice: 3 }), revival({ type: 'reviveKwisatz' }),
revival({ type: 'card', card: 'controlled-ghola' }), richese('silent'), richese('onceAround'),
];
for (const game of cases) {
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
}
assert.match(markup(auction({ type: 'bid', amount: 8, allyPayment: 0 })), /Place the required bid of 8/);
assert.match(markup(auction({ type: 'decision', karama: true, card: 'controlled-owned-karama' })), /Use the quoted Karama for the current auction payment/);
});
void test('turn-action current paid Box choices are exact through phases zero to eight without private custody reads', () => {
for (let phase = 0; phase <= 8; phase++) {
const game = projected(phase, { type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-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-search', cards: [{ id: 'controlled-recovered-hajr', name: 'Hajr', kind: 'special', effect: 'hajr' }] } };
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.turnActionGuide!.action);
assert.match(markup(game), /Complete your paid Box search/);
game.nullentropy.search = { ...game.nullentropy.search! }; press(button); assert.equal(sent.length, 1);
}
});
void test('turn-action native guides reject stale auction, invoice, Revival and hidden Richese source changes', () => {
const cases: [() => GameView, ((game: GameView) => void)[]][] = [
[() => auction({ type: 'bid', amount: 8, allyPayment: 0 }), [
game => { game.auction = { ...game.auction! }; }, game => { game.auction!.active = 'a'; },
game => { game.auction!.bid = 8; }, game => { game.auction!.remaining = 1; },
]],
[() => auction({ type: 'decision', karama: false }), [
game => { game.decision = { ...game.decision! }; }, game => { game.decision = null; },
game => { game.decision = { kind: 'auctionPayment', player: 'a' }; },
game => { game.auction!.bidder = 'a'; }, game => { game.auction!.bid = 8; }, game => { game.auction!.allyPayment = 1; },
]],
[() => revival(), [
game => { game.revivalOrder = { ...game.revivalOrder! }; }, game => { game.revivalOrder!.turn = 3; },
game => { game.revivalOrder!.remaining = ['a', 'p']; }, game => { game.active = 'a'; },
game => { game.revival = { ...game.revival }; }, game => { game.revival.orderBlocked = 'Wait for your turn.'; },
game => { game.revival.pending = true; }, game => { game.ready.push('p'); },
]],
[() => richese('silent'), [
game => { game.richeseAuction = { ...game.richeseAuction! }; }, game => { game.richeseAuction!.event = 'another-lot'; },
game => { game.richeseAuction!.eligible = ['a']; }, game => { game.richeseAuction!.submitted.push('p'); },
game => { game.richeseAuction!.outcome = { kind: 'unbid' }; },
]],
];
for (const [build, changes] of cases) for (const change of changes) {
const game = build(), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
for (const method of ['silent', 'onceAround'] as const) {
const game = richese(method);
Object.defineProperty(game.richeseAuction!, 'sealed', { get() { throw Error('No hidden bids'); } });
Object.defineProperty(game.richeseAuction!, 'card', { get() { throw Error('No hidden lot'); } });
const sent: Action[] = []; press(guide({ game, busy: false, act(value) { sent.push(value); } })); assert.equal(sent.length, 1);
}
});
void test('turn-action guide rejects stale event, packet identity, current phase, owner and quiet-window changes', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; },
game => { game.active = 'a'; }, game => { game.turnActionGuide = null; },
game => { game.turnActionGuide = { ...game.turnActionGuide! }; },
game => { game.turnActionGuide!.action = { ...game.turnActionGuide!.action }; },
game => { game.turnActionGuide!.event = 'changed'; }, game => { game.turnActionGuide!.action.turnActionPromiseEvent = 'changed'; },
game => { game.turnActionGuide!.action.turnActionPromisePhase = 4; }, game => { delete game.turnActionGuide!.action.turnActionPromisePhase; },
game => { (game.turnActionGuide!.action.forces as Record<string, number>)['arrakeen:10'] = 2; },
game => { (game.turnActionGuide!.action.eliteForces as Record<string, number>)['arrakeen:10'] = 0; },
game => { game.turnActionPromises[0].event = 'expired'; }, game => { game.turnActionPromises[0].player = 'a'; },
game => { game.turnActionPromises[0].turn = 1; }, game => { game.turnActionPromises[0].fulfilled = true; },
game => { game.turnActionPromises[0].released = true; }, game => { game.turnActionPromises = []; },
game => { game.phaseOpening = { passed: [] }; }, game => { game.automaticContinuationPending = true; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, game => { game.truthtrance = answering().truthtrance; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
change(game); press(button); assert.deepEqual(sent, []);
}
const busy = { game: projected(), busy: false, act() { assert.fail('Busy guide cannot send'); } };
const busyButton = guide(busy); busy.busy = true; press(busyButton);
const replaced = { game: projected(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
const old = guide(replaced); replaced.game = { ...replaced.game }; press(old);
});
void test('turn-action guides reject wrong native opportunities even when a packet is projected', () => {
const invalid: GameView[] = [];
for (const phase of [0, 1, 2, 3, 4, 6, 7, 8]) for (const action of [
{ type: 'ship', amount: 3, territory: 'carthag', sector: 11 }, { type: 'move', forces: { 'arrakeen:10': 4 }, territory: 'carthag', sector: 11 },
{ type: 'endMovement' },
] satisfies Action[]) invalid.push(projected(phase, action));
invalid.push(projected(0, { type: 'ready' }), projected(1, { type: 'ready' }), projected(6, { type: 'ready' }), projected(4, { type: 'charity' }),
projected(2, { type: 'revive', amount: 3 }), projected(3, { type: 'decision', karama: false }), auction({ type: 'decision', karama: true }),
projected(5, { type: 'guildShip', from: 'arrakeen', territory: 'carthag', amount: 3 }));
for (const game of invalid) {
const sent: Action[] = [], button = guide({ game, busy: false, act(value) { sent.push(value); } });
assert.equal(button.props.disabled, true); press(button); assert.deepEqual(sent, []);
}
});
void test('turn-action answers offer only the current respondent native choices throughout phases zero through eight', () => {
for (let phase = 0; phase <= 8; phase++) {
const game = answering(phase), sent: Action[] = [], props = { game, busy: false, act(value: Action) { sent.push(value); } };
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
for (const answer of game.truthTurnActionAnswers!) click(find(TurnActionAnswers(props), 'data-turn-action-answer', answer));
assert.deepEqual(sent, ['yes', 'no', 'unknown'].map(answer => ({ type: 'truthAnswer', answer })));
game.truthTurnActionAnswers = ['no'];
const html = renderToStaticMarkup(createElement(TurnActionAnswers, { game, busy: false, act() {} }));
assert.match(html, /Answer No/); assert.doesNotMatch(html, /Answer Yes|I don’t know yet/);
assert.equal(TurnActionAnswers({ game: { ...game, me: 'a' }, busy: false, act() {} }), null);
assert.equal(TurnActionAnswers({ game: { ...game, truthTurnActionAnswers: null }, busy: false, act() {} }), null);
}
});
void test('turn-action answer callbacks reject stale target, source queue, private choices, phase and modal ownership', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 4; }, game => { game.status = 'setup'; },
game => { game.truthtrance = null; }, game => { game.truthtrance!.stage = 'ask'; },
game => { game.truthtrance!.question = { ...game.truthtrance!.question! }; }, game => { game.truthtrance!.question!.target = 'a'; },
game => { game.truthtrance!.queue[0] = { ...game.truthtrance!.queue[0] }; }, game => { game.truthtrance!.queue[0].card = 'another-card'; },
game => { game.truthTurnActionAnswers = ['yes']; }, game => { game.truthTurnActionAnswers!.splice(0); },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, game => { game.phaseOpening = { passed: [] }; },
game => { game.automaticContinuationPending = true; }, game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
];
for (const change of changes) {
const game = answering(), sent: Action[] = [], tree = TurnActionAnswers({ game, busy: false, act(value) { sent.push(value); } });
const button = find(tree, 'data-turn-action-answer', 'yes'); change(game); click(button); assert.deepEqual(sent, []);
}
for (const phase of [-1, 9, 1.5]) {
const game = answering(phase), sent: Action[] = [], button = find(TurnActionAnswers({ game, busy: false, act(value) { sent.push(value); } }), 'data-turn-action-answer', 'yes');
assert.equal(button.props.disabled, true); click(button); assert.deepEqual(sent, []);
}
const busy = { game: answering(), busy: false, act() { assert.fail('Busy answer cannot send'); } };
const button = find(TurnActionAnswers(busy), 'data-turn-action-answer', 'yes'); busy.busy = true; click(button);
const replaced = { game: answering(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
const old = find(TurnActionAnswers(replaced), 'data-turn-action-answer', 'yes'); replaced.game = { ...replaced.game }; click(old);
});
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import {
actTruthBid as act, createTruthBidFixture, truthBidInventory as inventory, truthBidPlayer as player,
type TruthBidStep,
} from './fixture-truth-bid';
import {
createTruthRevivalEarlyFixture, reachTruthRevivalEarlyOpportunity, settleTruthRevivalEarly,
} from './fixture-truth-revival-early';
/** These two sources actually deal Harkonnen both printed Truthtrances.
* Every commitment below uses its own card and native declaration/priority/
* Ask/answer; no hand, cash, force, clock or promise is installed. */
function ask(game: Game, asker: string, card: string, question: TruthQuestion, actions: TruthBidStep[]) {
game = act(game, asker, { type: 'card', card }, actions);
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' }, actions);
}
return act(game, asker, { type: 'truthAsk', question }, actions);
}
function paymentQuestion(game: Game, target: string, recipient: string, minimum: number): TruthQuestion {
return { kind: 'turnActions', target, turn: game.turn,
mixed: { kind: 'action', action: { kind: 'payment', recipient, minimum } } };
}
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physicalTruths(game: Game, truths: string[], originalCards: string[]) {
for (const truth of truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), originalCards);
}
for (const first of ['bid', 'aggregate'] as const)
void test(`two original Truths reject a second unfunded payment or bid Yes with ${first} accepted first`, () => {
const f = createTruthBidFixture({ window: 'active' }), actions: TruthBidStep[] = [];
assert.equal(f.game.phase, 3); assert.equal(f.game.auction?.active, f.target);
assert.equal(player(f.game, f.target).spice, 10);
assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
const bid: TruthQuestion = { ...f.question, minimum: 10 };
const aggregate = paymentQuestion(f.game, f.target, f.asker, 10);
let game = ask(f.game, f.asker, f.truths[0], first === 'bid' ? bid : aggregate, actions);
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }, actions);
const earlierHistory = structuredClone(game.truthHistory![0]);
const earlierRow = structuredClone(first === 'bid' ? game.bidPromises![0] : game.turnActionPromises![0]);
game = ask(game, f.asker, f.truths[1], first === 'bid' ? aggregate : bid, actions);
const answers = first === 'bid' ? viewGame(game, f.target).truthTurnActionAnswers : viewGame(game, f.target).truthBidAnswers;
assert.deepEqual(answers, ['no']); rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.deepEqual(first === 'bid' ? game.bidPromises![0] : game.turnActionPromises![0], earlierRow);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
assert.equal(player(game, f.target).spice, 10);
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }, actions);
assert.equal(game.truthHistory!.length, 2); assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.deepEqual(first === 'bid' ? game.bidPromises![0] : game.turnActionPromises![0], earlierRow);
game = act(game, f.target, first === 'bid'
? { type: 'bid', amount: 10, allyPayment: 0 }
: { type: 'bribe', target: f.asker, amount: 10 }, actions);
assert.equal(first === 'bid' ? game.bidPromises![0].fulfilled : game.turnActionPromises![0].fulfilled, true);
assert.ok(!(first === 'bid' ? game.bidPromises![0].released : game.turnActionPromises![0].released));
physicalTruths(game, f.truths, f.originalCards);
});
for (const first of ['revival', 'aggregate'] as const)
void test(`two earned native Truths reject a second unfunded payment or Revival Yes with ${first} accepted first`, () => {
// The original source paid six for a real shipment of three; the next
// Storm killed those counters. Its four remaining spice fund exactly the
// ordinary three-force return (one free, two paid), or one payment of four.
const f = createTruthRevivalEarlyFixture({ phase: 3 }), actions = structuredClone(f.sourceActions);
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
let game = reachTruthRevivalEarlyOpportunity(f.game, f.target, actions);
assert.equal(game.turn, 2); assert.equal(game.phase, 4); assert.equal(player(game, f.target).spice, 4);
assert.equal(player(game, f.target).tanks, 3); assert.equal(player(game, f.target).reserves, 17);
assert.deepEqual(player(game, f.asker).hand.map(card => card.id), f.truths);
const revival: TruthQuestion = { kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 3 };
const aggregate = paymentQuestion(game, f.target, f.asker, 4);
game = ask(game, f.asker, f.truths[0], first === 'revival' ? revival : aggregate, actions);
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }, actions);
const earlierHistory = structuredClone(game.truthHistory![0]);
const earlierRow = structuredClone(first === 'revival' ? game.revivalPromises![0] : game.turnActionPromises![0]);
game = ask(game, f.asker, f.truths[1], first === 'revival' ? aggregate : revival, actions);
const answers = first === 'revival' ? viewGame(game, f.target).truthTurnActionAnswers : viewGame(game, f.target).truthRevivalAnswers;
assert.deepEqual(answers, ['no']); rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.deepEqual(first === 'revival' ? game.revivalPromises![0] : game.turnActionPromises![0], earlierRow);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }, actions);
assert.equal(game.truthHistory!.length, 2); assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.deepEqual(first === 'revival' ? game.revivalPromises![0] : game.turnActionPromises![0], earlierRow);
game = settleTruthRevivalEarly(act(game, f.target, first === 'revival'
? { type: 'revive', amount: 3, elite: 0 }
: { type: 'bribe', target: f.asker, amount: 4 }, actions), actions);
assert.equal(player(game, f.target).spice, 0);
assert.equal(first === 'revival' ? game.revivalPromises![0].fulfilled : game.turnActionPromises![0].fulfilled, true);
assert.ok(!(first === 'revival' ? game.revivalPromises![0].released : game.turnActionPromises![0].released));
physicalTruths(game, f.truths, f.originalCards);
});
+310
View File
@@ -0,0 +1,310 @@
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 { TruthQuestion } from '../game/truthtrance';
import { truthFactAnswer } from '../game/truthtrance';
import { completeTruthBribeExpression } from './fixture-truth-bribe-expression';
import {
actTruthTurnAction as act, advanceTruthTurnAction as advance, answerTruthTurnAction as answer,
askTruthTurnAction as ask, bindTruthTurnAction as bind, completeTruthTurnAction as complete,
createTruthTurnActionFixture as fixture, declareTruthTurnAction as declare, finishTruthTurnAction as finish,
settleTruthTurnAction as settle, truthTurnActionClean as clean, truthTurnActionFact as fact,
truthTurnActionGroup as group, truthTurnActionInventory as inventory, truthTurnActionMove as move,
truthTurnActionPayment as payment, truthTurnActionPlayer as player, truthTurnActionPromise as promise,
truthTurnActionShip as ship, type TruthTurnActionFixture, type TruthTurnActionStep,
} from './fixture-truth-turn-action';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function truthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
function nativeControls(game: Game) {
return { turn: game.turn, phase: game.phase, active: game.active, order: game.order,
movementRemaining: game.movementRemaining, storm: game.storm, response: game.response ?? null,
decision: game.decision ?? null, phaseOpening: game.phaseOpening ?? null, pendingShipment: game.pendingShipment ?? null };
}
function scalarQuestion(f: TruthTurnActionFixture, recipient = f.recipient, minimum = 6): TruthQuestion {
return { kind: 'bribe', target: f.target, turn: f.game.turn, recipient, minimum };
}
function bindQuestion(f: TruthTurnActionFixture, source: Game, copy: 0 | 1, question: TruthQuestion,
response: 'yes' | 'no' = 'yes', actions: TruthTurnActionStep[] = []) {
const opened = declare(f, source, copy, actions).game;
const asked = act(opened, f.asker, { type: 'truthAsk', question }, actions);
return answer(asked, f.target, response, actions);
}
void test('native turn-action source keeps two original Truths and the real active Atreides movement prefix', () => {
const f = fixture(); assert.ok(f.orderSource);
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.deck.length, 33);
assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2);
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), f.truths);
assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.target); assert.equal(f.target, 'atreides');
assert.equal(f.recipient, 'emperor'); assert.equal(player(f.game, f.recipient).ally, null);
assert.equal(f.sourceActionCount, 27); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.sourceCount, f.actions.length);
assert.equal(f.sourceCounts.inherited + f.sourceCounts.nativeContinuation, f.sourceCount);
assert.deepEqual(f.sourceActions, f.orderSource.sourceActions);
assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.target).reserves, 10);
assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10 });
assert.equal(player(f.game, f.target).shipped, false); assert.equal(player(f.game, f.target).moved, 0);
assert.equal(f.game.truthHistory?.length ?? 0, 0); assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
});
void test('native AND Yes pays two real spice and separately ships and moves physical forces at original costs', () => {
const f = fixture(), source = structuredClone(f.game), recipientSpice = player(f.game, f.recipient).spice;
let game = bind(f).game;
assert.deepEqual(f.game, source); assert.deepEqual(nativeControls(game), nativeControls(source));
assert.deepEqual(promise(game, f.target).mixed, f.mixed); rejected(game, f.target, f.end);
game = act(game, f.target, f.payment);
assert.equal(player(game, f.target).spice, 8); assert.equal(player(game, f.recipient).spice, recipientSpice);
assert.equal(player(game, f.recipient).bribes, 2); assert.ok(!promise(game, f.target).fulfilled);
assert.deepEqual(game.turnActionPromises![0].payments, [{ recipient: f.recipient, amount: 2 }]);
rejected(game, f.target, f.end); game = settle(act(game, f.target, f.shipment));
assert.equal(player(game, f.target).spice, 6); assert.equal(player(game, f.target).reserves, 9);
assert.equal(player(game, f.target).forces['polar_sink:0'], 1); assert.equal(player(game, f.target).moved, 0);
assert.deepEqual(game.turnActionPromises![0].progress, []);
assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, f.end);
game = settle(act(game, f.target, f.move));
assert.equal(player(game, f.target).forces['arrakeen:10'], 9);
assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
assert.deepEqual(game.turnActionPromises![0].progress, [{ territory: 'imperial_basin', maximum: 1 }]);
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released);
game = settle(act(game, f.target, f.end)); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native ordinary payments to one recipient sum while reserve shipment never supplies ground progress', () => {
const f = fixture(), mixed = group('and', payment(f.recipient, 2), ship('imperial_basin'), move());
let game = bind(f, 'yes', mixed).game;
game = act(game, f.target, { ...f.payment, amount: 1 });
game = settle(act(game, f.target, { ...f.shipment, territory: 'imperial_basin', sector: 9 }));
assert.equal(player(game, f.recipient).bribes, 1); assert.equal(player(game, f.target).forces['imperial_basin:9'], 1);
assert.equal(player(game, f.target).moved, 0); assert.ok(!promise(game, f.target).fulfilled);
game = act(game, f.target, { ...f.payment, amount: 1 });
assert.equal(player(game, f.recipient).bribes, 2); assert.ok(!promise(game, f.target).fulfilled);
assert.deepEqual(game.turnActionPromises![0].payments, [{ recipient: f.recipient, amount: 2 }]);
rejected(game, f.target, f.end); game = settle(act(game, f.target, f.move));
assert.equal(player(game, f.target).forces['imperial_basin:9'], 2);
assert.equal(promise(game, f.target).fulfilled, true); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native repeated smaller Hajr groups retain one movement maximum rather than summed force arrivals', () => {
const f = fixture({ source: 'hajr' }); assert.ok(f.hajrAction); assert.ok(f.hajrSource);
const mixed = group('and', payment(f.recipient), ship(), move('imperial_basin', 2));
let game = bind(f, 'no', mixed).game;
game = act(game, f.target, f.payment); game = settle(act(game, f.target, f.shipment));
game = settle(act(game, f.target, f.move)); game = settle(act(game, f.target, f.hajrAction));
game = settle(act(game, f.target, f.move));
assert.equal(player(game, f.target).moved, 2); assert.equal(player(game, f.target).forces['imperial_basin:9'], 2);
assert.deepEqual(game.turnActionPromises![0].progress, [{ territory: 'imperial_basin', maximum: 1 }]);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
game = finish(game); assert.equal(promise(game, f.target).fulfilled, true);
truthOnce(game, f.truth); assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native OR Yes retains either manual payment or movement route without fixing its alternative', () => {
const f = fixture(), mixed = group('or', payment(f.recipient), move()), bound = bind(f, 'yes', mixed).game;
for (const action of [f.payment, f.move]) {
let game = settle(act(bound, f.target, action));
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
assert.equal(player(game, f.target).reserves, 10);
assert.equal(player(game, f.target).moved, action.type === 'move' ? 1 : 0);
assert.equal(player(game, f.recipient).bribes, action.type === 'bribe' ? 2 : 0);
game = settle(act(game, f.target, f.end)); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('native OR cumulative payments preserve both partial and full-purse payment routes alongside shipment', () => {
const f = fixture(), mixed = group('or', payment(f.recipient, 10), ship());
const bound = bind(f, 'yes', mixed).game;
assert.equal(player(bound, f.target).spice, 10);
const options = viewGame(bound, f.target).bribeOptions;
assert.equal(options.blocked, null); assert.equal(options.available, 10);
assert.equal(options.targets.find(target => target.id === f.recipient)?.maximum, 10);
// Nine leaves one funded spice and nine progress: a final one-spice
// payment still completes this OR, even when shipment is no longer funded.
let partial = act(bound, f.target, { ...f.payment, amount: 9 });
assert.equal(player(partial, f.target).spice, 1); assert.ok(!promise(partial, f.target).fulfilled);
partial = act(partial, f.target, f.end);
partial = act(partial, f.target, { ...f.payment, amount: 1 });
assert.equal(promise(partial, f.target).fulfilled, true);
assert.deepEqual(partial.turnActionPromises![0].payments, [{ recipient: f.recipient, amount: 10 }]);
const paid = act(bound, f.target, { ...f.payment, amount: 10 });
assert.equal(player(paid, f.target).spice, 0); assert.equal(player(paid, f.recipient).bribes, 10);
assert.equal(player(paid, f.target).reserves, 10); assert.equal(player(paid, f.target).moved, 0);
assert.equal(promise(paid, f.target).fulfilled, true); assert.ok(!promise(paid, f.target).released);
const shipped = settle(act(bound, f.target, f.shipment));
assert.equal(player(shipped, f.target).spice, 8); assert.equal(player(shipped, f.target).reserves, 9);
assert.equal(player(shipped, f.target).forces['polar_sink:0'], 1);
assert.equal(player(shipped, f.recipient).bribes, 0); assert.equal(promise(shipped, f.target).fulfilled, true);
for (const state of [partial, paid, shipped]) { truthOnce(state, f.truth); assert.deepEqual(inventory(state), f.originalCards); }
});
void test('native payment OR movement Yes may close own transport then pay in late Mentat before Ready', () => {
const f = fixture(), mixed = group('or', payment(f.recipient), move());
let game = bind(f, 'yes', mixed).game;
game = settle(act(game, f.target, f.end));
assert.equal(player(game, f.target).moved, 0); assert.equal(player(game, f.target).reserves, 10);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, { type: 'ready' });
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true);
game = act(game, f.target, { type: 'ready' }); assert.ok(game.ready.includes(f.target));
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native whole No keeps its payment cap after own transport closes and through actual current turn end', () => {
const f = fixture(), mixed = group('or', payment(f.recipient), move());
let game = bind(f, 'no', mixed).game;
rejected(game, f.target, f.move); rejected(game, f.target, f.payment);
game = act(game, f.target, { ...f.payment, amount: 1 }); game = settle(act(game, f.target, f.end));
assert.ok(!promise(game, f.target).fulfilled);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released);
rejected(game, f.target, { ...f.payment, amount: 1 });
game = finish(game); assert.equal(game.turn, 2); assert.equal(promise(game, f.target).fulfilled, true);
const oldReceipt = structuredClone(game.turnActionPromises![0]);
game = act(game, f.target, f.payment); assert.deepEqual(game.turnActionPromises![0], oldReceipt);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('controlled known Inflation Double rejects a late payment-only Yes and retains truthful No', () => {
const f = fixture({ source: 'mentat' }), controlled = structuredClone(f.game);
// Original played source and inventory; only the public timing token is a
// controlled variant, not an asserted original CHOAM journey.
controlled.inflation = { side: 'double', placedTurn: 0, updatedTurn: 0, flipped: false };
const opened = declare(f, controlled).game;
const asked = ask(opened, f.asker, f.target, f.mixed);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const game = answer(asked, f.target, 'no');
assert.equal(viewGame(game, f.target).turnActionGuide?.action.type, 'ready');
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native current-spice facts freeze at the answer before real payments reduce the purse', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), payment(f.recipient), ship(), move());
assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.ok(!promise(game, f.target).fulfilled);
rejected(game, f.target, f.end); game = settle(act(game, f.target, f.shipment));
game = settle(act(game, f.target, f.move)); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(promise(game, f.target).mixed, mixed); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const first of ['scalar', 'aggregate'] as const) void test(`two native Truths share one actual payment in ${first}-first acceptance order`, () => {
const f = fixture(), mixed = group('and', payment(f.recipient, 6), ship(), move());
const aggregate: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn, mixed };
const questions = first === 'scalar' ? [scalarQuestion(f), aggregate] : [aggregate, scalarQuestion(f)];
const actions: TruthTurnActionStep[] = [];
let game = bindQuestion(f, f.game, 0, questions[0], 'yes', actions);
const earlierHistory = structuredClone(game.truthHistory![0]);
game = bindQuestion(f, game, 1, questions[1], 'yes', actions);
assert.deepEqual(game.truthHistory![0], earlierHistory);
game = act(game, f.target, { ...f.payment, amount: 6 }, actions);
assert.equal(game.bribePromises![0].progress, 6); assert.equal(game.bribePromises![0].fulfilled, true);
assert.equal(player(game, f.recipient).bribes, 6); assert.equal(player(game, f.target).spice, 4);
assert.ok(!promise(game, f.target).fulfilled); game = settle(act(game, f.target, f.shipment, actions), actions);
game = settle(act(game, f.target, f.move, actions), actions);
assert.equal(player(game, f.target).spice, 2); assert.equal(promise(game, f.target).fulfilled, true);
assert.ok(!game.bribePromises![0].released); assert.equal(game.truthHistory!.length, 2);
assert.equal(actions.filter(step => step.action.type === 'bribe').length, 1);
for (const truth of f.truths) truthOnce(game, truth); assert.deepEqual(inventory(game), f.originalCards);
});
for (const first of ['scalar', 'aggregate'] as const) void test(`two native Truths reject overbooking original cash in ${first}-first acceptance order`, () => {
const f = fixture(), mixed = group('and', payment(f.asker, 1), ship(), move());
const aggregate: TruthQuestion = { kind: 'turnActions', target: f.target, turn: f.game.turn, mixed };
const scalar = scalarQuestion(f, f.recipient, 8);
let game = bindQuestion(f, f.game, 0, first === 'scalar' ? scalar : aggregate);
const history = structuredClone(game.truthHistory), earlier = structuredClone(first === 'scalar'
? game.bribePromises![0] : game.turnActionPromises![0]);
game = act(declare(f, game, 1).game, f.asker, { type: 'truthAsk', question: first === 'scalar' ? aggregate : scalar });
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(game.truthHistory, history);
assert.deepEqual(first === 'scalar' ? game.bribePromises![0] : game.turnActionPromises![0], earlier);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
assert.equal(player(game, f.target).spice, 10); assert.deepEqual(inventory(game), f.originalCards);
});
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} late Mentat payer fulfills before the receiver's next opening receipt`, () => {
const f = fixture({ source: 'mentat', advanced }); assert.ok(f.mentatSource);
assert.equal(f.sourceActionCount, advanced ? 35 : 32); assert.equal(f.sourceActionCount, f.sourceActions.length);
let game = act(f.game, f.target, { type: 'ready' }); game = act(game, f.asker, { type: 'ready' });
assert.equal(game.phase, 8); const recipientSpice = player(game, f.recipient).spice;
game = bind(f, 'yes', f.mixed, game).game;
assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.asker));
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 8);
assert.equal(player(game, f.recipient).spice, recipientSpice); assert.equal(player(game, f.recipient).bribes, 2);
const receipt = structuredClone(game.turnActionPromises![0].payments);
game = act(game, f.target, { type: 'ready' });
const result = completeTruthBribeExpression(f.mentatSource, game);
assert.equal(result.nextTurn.after.turn, 2); assert.equal(result.nextTurn.after.phase, 0);
assert.equal(player(result.nextTurn.after, f.recipient).bribes, 2);
assert.equal(player(result.mentat.before, f.recipient).bribes, 2);
assert.equal(player(result.mentat.after, f.recipient).bribes, 0);
assert.equal(player(result.mentat.after, f.recipient).spice - player(result.mentat.before, f.recipient).spice, 2);
assert.deepEqual(result.game.turnActionPromises![0].payments, receipt, 'Actual later income must not count the payer action twice');
assert.equal(promise(result.game, f.target).fulfilled, true);
truthOnce(result.game, f.truth); assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('native turn-action raw projection and exact current-step guide preserve owner phase and event authority', () => {
const f = fixture(), declared = declare(f);
rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
const asked = ask(declared.game, f.asker, f.target, f.mixed);
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'yes');
const row = promise(game, f.target), required = ['event', 'player', 'asker', 'turn', 'mixed', 'answer'];
assert.ok(required.every(key => Object.keys(row).includes(key)));
assert.ok(Object.keys(row).every(key => [...required, 'fulfilled', 'released'].includes(key)));
assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, f.question);
for (const actor of [f.asker, f.observer]) {
assert.deepEqual(viewGame(game, actor).turnActionPromises, []); assert.equal(viewGame(game, actor).turnActionGuide, null);
}
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
assert.equal(guide.action.turnActionPromisePhase, game.phase);
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-native-turn-action-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 4 });
rejected(game, f.target, { ...guide.action, amount: Number(guide.action.amount) + 1 });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
rejected(game, f.target, wire); game = complete(game, f.target); rejected(game, f.target, guide.action);
assert.equal(promise(game, f.target).fulfilled, true); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal profiles actually answer and perform the current native payment shipment and movement guides', () => {
const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnActionStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const before = structuredClone(answering), answerAction = botActions(answering).find(action => action.type === 'truthAnswer');
assert.ok(answerAction, difficulty); assert.deepEqual(answering, before); assert.equal(answerAction.answer, 'yes', difficulty);
game = act(game, f.target, answerAction, actions);
for (let count = 0; count < 12 && !promise(game, f.target).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.turnActionGuide; assert.ok(guide, difficulty);
const action = botActions(view).find(action => action.turnActionPromiseEvent === guide.event);
assert.ok(action, difficulty); assert.deepEqual(action, guide.action);
game = settle(act(game, f.target, action, actions), actions);
}
assert.equal(promise(game, f.target).fulfilled, true, difficulty);
for (const kind of ['bribe', 'ship', 'move']) assert.ok(actions.some(step => step.action.type === kind), `${difficulty}: ${kind}`);
assert.equal(player(game, f.target).spice, 6); assert.equal(player(game, f.target).reserves, 9);
assert.equal(player(game, f.target).moved, 1); assert.equal(player(game, f.recipient).bribes, 2);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});
+379
View File
@@ -0,0 +1,379 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
compileMixedActionExpression,
type MixedActionExpression,
} from '../game/mixed-action-question';
import {
TURN_ACTION_MAX_DEPTH,
TURN_ACTION_MAX_LEAVES,
TurnActionError,
closeTurnActionTransport,
finishTurnActionTurn,
liveTurnActionPromises,
parseTurnActionLeaf,
recordTurnActionMovement,
recordTurnActionPayment,
recordTurnActionShipment,
turnActionBranches,
turnActionHasPayments,
turnActionValue,
validateTurnActionPromises,
type TurnActionBranchLeaf,
type TurnActionLeaf,
type TurnActionPromise,
} from '../game/turn-action-promises';
type Expression = MixedActionExpression<TurnActionLeaf>;
const seats = ['respondent', 'asker', 'recipient', 'other', 'second-recipient'];
const travel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3): Expression =>
({ kind: 'action', action: { kind, territory, minimum } });
const pay = (recipient = 'recipient', minimum = 3): Expression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
answer = true): TurnActionBranchLeaf => ({ kind, territory, minimum, answer });
const signedPay = (recipient = 'recipient', minimum = 3, progress = 0,
answer = true): TurnActionBranchLeaf => ({ kind: 'payment', recipient, minimum, progress, answer });
const event = (territory = 'carthag', amount = 3) => ({ territory, amount });
const payment = (recipient = 'recipient', amount = 3) => ({ recipient, amount });
function promise(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
return {
turn: 1, mixed, event: id, player: 'respondent', asker: 'asker', answer,
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
shipment: { pending: true, event: null }, progress: [], payments: [],
transportClosed: false, paymentClosed: false, fulfilled: false, released: false,
};
}
const branches = (record: TurnActionPromise) => [...turnActionBranches(record)];
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
for (const kind of ['ship', 'move'] as const) {
const leaf = { kind, territory: 'carthag', minimum: 3 };
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
assert.notEqual(parseTurnActionLeaf(leaf), leaf);
for (const invalid of [{ ...leaf, minimum: 0 }, { ...leaf, minimum: 21 },
{ ...leaf, territory: 'not-a-territory' }, { ...leaf, amount: 3 },
{ ...leaf, answer: true }, { ...leaf, recipient: 'recipient' }])
assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError);
}
for (const minimum of [1, 21, 1001, Number.MAX_SAFE_INTEGER]) {
const leaf = { kind: 'payment', recipient: 'recipient', minimum };
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
assert.notEqual(parseTurnActionLeaf(leaf), leaf);
}
const leaf = { kind: 'payment', recipient: 'recipient', minimum: 3 };
for (const invalid of [null, [], {}, { ...leaf, kind: 'gift' }, { ...leaf, recipient: '' },
{ ...leaf, recipient: ' recipient' }, { ...leaf, progress: 0 }, { ...leaf, turn: 1 },
{ ...leaf, territory: 'carthag' }, { ...leaf, answer: true },
...[0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '3'].map(minimum => ({ ...leaf, minimum }))])
assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError);
});
void test('one whole AND requires actual shipment, physical movement and ordinary payment while OR retains choices', () => {
const and = promise(group('and', travel('ship'), travel('move', 'arrakeen', 2), pay()));
const or = promise(group('or', travel('ship'), travel('move', 'arrakeen', 2), pay()), true, 'turn-action:2');
assert.deepEqual(branches(and), [[signedTravel('ship'), signedTravel('move', 'arrakeen', 2), signedPay()]]);
assert.deepEqual(branches(or), [[signedTravel('ship')], [signedTravel('move', 'arrakeen', 2)], [signedPay()]]);
const shipped = recordTurnActionShipment([and, or], 'respondent', 1, event());
assert.equal(shipped[0].fulfilled, false);
assert.equal(shipped[1].fulfilled, true);
assert.deepEqual(branches(shipped[0]), [[signedTravel('move', 'arrakeen', 2), signedPay()]]);
const moved = recordTurnActionMovement(shipped, 'respondent', 1, event('arrakeen', 2));
assert.equal(moved[0].fulfilled, false);
assert.deepEqual(branches(moved[0]), [[signedPay()]]);
const paid = recordTurnActionPayment(moved, 'respondent', 1, payment());
assert.equal(paid[0].fulfilled, true);
assert.equal(turnActionValue(paid[0]), true);
assert.deepEqual(branches(paid[0]), [[]]);
assert.equal(liveTurnActionPromises(paid, 'respondent', 1).length, 0);
assert.equal(paid[1], shipped[1], 'A terminal alternative never receives later movement or payments');
assert.deepEqual(and.progress, []);
assert.deepEqual(and.payments, []);
assert.equal(and.shipment.pending, true);
validateTurnActionPromises(paid, seats);
});
void test('whole No negates the entire AND/OR tree and actual actions may change its remaining branch', () => {
const andNo = promise(group('and', travel('ship'), group('or', travel('move', 'arrakeen', 2), pay())), false);
assert.deepEqual(branches(andNo), [[signedTravel('ship', 'carthag', 3, false)],
[signedTravel('move', 'arrakeen', 2, false), signedPay('recipient', 3, 0, false)]]);
const shipped = recordTurnActionShipment([andNo], 'respondent', 1, event());
assert.deepEqual(branches(shipped[0]), [[signedTravel('move', 'arrakeen', 2, false),
signedPay('recipient', 3, 0, false)]]);
const before = structuredClone(shipped);
assert.throws(() => recordTurnActionMovement(shipped, 'respondent', 1, event('arrakeen', 2)), TurnActionError);
assert.throws(() => recordTurnActionPayment(shipped, 'respondent', 1, payment()), TurnActionError);
assert.deepEqual(shipped, before);
const orNo = promise(group('or', travel('ship'), pay()), false);
assert.deepEqual(branches(orNo), [[signedTravel('ship', 'carthag', 3, false),
signedPay('recipient', 3, 0, false)]]);
assert.throws(() => recordTurnActionShipment([orNo], 'respondent', 1, event()), TurnActionError);
assert.throws(() => recordTurnActionPayment([orNo], 'respondent', 1, payment()), TurnActionError);
const skipped = recordTurnActionShipment([andNo], 'respondent', 1, null);
assert.ok(branches(skipped[0]).some(branch => branch.length === 0));
assert.equal(skipped[0].fulfilled, false, 'Other relevant opportunities remain open even with a decisive absent shipment');
const paid = recordTurnActionPayment(skipped, 'respondent', 1, payment());
assert.equal(paid[0].fulfilled, false);
assert.equal(finishTurnActionTurn(paid, 'respondent', 1)[0].fulfilled, true);
});
void test('payment progress sums actual payer events while movement progress retains only one maximum group', () => {
const original = promise(group('and', travel('move', 'carthag', 5), pay('recipient', 5)));
const moved = recordTurnActionMovement([original], 'respondent', 1, event('carthag', 3));
const movedAgain = recordTurnActionMovement(moved, 'respondent', 1, event('carthag', 3));
assert.equal(movedAgain[0], moved[0]);
assert.deepEqual(movedAgain[0].progress, [{ territory: 'carthag', maximum: 3 }]);
const paid = recordTurnActionPayment(movedAgain, 'respondent', 1, payment('recipient', 3));
const paidAgain = recordTurnActionPayment(paid, 'respondent', 1, payment('recipient', 2));
assert.deepEqual(paidAgain[0].payments, [payment('recipient', 5)]);
assert.equal(turnActionValue(paidAgain[0]), false);
assert.deepEqual(branches(paidAgain[0]), [[signedTravel('move', 'carthag', 5)]]);
assert.equal(recordTurnActionMovement(paidAgain, 'respondent', 1, event('carthag', 5))[0].fulfilled, true);
assert.equal(recordTurnActionMovement(paidAgain, 'respondent', 1, event('arrakeen', 20))[0], paidAgain[0]);
assert.equal(recordTurnActionPayment(paidAgain, 'respondent', 1, payment('other', 20))[0], paidAgain[0]);
});
void test('answer intervals retain separate payment receipts, movement maxima and one shared original shipment', () => {
const mixed = group('and', group('or', travel('ship', 'carthag', 5), travel('move', 'arrakeen', 5)), pay('recipient', 7));
const old = promise(mixed);
const once = recordTurnActionPayment([old], 'respondent', 1, payment('recipient', 3));
const shipped = recordTurnActionShipment(once, 'respondent', 1, event('carthag', 4));
const moved = recordTurnActionMovement(shipped, 'respondent', 1, event('arrakeen', 4));
const fresh = promise(mixed, true, 'turn-action:2');
fresh.shipment.pending = false;
const otherPlayer = { ...promise(mixed, true, 'turn-action:3'), player: 'other' };
const otherTurn = { ...promise(mixed, true, 'turn-action:4'), turn: 2 };
const list = [moved[0], fresh, otherPlayer, otherTurn];
const paid = recordTurnActionPayment(list, 'respondent', 1, payment('recipient', 2));
assert.deepEqual(paid[0].payments, [payment('recipient', 5)]);
assert.deepEqual(paid[1].payments, [payment('recipient', 2)]);
assert.deepEqual(branches(paid[0]), [[signedTravel('move', 'arrakeen', 5), signedPay('recipient', 7, 5)]]);
assert.deepEqual(branches(paid[1]), [[signedTravel('move', 'arrakeen', 5), signedPay('recipient', 7, 2)]]);
const again = recordTurnActionShipment(paid, 'respondent', 1, event('arrakeen', 20));
assert.equal(again[0].shipment.event?.amount, 4);
assert.equal(again[1].shipment.event, null);
assert.equal(again[2], otherPlayer);
assert.equal(again[3], otherTurn);
const small = recordTurnActionMovement(again, 'respondent', 1, event('arrakeen', 2));
assert.deepEqual(small[0].progress, [{ territory: 'arrakeen', maximum: 4 }]);
assert.deepEqual(small[1].progress, [{ territory: 'arrakeen', maximum: 2 }]);
});
void test('signed DNF merges only same-kind bounds within a record and prunes incompatible positive shipment destinations', () => {
const same = promise(group('and', travel('ship', 'carthag', 2), travel('ship', 'carthag', 5),
travel('move', 'carthag', 3), travel('move', 'carthag', 4), pay('recipient', 3), pay('recipient', 6)));
const partial = recordTurnActionPayment([same], 'respondent', 1, payment('recipient', 2));
assert.deepEqual(branches(partial[0]), [[signedTravel('ship', 'carthag', 5), signedTravel('move', 'carthag', 4),
signedPay('recipient', 6, 2)]]);
const no = promise(group('or', pay('recipient', 3), pay('recipient', 6), travel('move', 'carthag', 2),
travel('move', 'carthag', 5)), false);
assert.deepEqual(branches(no), [[signedPay('recipient', 3, 0, false), signedTravel('move', 'carthag', 2, false)]]);
const impossible = promise(group('and', travel('ship'), travel('ship', 'arrakeen'), pay()));
assert.deepEqual(branches(impossible), []);
const choices = promise(group('and', group('or', travel('ship'), pay()),
group('or', travel('ship', 'arrakeen', 2), travel('move', 'arrakeen', 4))));
assert.deepEqual(branches(choices), [
[signedTravel('ship'), signedTravel('move', 'arrakeen', 4)],
[signedPay(), signedTravel('ship', 'arrakeen', 2)],
[signedPay(), signedTravel('move', 'arrakeen', 4)],
]);
});
void test('transport closure preserves actual shipment and unfinished Yes payment alternatives without inventing a release', () => {
const original = promise(group('and', travel('ship'), group('or', travel('move', 'arrakeen', 2), pay())));
const shipped = recordTurnActionShipment([original], 'respondent', 1, event());
const closed = closeTurnActionTransport(shipped, 'respondent', 1);
assert.equal(closed[0].transportClosed, true);
assert.equal(closed[0].paymentClosed, false);
assert.deepEqual(closed[0].shipment, { pending: false, event: event() });
assert.deepEqual(branches(closed[0]), [[signedPay()]]);
assert.equal(closed[0].fulfilled, false);
assert.equal(closed[0].released, false);
validateTurnActionPromises(closed, seats);
assert.equal(recordTurnActionMovement(closed, 'respondent', 1, event('arrakeen', 2))[0], closed[0]);
assert.equal(recordTurnActionPayment(closed, 'respondent', 1, payment())[0].fulfilled, true);
const impossible = closeTurnActionTransport([promise(travel('move'))], 'respondent', 1);
assert.deepEqual(branches(impossible[0]), []);
assert.equal(impossible[0].released, false, 'Native closure preflight decides whether the respondent may close');
validateTurnActionPromises(impossible, seats);
});
void test('whole No remains binding after transport closes through a later ordinary payment window', () => {
const original = promise(group('or', travel('move'), pay('recipient', 5)), false);
const closed = closeTurnActionTransport([original], 'respondent', 1);
assert.equal(closed[0].fulfilled, false);
assert.deepEqual(branches(closed[0]), [[signedPay('recipient', 5, 0, false)]]);
const partial = recordTurnActionPayment(closed, 'respondent', 1, payment('recipient', 4));
assert.equal(partial[0].fulfilled, false);
assert.deepEqual(branches(partial[0]), [[signedPay('recipient', 5, 4, false)]]);
assert.throws(() => recordTurnActionPayment(partial, 'respondent', 1, payment('recipient', 1)), TurnActionError);
validateTurnActionPromises(partial, seats);
const finished = finishTurnActionTurn(partial, 'respondent', 1);
assert.equal(finished[0].fulfilled, true);
assert.equal(finished[0].paymentClosed, true);
assert.equal(finished[0].released, false);
validateTurnActionPromises(finished, seats);
const transportOnly = closeTurnActionTransport([promise(travel('move'), false)], 'respondent', 1);
assert.equal(transportOnly[0].fulfilled, true);
const shipmentOnly = recordTurnActionShipment([promise(travel('ship'), false)], 'respondent', 1, null);
assert.equal(shipmentOnly[0].fulfilled, true);
});
void test('closed payment opportunities fold false while achieved payments fold true in remaining transport branches', () => {
const mixed = group('or', travel('move'), pay());
const closedPayment = { ...promise(mixed), paymentClosed: true };
assert.deepEqual(branches(closedPayment), [[signedTravel('move')]]);
assert.equal(recordTurnActionPayment([closedPayment], 'respondent', 1, payment())[0], closedPayment);
const achievedPayment = { ...promise(group('and', travel('move'), pay())), payments: [payment()] };
assert.deepEqual(branches(achievedPayment), [[signedTravel('move')]]);
const impossible = { ...promise(pay()), paymentClosed: true };
assert.deepEqual(branches(impossible), []);
validateTurnActionPromises([closedPayment], seats);
});
void test('actual turn expiry fulfills the aggregate answer or releases it and terminal rows never update', () => {
const yes = promise(group('and', travel('ship'), pay()));
const no = promise(group('and', travel('ship'), pay()), false, 'turn-action:2');
const externallyReleased = { ...promise(pay(), true, 'turn-action:3'), released: true };
const fulfilled = recordTurnActionPayment([promise(pay(), true, 'turn-action:4')], 'respondent', 1, payment())[0];
const finished = finishTurnActionTurn([yes, no, externallyReleased, fulfilled], 'respondent', 1);
assert.equal(finished[0].released, true);
assert.equal(finished[0].fulfilled, false);
assert.equal(finished[1].fulfilled, true);
assert.equal(finished[1].released, false);
assert.equal(finished[2], externallyReleased);
assert.equal(finished[3], fulfilled);
for (const record of finished.slice(0, 2)) {
assert.equal(record.transportClosed, true);
assert.equal(record.paymentClosed, true);
assert.equal(record.shipment.pending, false);
}
for (const transform of [
(list: TurnActionPromise[]) => recordTurnActionShipment(list, 'respondent', 1, event()),
(list: TurnActionPromise[]) => recordTurnActionMovement(list, 'respondent', 1, event()),
(list: TurnActionPromise[]) => recordTurnActionPayment(list, 'respondent', 1, payment()),
(list: TurnActionPromise[]) => closeTurnActionTransport(list, 'respondent', 1),
(list: TurnActionPromise[]) => finishTurnActionTurn(list, 'respondent', 1),
]) assert.ok(transform(finished).every((record, index) => record === finished[index]));
validateTurnActionPromises(finished, seats);
});
void test('frozen known and unknown facts retain whole-answer constants and original payment scope', () => {
for (const answer of [true, false]) {
for (const factAnswer of ['yes', 'no', 'unknown'] as const) {
for (const kind of ['and', 'or'] as const) {
const mixed = group(kind, fact(), pay());
const record = promise(mixed, answer);
record.compiled = compileMixedActionExpression(mixed, () => factAnswer, answer);
assert.deepEqual(record.mixed, mixed);
assert.equal(turnActionHasPayments(record), true);
if (record.compiled.kind === 'constant') {
assert.equal(turnActionValue(record), record.compiled.value);
assert.deepEqual(branches(record), record.compiled.value === answer ? [[]] : []);
if (record.compiled.value === answer) {
record.fulfilled = true;
validateTurnActionPromises([record], seats);
}
} else assert.deepEqual(branches(record), [[signedPay('recipient', 3, 0, answer)]]);
}
}
}
assert.equal(turnActionHasPayments(promise(travel('move'))), false);
});
void test('original and private turn-action expressions enforce sixteen total leaves and four group levels', () => {
assert.equal(TURN_ACTION_MAX_LEAVES, 16);
assert.equal(TURN_ACTION_MAX_DEPTH, 4);
const sixteen = group('and', ...Array.from({ length: 16 }, () => pay()));
assert.doesNotThrow(() => turnActionValue(promise(sixteen)));
const nested = (depth: number): Expression => depth === 0 ? pay() : group('or', travel('move'), nested(depth - 1));
assert.doesNotThrow(() => turnActionValue(promise(nested(4))));
for (const mixed of [nested(5), group('or', ...Array.from({ length: 17 }, () => pay())),
group('and', group('or', ...Array.from({ length: 9 }, () => pay())),
group('or', ...Array.from({ length: 8 }, () => travel('move'))))])
assert.throws(() => turnActionValue(promise(mixed)), TurnActionError);
const manyFacts: Expression = { kind: 'fact', fact: { kind: 'and', terms: Array.from({ length: 16 }, () =>
({ kind: 'spice', compare: 'gte', value: 5 })) } };
assert.throws(() => turnActionValue(promise(group('and', manyFacts, pay()))), TurnActionError,
'The fact wrapper shares the original logical budget');
const original = promise(pay());
const compiled = { kind: 'and', terms: Array.from({ length: 17 }, () => original.compiled) };
assert.throws(() => turnActionValue({ ...original, compiled } as TurnActionPromise), TurnActionError);
const nestedPrivate = (depth: number): TurnActionPromise['compiled'] => depth === 0 ? original.compiled :
{ kind: 'or', terms: [{ kind: 'constant', value: true }, nestedPrivate(depth - 1)] };
assert.throws(() => turnActionValue({ ...original, compiled: nestedPrivate(5) }), TurnActionError,
'A decisive private constant cannot skip an over-depth later clause');
});
void test('safe payment accumulation is immutable and rejects overflow even in a nonselected alternative', () => {
const original = promise(group('or', pay('recipient', Number.MAX_SAFE_INTEGER), travel('move')));
const partial = recordTurnActionPayment([original], 'respondent', 1,
payment('recipient', Number.MAX_SAFE_INTEGER - 1));
const before = structuredClone(partial);
assert.throws(() => recordTurnActionPayment(partial, 'respondent', 1, payment('recipient', 2)), TurnActionError);
assert.deepEqual(partial, before);
const ordinary = promise(group('and', pay('recipient', 5), travel('move')), true, 'turn-action:2');
const list = [ordinary, partial[0]];
assert.throws(() => recordTurnActionPayment(list, 'respondent', 1, payment('recipient', 2)), TurnActionError);
assert.deepEqual(ordinary.payments, []);
const complete = recordTurnActionPayment(partial, 'respondent', 1, payment('recipient', 1));
assert.equal(complete[0].fulfilled, true);
assert.deepEqual(complete[0].payments, [payment('recipient', Number.MAX_SAFE_INTEGER)]);
});
void test('stored rows reject missing or extra metadata, foreign private leaves and malformed native receipts', () => {
const original = promise(group('and', travel('ship'), travel('move'), pay()));
validateTurnActionPromises([original], seats);
const corruptions: unknown[] = [
{ ...original, turn: 0 }, { ...original, event: '' }, { ...original, player: ' respondent' },
{ ...original, order: 1 }, { ...original, answer: 'yes' }, { ...original, fulfilled: undefined },
{ ...original, paymentClosed: undefined }, { ...original, transportClosed: 'yes' },
{ ...original, fulfilled: true, released: true },
{ ...original, shipment: { pending: true, event: event() } },
{ ...original, shipment: { pending: false, event: { ...event(), elite: 1 } } },
{ ...original, shipment: { pending: true, event: null }, transportClosed: true },
{ ...original, progress: [{ territory: 'carthag', maximum: 21 }] },
{ ...original, progress: [{ territory: 'arrakeen', maximum: 3 }] },
{ ...original, progress: [{ territory: 'carthag', maximum: 3, amount: 3 }] },
{ ...original, progress: [{ territory: 'carthag', maximum: 1 }, { territory: 'carthag', maximum: 2 }] },
{ ...original, payments: [payment('other')] },
{ ...original, payments: [payment(), payment()] },
{ ...original, payments: [payment('recipient', Number.MAX_SAFE_INTEGER + 1)] },
{ ...original, payments: [{ ...payment(), received: true }] },
{ ...original, compiled: { kind: 'constant', value: 'unknown' } },
{ ...original, compiled: { kind: 'constant', value: true, answer: true } },
{ ...original, compiled: { kind: 'action', action: { kind: 'payment', recipient: 'other', minimum: 3 } } },
{ ...original, compiled: { kind: 'action', action: { kind: 'move', territory: 'carthag', minimum: 4 } } },
{ ...original, compiled: { kind: 'and', terms: [] } },
{ ...original, mixed: { kind: 'constant', value: true } },
{ ...original, mixed: { kind: 'action', action: { kind: 'payment', recipient: 'recipient', minimum: 3, progress: 0 } } },
{ ...original, mixed: fact() },
];
for (const field of Object.keys(original)) {
const missing: Record<string, unknown> = { ...original };
delete missing[field];
corruptions.push(missing);
}
for (const corrupted of corruptions)
assert.throws(() => turnActionValue(corrupted as TurnActionPromise), TurnActionError);
for (const corrupted of [{ ...original, asker: 'respondent' }, { ...original, player: 'missing' },
{ ...original, fulfilled: true }, { ...original, mixed: pay('missing'), compiled: promise(pay('missing')).compiled },
{ ...original, mixed: pay('respondent'), compiled: promise(pay('respondent')).compiled },
{ ...original, answer: false, shipment: { pending: false, event: event() },
progress: [{ territory: 'carthag', maximum: 3 }], payments: [payment()] },
{ ...original, answer: false, fulfilled: true }])
assert.throws(() => validateTurnActionPromises([corrupted], seats), TurnActionError);
assert.throws(() => validateTurnActionPromises([original, original], seats), TurnActionError);
for (const invalid of [{ ...event(), amount: 21 }, { ...event(), amount: -1 },
{ ...event(), amount: 2.5 }, { ...event(), amount: '3' },
{ ...event(), territory: 'missing' }, { ...event(), elite: 1 }]) {
assert.throws(() => recordTurnActionMovement([original], 'respondent', 1, invalid as never), TurnActionError);
assert.throws(() => recordTurnActionShipment([original], 'respondent', 1, invalid as never), TurnActionError);
}
for (const invalid of [{ ...payment(), amount: 0 }, { ...payment(), amount: -1 },
{ ...payment(), amount: 1.5 }, { ...payment(), amount: Number.MAX_SAFE_INTEGER + 1 },
{ ...payment(), recipient: '' }, { ...payment(), received: true }])
assert.throws(() => recordTurnActionPayment([original], 'respondent', 1, invalid as never), TurnActionError);
});