diff --git a/components/game-table.tsx b/components/game-table.tsx index 3e941f54..af19b64f 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1,4 +1,5 @@ 'use client'; +import { TravelPromises } from './travel-promises'; import { MixedTraitorPromises } from './mixed-traitor-promises'; import { MixedBattlePromises } from './mixed-battle-promises'; import { TraitorDecisionPromises } from './traitor-decision-promises'; @@ -2064,6 +2065,7 @@ export function GameTable({ {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } + {g.status === 'playing' && } {g.guildBetrayalReaction ? ( ) : g.richeseBetrayalReaction ? ( diff --git a/components/mixed-action-fields.tsx b/components/mixed-action-fields.tsx index 835cc4ec..f6439556 100644 --- a/components/mixed-action-fields.tsx +++ b/components/mixed-action-fields.tsx @@ -19,6 +19,7 @@ export type MixedActionConfig = { maxDepth: number; maxNodes?: number; maxTerms?: number; + requireFact?: boolean; newAction: () => A; parseAction: (value: unknown, game: GameView, target: string) => A; renderAction: (props: { value: A; id: string; game: GameView; target: string; @@ -57,7 +58,8 @@ export function mixedActionInputError(value: unknown, game: GameView, target: return fact; }, input => config.parseAction(input, game, target), { maxDepth: config.maxDepth, maxLeaves: config.maxLeaves, ...(config.maxNodes === undefined ? {} : { maxNodes: config.maxNodes }), - ...(config.maxTerms === undefined ? {} : { maxTerms: config.maxTerms }) }); + ...(config.maxTerms === undefined ? {} : { maxTerms: config.maxTerms }), + ...(config.requireFact === undefined ? {} : { requireFact: config.requireFact }) }); return null; } catch (error) { return error instanceof Error ? error.message : config.fallbackError; diff --git a/components/travel-claim-fields.tsx b/components/travel-claim-fields.tsx new file mode 100644 index 00000000..67439cae --- /dev/null +++ b/components/travel-claim-fields.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { TERRITORIES } from '@/game/board'; +import type { GameView } from '@/game/engine'; +import type { MixedActionExpression } from '@/game/mixed-action-question'; +import { parseTravelActionClaim, TRAVEL_MAX_DEPTH, TRAVEL_MAX_LEAVES, type TravelActionClaim } from '@/game/travel-expression-promises'; +import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields'; +import { Input } from './ui/input'; + +type TravelExpression = MixedActionExpression; +const config: MixedActionConfig = { + prefix: 'travel', + legend: 'Future shipment / ground-movement claim, with optional current facts', + actionLabel: 'shipment or ground-movement condition', + actionTiming: 'Future shipment or ground move · this turn', + actionHelp: <>Shipment conditions concern your single future original reserve shipment after the answer. + Ground-movement conditions concern one actual future ground move before your Movement turn ends this round. + Count physical forces in one action; smaller moves never add together. Earlier actions do not count. + OR keeps legal shipment and movement alternatives available. No makes the whole expression false. + Original costs, movement rights, cards and preparation still apply; this claim grants no extra shipment or movement., + fallbackError: 'Choose a future shipment or ground-movement condition, with optional current facts.', + maxLeaves: TRAVEL_MAX_LEAVES, + maxDepth: TRAVEL_MAX_DEPTH, + requireFact: false, + newAction: () => ({ kind: 'ship', territory: 'carthag', minimum: 1 }), + parseAction: parseTravelActionClaim, + renderAction: ({ value, id, disabled, onChange }) => <> + + + + + + 20} + onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} /> + , +}; + +/** Only public printed choices belong here; native eligibility stays on the server. */ +export function travelClaimInputError(value: unknown, game: GameView, target: string): string | null { + return mixedActionInputError(value, game, target, config); +} + +export function TravelClaimFields(props: { + value: TravelExpression; + game: GameView; + target: string; + disabled: boolean; + onChange: (expression: TravelExpression) => void; +}) { + return MixedActionFields({ ...props, config }); +} diff --git a/components/travel-promises.tsx b/components/travel-promises.tsx new file mode 100644 index 00000000..ebc880a4 --- /dev/null +++ b/components/travel-promises.tsx @@ -0,0 +1,109 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +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 TravelProps = { game: GameView; busy: boolean; act: (action: Action) => void }; + +/** Forward the exact current native packet without choosing a branch or route. */ +export function TravelPromiseGuideButton(props: TravelProps) { + const game = props.game, guide = game.travelPromiseGuide; + 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); + const canAct = () => { + const current = props.game, me = current.players.find(player => player.id === actor); + const paidBox = action.type === 'decision' && current.decision?.kind === 'nullentropy' && + current.decision.player === actor && !!current.nullentropy?.search && + current.nullentropy.search.event === action.event && !current.nullentropy.blocked; + const opportunity = paidBox || action.type === 'guildShip' && action.from === 'reserves' || + ['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type); + return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' && + phase === 5 && current.phase === phase && current.turn === turn && current.me === actor && current.active === actor && + current.travelPromiseGuide === guide && guide.event === event && guide.action === action && + action.travelPromiseEvent === event && JSON.stringify(action) === signature && opportunity && + current.travelPromises.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) && + !current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; + }; + const amount = action.forces && typeof action.forces === 'object' + ? Object.values(action.forces as Record).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 sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : ''; + const label = 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 honoring your whole answer' + : action.type === 'decision' ? 'Complete your paid Box search for your whole answer' + : action.type === 'prepareAdvisors' ? `Prepare advisors in ${destination} for your whole answer` + : action.type === 'revealNoField' ? 'Reveal your No-Field to prepare physical forces for your whole answer' + : 'Use the offered native card preparation for your whole answer'; + return ; +} + +/** Retain own raw history, never a compiled branch or individual factual answer. */ +export function TravelPromises(props: TravelProps) { + const game = props.game, own = game.travelPromises.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
+

Your shipment and movement promises

+
    {own.map(promise =>
  • + Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'travel', 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 your Movement turn ends this round.'} +
  • )}
+

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

+

Shipment conditions concern one future original reserve shipment after the answer. + Ground-movement conditions count the maximum physical forces in one actual future move, never the sum of smaller moves. + Earlier actions do not count. A shipment can prepare a move; each action counts only for its own conditions. + Original shipment rights, movement opportunities, cards, preparation and costs still apply. + This claim grants no extra shipment or movement.

+ {live && game.active !== game.me &&

Your Movement turn is still ahead. Keep the needed forces, spice and cards available; + native controls appear on your turn.

} + {live && } +
; +} + +/** One whole answer, using only the current target's offered native choices. */ +export function TravelAnswers(props: TravelProps) { + const game = props.game, window = game.truthtrance, question = window?.question; + if (window?.stage !== 'answer' || question?.kind !== 'travel' || question.target !== game.me || !game.truthTravelAnswers) return null; + const actor = game.me, turn = game.turn, phase = game.phase, source = window.queue[0], answers = game.truthTravelAnswers; + 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' && phase === 5 && 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.truthTravelAnswers === answers && answers.includes(answer) && + !!me && !me.autopilot && !current.response && !current.decision && !current.phaseOpening && + !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; + }; + return
+

Answer the whole shipment and ground-movement expression. Current facts freeze now. + OR preserves legal alternatives; No keeps the whole expression false. + Only a future original reserve shipment and actual future ground moves count, with physical forces in one action. + Original rights, preparation and costs still apply. Only your whole answer is shared.

+ {answers.map(answer => { + const action: Action = { type: 'truthAnswer', answer }; + return ; + })} +
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index f28723b8..ea65196a 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,5 +1,9 @@ 'use client'; +import { TravelClaimFields, travelClaimInputError } from './travel-claim-fields'; +import { TravelAnswers } from './travel-promises'; +import type { TravelActionClaim } from '@/game/travel-expression-promises'; + import { MixedTraitorFields, mixedTraitorInputError } from './mixed-traitor-fields'; import type { TraitorActionClaim } from '@/game/mixed-traitor-question'; @@ -89,7 +93,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' + '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'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); @@ -166,6 +170,11 @@ export function Truthtrance({ { kind: 'action', action: { call: true } }, ] }); const mixedTraitorError = mixedTraitorInputError(mixedTraitor, g, target); + const [travel, setTravel] = useState>({ kind: 'and', terms: [ + { kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 1 } }, + { kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 1 } }, + ] }); + const travelError = travelClaimInputError(travel, g, target); const [shipment, setShipment] = useState({ territory: 'carthag', minimum: 6 }); const [mixed, setMixed] = useState({ kind: 'and', @@ -202,6 +211,7 @@ export function Truthtrance({ claim: planClaim, } : kind === 'mixedBattlePlan' ? { kind, target, battle: g.battle?.event ?? null, territory: g.battle?.territory ?? '', mixed: mixedBattle } + : 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 ?? '' } : kind === 'mixedMovement' ? { kind, target, turn: g.turn, mixed: mixedMovement } @@ -431,6 +441,8 @@ export function Truthtrance({
{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
))} + ) : w.question!.kind === 'travel' ? ( + ) : w.question!.kind === 'traitorCall' || w.question!.kind === 'mixedTraitorCall' ? ( ) : w.question!.kind === 'battlePlan' ? ( @@ -557,6 +569,7 @@ export function Truthtrance({ + @@ -572,7 +585,15 @@ export function Truthtrance({ )} - {kind === 'mixedTraitorCall' ? ( + {kind === 'travel' ? ( + <> + + {!g.truthTravelTargets.includes(target) &&

Choose an active or unused native Shipment and Movement opportunity in this phase.

} +

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

+ + ) : kind === 'mixedTraitorCall' ? ( + <> @@ -812,6 +833,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 === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) || (kind === 'mixedTraitorCall' && (!!mixedTraitorError || !g.truthMixedTraitorTargets.includes(target))) || (kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) || (kind === 'mixedShipment' && diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 2a7bac66..f16c0831 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,46 @@ open mode gates. ## Current checkpoint and work +**Truthtrance / combined shipment and physical movement — 26 October. Prototyped / Partial.** +One whole AND/OR answer now combines the future original reserve shipment and +physical ground groups, optionally with frozen current facts. Distinct receipts +and maximum-per-destination movement retain native costs/pieces/entitlements; +No negates the whole tree and lasts through actual own turn end. Private lazy +signed alternatives reuse the original joint preparation/completion search; +no chosen route enters real native ledgers. Accepted-order prefixes connect +new travel, shipment and scalar/whole/mixed movement answers. Full editor, +raw Ask/private answers, persistent own history, exact guarded native guide +and four minimal profiles connect. Scope is current phase5 active/unused waiting +native turns, with original optional-module and mode gates retained. + +Root integration with disjoint helper/controls/native lanes passes **24 files, +237/237**, types/lint/build and **six native paths**,21 GameTable SSR renders/ +six actual answer callbacks/six guide callbacks plus one editor callback followed +by native Ask. Original active19 adds7 for paid shipment2 plus real move/own end; +edited OR19 adds6 for a manual move without shipping. Both No-to-AND routes19 +add6 and settle only at own end. Frozen Hajr/spice facts19 add10 for real bribe3, +shipment2, nonmatching move, actual Hajr and required second move. Waiting19 +adds8 while retaining the other owner's real turn before ship/move. All33 +original Treachery identities remain physical; four profiles really complete. + +Ten helper, nine controls and18 native cases supplement existing shipment/ +movement/preparation/funding suites. Nine genuine H/A/E two-Truth cases use the +original Harkonnen two-card opening and27-action prefix (bidding20 + continuation7), +covering both acceptance orders across three native ledger kinds, whole No and +incompatible second-answer rejection without erasing earlier history/receipts. +Focused feedback repairs native receipt shape, bot lookup and test assumptions; +v1 JSX andv2 generic narrowing repairs precede passing v3. Bounded review then +finds missing Guild-allied Fremen reserve transport: native variant/tariff/guide +repairs pass three conserved controlled cases and enumeration/guide checks. +Complete v4 passes. Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-travel-proof-v4/`; +journeys/HTML: adjacent `20261026-truth-travel-runtime/`. +No saved-game/database/server/reset/restart/deployment operation is performed. +Earlier phases, other cross-action families/correlated unknowns, opposing-release/ +optional/faction/preparation compositions and final rules/browser/deployed/ +assurance remain. No complete mode is certified. +[Authority, event semantics and native branch/order evidence](TRUTHTRANCE_TRAVEL_PROMISES.md). + **Truthtrance / current facts and original Traitor declarations — 26 October. Prototyped / Partial.** Full mixed AND/OR questions freeze current facts and reduce Reveal/Decline leaves over one original native decision. Constants/tautologies bind nothing; a forced diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 34beecbf..a575a650 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,48 @@ # Dune implementation status +## 26 October 2026 — Combined shipment and physical movement commitments + +**Prototyped / Partial.** +One whole AND/OR answer now combines the future original reserve shipment and +physical ground groups, optionally with frozen current facts. Distinct receipts +and maximum-per-destination movement retain native costs/pieces/entitlements; +No negates the whole tree and lasts through actual own turn end. Private lazy +signed alternatives reuse the original joint preparation/completion search; +no chosen route enters real native ledgers. Accepted-order prefixes connect +new travel, shipment and scalar/whole/mixed movement answers. Full editor, +raw Ask/private answers, persistent own history, exact guarded native guide +and four minimal profiles connect. Scope is current phase5 active/unused waiting +native turns, with original optional-module and mode gates retained. + +Root integration with disjoint helper/controls/native lanes passes **24 files, +237/237**, types/lint/build and **six native paths**,21 GameTable SSR renders/ +six actual answer callbacks/six guide callbacks plus one editor callback followed +by native Ask. Original active19 adds7 for paid shipment2 plus real move/own end; +edited OR19 adds6 for a manual move without shipping. Both No-to-AND routes19 +add6 and settle only at own end. Frozen Hajr/spice facts19 add10 for real bribe3, +shipment2, nonmatching move, actual Hajr and required second move. Waiting19 +adds8 while retaining the other owner's real turn before ship/move. All33 +original Treachery identities remain physical; four profiles really complete. + +Ten helper, nine controls and18 native cases supplement existing shipment/ +movement/preparation/funding suites. Nine genuine H/A/E two-Truth cases use the +original Harkonnen two-card opening and27-action prefix (bidding20 + continuation7), +covering both acceptance orders across three native ledger kinds, whole No and +incompatible second-answer rejection without erasing earlier history/receipts. +Focused feedback repairs native receipt shape, bot lookup and test assumptions; +v1 JSX andv2 generic narrowing repairs precede passing v3. Bounded review then +finds missing Guild-allied Fremen reserve transport: native variant/tariff/guide +repairs pass three conserved controlled cases and enumeration/guide checks. +Complete v4 passes. Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-travel-proof-v4/`; +journeys/HTML: adjacent `20261026-truth-travel-runtime/`. +No saved-game/database/server/reset/restart/deployment operation is performed. +Earlier phases, other cross-action families/correlated unknowns, opposing-release/ +optional/faction/preparation compositions and final rules/browser/deployed/ +assurance remain. No complete mode is certified. +[Authority, event semantics and native branch/order evidence](TRUTHTRANCE_TRAVEL_PROMISES.md). + + ## 26 October 2026 — Current facts with original Traitor declarations **Prototyped / Partial.** diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index eef1f45a..969fa432 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Combined shipment and physical movement commitments — 26 October 2026 + +Publisher November2020 FAQp8 permits current-turn AND/OR action commitments; +supplied Advanced physical39 supplies the authorized compliance/release source. +Compose original reserve shipment and physical ground-group events in one whole +answer. A shipment is not a move; movement minima use one actual group, never +summed smaller moves. Facts freeze at answer; whole No uses De Morgan, not +separate No answers. Preserve native costs, preparation and legal alternatives +through a private lazy signed-branch search, without installing a chosen branch. +This first cross-action envelope is current phase5 active/unused waiting native +turns, provisionally applying the existing owned-action witness interpretation. +It is not a printed restriction on questions or a complete arbitrary-promise +solver. [Contract and evidence](TRUTHTRANCE_TRAVEL_PROMISES.md). + ## Current facts combined with original Traitor declarations — 26 October 2026 Compose recorded publisher FAQp8 AND/OR/current-turn and supplied Advanced diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md index 46bd2c55..baae9127 100644 --- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md +++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md @@ -1,5 +1,10 @@ # Truthtrance nonbattle readiness and first implementation slice +The [combined travel follow-up](TRUTHTRANCE_TRAVEL_PROMISES.md) adds full AND/OR +shipment and physical ground-movement conditions in the current native phase5 +interval, optionally with frozen facts. Each event retains its physical meaning; +other cross-action families, earlier timing and broader enforcement remain open. + The [mixed current Traitor-decision follow-up](TRUTHTRANCE_MIXED_TRAITOR.md) combines answer-time facts with current revealed native Reveal/Decline choices. Original eligibility, actual declaration receipts and Cunning protection remain; diff --git a/docs/TRUTHTRANCE_TRAVEL_PROMISES.md b/docs/TRUTHTRANCE_TRAVEL_PROMISES.md new file mode 100644 index 00000000..d8e7653d --- /dev/null +++ b/docs/TRUTHTRANCE_TRAVEL_PROMISES.md @@ -0,0 +1,149 @@ +# Truthtrance combined shipment and physical movement + +## Native travel prototype — 26 October 2026 + +**Prototyped / Partial.** The question is `{kind:'travel',target,turn,mixed}`. +Travel action leaves are `{kind:'ship'|'move',territory,minimum}` inside the shared +fact/action/AND/OR tree. Current facts are optional; at least one action remains +required. Every original subtree is validated under sixteen logical leaves and +four grouping levels. Old mixed families still require a fact by default. + +## Authority and interval + +Reuse publisher November2020 FAQp8 game-related yes/no/current-turn/AND/OR and +shipment example, plus supplied Advanced physical39/printed37 compliance, +impossibility and unknown authority. No dedicated printed cross-action planner +is claimed. The existing native owned-action witness interpretation is a +provisional online implementation choice under the standing14October priority. + +This first cross-action envelope is the current phase5 native active or unused +waiting turn accepted by the existing movement target rules. Printed factions +and selected native expansion decks use that existing scope; optional modules +retain their separate limits. Earlier phases and arbitrary future card/power/ +other-action expressions remain unfinished. Parser rejection is a software +boundary, not a printed rule that those questions cannot be asked. + +Shipment leaves concern the one future original reserve-shipment event after +the answer. A shipment already used before answering is false for those future +leaves. Cross-planet/return transport consumes that original opportunity without +becoming a reserve shipment. A skipped or stopped shipment records absence. +Arrival, original fees, physical reserves and native responses remain unchanged. + +Movement leaves concern one future actual ordinary physical ground group to a +named destination. The largest group per destination is retained, not a sum of +smaller moves. Original Hajr/Ornithopter and owned preparations may supply more +moves through the existing planner; no entitlement is added. Ambassador moves, +markers and shipments do not contribute movement progress. A shipment may +prepare a later movement while satisfying only its own shipment conditions. + +## One whole answer and native feasibility + +Current facts freeze at the actual answer. Unknown is false for a definite Yes +and true for No, retaining the earlier guaranteed-aggregate interpretation. +Unknown-fact correlations are not inferred. No negates the whole tree, not each +leaf. A true current fact OR action, or false current fact AND action, can make +the selected answer terminal without a future commitment. + +`game/travel-expression-promises.ts` lazily reduces the whole compiled answer +to signed branches, folding actual progress and closed opportunities. Every +shipment leaf shares one event. Native min/cap contradictions are pruned; +alternatives remain available rather than binding one route. Private detached +witnesses translate a branch into existing shipment and movement constraints, +then call the original ground-movement completion search. The real ledgers never +receive that selected branch. Quotes retain original force types, wallets, +movement allowance, routes, owned card preparation and prior native promises. + +Actual shipment/ground hooks record receipts; a qualifying whole Yes fulfills +immediately. Whole No remains binding through the respondent's actual turn end. +Ending cannot erase a required action. Spending or consuming the last owned +route is rejected. An opposing completed effect can release a now-impossible +answer; interrupted responses retain original priority. + +At the first combined answer, the private order records existing shipment rows +and the native within-movement sequence before the new row. Subsequent real +scalar, whole/mixed movement, shipment and travel answers append their native +ledger index. Joint feasibility reconciles prefixes in that accepted order; +no virtual branch is made permanent. Historical ordering between different +native families before this cutover is not reconstructed. Wider cross-family +revival/payment/alliance/battle composition remains outside this first envelope. + +### Guild-allied Fremen reserve transport follow-up + +Bounded read-only review found the joint movement preparation search enumerated +ordinary `ship` but omitted native Fremen `guildShip` from southern reserves. +The native generator now retains that distinct reciprocal-Guild-ally variant; +the witness uses the original Guild tariff and funding split, then dispatches +the actual transport validator. It never substitutes Fremen's free ordinary +reinforcement price. The exact private guide/control also accepts that packet. +Three controlled conserved native cases isolate real transport, subsequent +physical movement, pledged Guild funding, later scalar compatibility and +insufficient funds. They claim no historical setup/alliance acquisition. Native +enumeration and exact guide packet checks also cover that variant. The complete +v4 batch passes after this correction; the earlier v3 proof predates it. + +## Connected controls and minimal policies + +The shared full editor switches ship/move leaves, destination and minimum, +adds/wraps/nests/removes/collapses groups, and optionally includes current facts. +It reads public vocabulary only. Original raw preview/Ask, private whole-answer +choices and persistent own aggregate history connect through the existing +Truthtrance window. Raw own rows omit compiled/frozen values and route choices. + +One private exact guide offers the next native action, with original selection +and costs available for review. Identity, actor, turn, phase, event, busy and +native modal guards prevent stale dispatch. Ordinary manual legal alternatives +remain available. Four minimal profiles answer and consume that same guide; +strategic refinement/calibration remain deferred. + +## Evidence and remaining gates + +Helpers: `tests/travel-expression-promises.test.ts`, +`tests/mixed-action-question.test.ts`. Source/runtime: +`tests/fixture-truth-travel.ts`, `tests/truth-travel-runtime.test.ts`. +A separate actual Harkonnen two-card opening supplies both original Truths for +`tests/fixture-truth-travel-order.ts` and `tests/truth-travel-order.test.ts`; +its acceptance-order journeys install no extra card or ledger. +Controls: `tests/truth-travel-controls.test.tsx`. +Frozen v4 passes **24 files,237/237**, types/lint/build and six original runtime +paths,21 GameTable SSR renders, six actual answer callbacks/six guide callbacks +and one editor callback followed by native Ask. Active19 adds7 for paid shipment2 +and a distinct actual move/own end. Edited OR19 adds6 for manual movement without +shipping. Both No-to-AND routes19 add6 and complete only at own end. Frozen Hajr/ +spice facts19 add10 for actual bribe3, shipment2, nonmatching move, physical Hajr +and required second move. Waiting19 adds8 while preserving the original other +owner's turn before the respondent's actual ship/move. Four minimal profiles +actually answer and perform the same native guide. All33 original cards remain. + +Ten helper, nine controls and18 native cases supplement existing shipment, +movement and funding coverage. Nine original two-Truth cases use genuine Harkonnen +opening custody and27 native actions (bidding20 plus continuation7), covering both +orders across scalar shipment, scalar movement and whole movement, compatible +whole No and conflicting second-answer rejection while retaining earlier records. +No opposing native release journey is claimed from those sources. + +Focused feedback repairs the native shipment event projection, a bot actor lookup +and test assumptions about undrawn cards and redundant valid branches. Frozen +v1 catches JSX composition; v2 catches generic array narrowing. Those are repaired +before the passing v3. Bounded read-only review then identifies the missing +Guild-allied Fremen preparation route described above; native generator, exact +Guild price and guide repairs precede the complete passing v4. Extra evidence: +`tests/movement-shipment-preparation.test.ts` and +`tests/truth-travel-guild-fremen.test.ts`. Proof and per-file manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-travel-proof-v4/`; +journeys/HTML: adjacent `20261026-truth-travel-runtime/`. +Evidence is local native engine/SSR/actual callbacks, not DOM, hydration, browser, +mobile or deployed acceptance, and certifies no complete mode. + +The original Basic base33 source retains its actual selected unopened deal, +Storm/Spice/Charity/Bidding/Revival prefix. Active selects the recorded prefix +immediately before the paid shipment; waiting retains the current owner and +original movement queue. Only one Truthtrance is actually dealt in that source; +the other original physical copy remains undrawn. No extra hand, wallet, force, +clock, promise, answer or event is installed. Controlled helper/view cases are +not represented as historical acquisition evidence. + +Earlier phases, other cross-action families, unknown-fact correlations, native +optional-module/expansion/owned-preparation compositions and full rules/recovery/ +privacy/custody/browser/mobile/deployed acceptance remain required. No complete +mode is certified. No database, saved-game, server, reset, restart or deployment +operation belongs to this prototype batch. diff --git a/game/bots.ts b/game/bots.ts index 30997ed1..baa27f94 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1374,6 +1374,10 @@ function policyActions(g: GameView): Action[] { const answer = g.truthBidAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } + if (w.question.kind === 'travel') { + const answer = g.truthTravelAnswers?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'mixedTraitorCall') { const answer = g.truthMixedTraitorAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; @@ -4259,6 +4263,9 @@ export function botActions(g: GameView): Action[] { if (g.movementExpressionPromiseGuide && g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) return [g.movementExpressionPromiseGuide.action]; + if (g.travelPromiseGuide && g.status === 'playing' && g.phase === 5 && g.active === g.me && + !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) + return [g.travelPromiseGuide.action]; if (g.movementPromisePolicy && g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) return g.movementPromisePolicy; diff --git a/game/engine.ts b/game/engine.ts index 1102cc86..6baea348 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,5 @@ +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'; import type { MixedBattleActionClaim, MixedBattleClaim } from './mixed-battle-question'; import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans, @@ -2162,6 +2164,9 @@ export type Game = { }; truthtrance?: TruthWindow | null; truthHistory?: TruthRecord[]; + travelPromises?: TravelPromise[]; + /** Accepted order shared by phase-five travel, shipment and movement answers. */ + travelPromiseOrder?: { kind: 'shipment' | 'movement' | 'movementExpression' | 'travel'; index: number }[]; mixedTraitorQuestions?: (MixedTraitorClaim & { event: string; turn: number; player: string; asker: string; answer: boolean; binding: boolean; fulfilled: boolean; released: boolean; @@ -6942,6 +6947,8 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { battle: g.battle, shipmentPromises: g.shipmentPromises, movementPromises: g.movementPromises, + ...(g.travelPromiseOrder ? { travelPromiseOrder: g.travelPromiseOrder, travelPromises: g.travelPromises } : {}), + ...(next.record.question.kind === 'travel' ? { movementExpressionPromises: g.movementExpressionPromises } : {}), ...(next.record.question.kind === 'mixedTraitorCall' ? { mixedTraitorQuestions: g.mixedTraitorQuestions } : {}), ...(next.record.question.kind === 'mixedBattlePlan' ? { mixedBattleQuestions: g.mixedBattleQuestions } : {}), ...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? { @@ -18649,7 +18656,141 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se throw error; } } +function travelRule(operation: () => T): T { + try { return operation(); } + catch (error) { if (error instanceof TravelPromiseError) throw new RuleError(error.message); throw error; } +} +function travelTargetAvailable(g: Game, actor: string): boolean { + return g.phase === 5 && movementPromiseTargetAvailable(g, actor); +} +function appendTravelOrder(g: Game, kind: NonNullable[number]['kind'], index: number) { + g.travelPromiseOrder?.push({ kind, index }); +} +function initializeTravelOrder(g: Game) { + if (g.travelPromiseOrder) return; + const movement = [ + ...(g.movementPromises ?? []).map((promise, index) => ({ kind: 'movement' as const, index, event: promise.event })), + ...(g.movementExpressionPromises ?? []).map((promise, index) => ({ kind: 'movementExpression' as const, index, event: promise.event })), + ]; + const sequence = g.movementPromiseSequence ?? []; + const order = (event: string | undefined) => event === undefined ? -1 : sequence.indexOf(event); + movement.sort((a, b) => order(a.event) - order(b.event)); + g.travelPromiseOrder = [ + ...(g.shipmentPromises ?? []).map((_, index) => ({ kind: 'shipment' as const, index })), + ...movement.map(({ kind, index }) => ({ kind, index })), + ...(g.travelPromises ?? []).map((_, index) => ({ kind: 'travel' as const, index })), + ]; +} +/** Only detached witness states receive signed native constraints. No selected + * DNF route is installed into the real shipment or movement ledgers. */ +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 { + const travel = liveTravelPromises(source.travelPromises ?? [], actor, source.turn); + const search = (index: number, leaves: TravelBranchLeaf[]): MovementCompletion | null => { + if (index < travel.length) { + for (const branch of travelBranches(travel[index])) { + const result = search(index + 1, [...leaves, ...branch]); + if (result) return result; + } + return null; + } + const trial = { ...source, travelPromises: undefined, travelPromiseOrder: undefined, + shipmentPromises: [...shipments], movementPromises: [...movements], movementExpressionPromises: [...expressions] }; + for (const leaf of leaves) { + const promise = { territory: leaf.territory, minimum: leaf.minimum, turn: source.turn, + player: actor, asker: source.players.find(player => player.id !== actor)!.id, answer: leaf.answer }; + if (leaf.kind === 'ship') trial.shipmentPromises.push(promise); + else trial.movementPromises.push(promise); + } + return groundMovementCompletionNative(trial, actor, trial.movementPromises, trial.movementExpressionPromises); + }; + return travelRule(() => search(0, [])); +} +function travelTruthAnswers(g: Game, p: Player, claim: TravelClaim): TruthAnswer[] { + if (claim.turn !== g.turn || !travelTargetAvailable(g, p.id)) return []; + const answers: TruthAnswer[] = []; + for (const answer of [true, false]) { + const compiled = compileMixedActionExpression(claim.mixed, + fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer); + if (compiled.kind === 'constant' && compiled.value !== answer) continue; + const promise: TravelPromise = { ...structuredClone(claim), event: 'candidate', player: p.id, + asker: g.players.find(player => player.id !== p.id)!.id, answer, compiled, + shipment: { pending: shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false, + fulfilled: compiled.kind === 'constant', released: false }; + const trial = { ...g, travelPromises: [...(g.travelPromises ?? []), promise] }; + if (travelCompletion(trial, p.id) && bribeCompatibleLaterPromises(trial, p, {})) answers.push(answer ? 'yes' : 'no'); + } + return answers.length ? answers : ['unknown']; +} +function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean, asker: string) { + requireRule(travelTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), + 'Choose a whole travel answer compatible with the original shipment and ground movements.'); + const compiled = compileMixedActionExpression(claim.mixed, + fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer); + initializeTravelOrder(g); + (g.travelPromises ??= []).push({ ...structuredClone(claim), event: crypto.randomUUID(), player: p.id, asker, answer, + compiled, shipment: { pending: shipmentAvailable(g, p), event: null }, progress: [], movementClosed: false, + fulfilled: compiled.kind === 'constant', released: false }); + appendTravelOrder(g, 'travel', g.travelPromises!.length - 1); +} +function reconcileTravelPromises(g: Game, actor?: string, action?: Action) { + 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 && + (promise.turn !== g.turn || g.phase > 5 || g.status !== 'playing')) promise.released = true; + if (g.phase !== 5) return; + initializeTravelOrder(g); + for (const p of g.players) { + if (!liveTravelPromises(g.travelPromises, p.id, g.turn).length) continue; + const voluntary = actor === p.id && action?.type !== 'passResponse'; + if (g.truthtrance || g.phaseOpening || g.pendingFremenMove?.order.player === p.id || + g.pendingIxMove?.player === p.id || g.pendingChoamMove?.player === p.id || + g.pendingAdvisorMatching?.intent.kind === 'movement' && g.pendingAdvisorMatching.intent.order.player === p.id || + g.response?.kind === 'advisorFlip' && g.response.owner === p.id || + g.pendingAutomaticAdvisorFlips?.some(source => source.owner === p.id || + source.producer === 'groundMovement' && source.mover === p.id)) continue; + if (!voluntary && (g.response || g.decision || g.pendingAmbassador || g.pendingTerrorEntry || + g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || g.pendingTreacheryDiscard)) continue; + const shipments: ShipmentPromise[] = [], movements: MovementPromise[] = [], expressions: MovementExpressionPromise[] = [], travel: TravelPromise[] = []; + for (const entry of g.travelPromiseOrder!) { + const ledger = entry.kind === 'shipment' ? g.shipmentPromises : entry.kind === 'movement' ? g.movementPromises + : entry.kind === 'movementExpression' ? g.movementExpressionPromises : g.travelPromises; + const promise = ledger?.[entry.index]; + if (!promise || promise.player !== p.id || promise.turn !== g.turn || promise.fulfilled || promise.released) continue; + if (entry.kind === 'shipment') shipments.push(promise as ShipmentPromise); + else if (entry.kind === 'movement') movements.push(promise as MovementPromise); + else if (entry.kind === 'movementExpression') expressions.push(promise as MovementExpressionPromise); + else travel.push(promise as TravelPromise); + const trial = { ...g, shipmentPromises: shipments, movementPromises: movements, + movementExpressionPromises: expressions, travelPromises: travel }; + if (travelCompletion(trial, p.id)) continue; + requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted travel commitment.'); + promise.released = true; + log(g, `${p.name} can no longer fulfill an accepted Truthtrance commitment; that answer is no longer binding.`); + } + } +} +function projectedTravelPromises(g: Game, actor: string) { + return (g.travelPromises ?? []).filter(promise => promise.player === actor).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 projectedTravelGuide(g: Game, player: Player): { event: string; action: Action } | null { + const promise = liveTravelPromises(g.travelPromises ?? [], player.id, g.turn)[0]; + const policy = promise && g.phase === 5 && g.active === player.id ? projectedGroundMovementPolicy(g, player) : null; + const action = policy?.[0]; + return promise && action ? { event: promise.event, action: { ...structuredClone(action), travelPromiseEvent: promise.event } } : null; +} function groundMovementCompletion(source: Game, actor: string, + promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn), + expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn)): MovementCompletion | null { + return liveTravelPromises(source.travelPromises ?? [], actor, source.turn).length + ? travelCompletion(source, actor, promises, expressions) : groundMovementCompletionNative(source, actor, promises, expressions); +} +function groundMovementCompletionNative(source: Game, actor: string, promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn), expressions = liveMovementExpressionPromises(source.movementExpressionPromises ?? [], actor, source.turn)): MovementCompletion | null { if (!movementPromiseModeSupported(source) || source.phase < 0 || source.phase > 5) return null; @@ -18728,8 +18869,12 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises: } yield* movementShipmentPreparations(state, owner, (source, actor, action) => { const payer = getPlayer(source, actor), share = Number(action.allyPayment); - const cost = reserveShipmentCost({ faction: payer.faction, halfRate: shipmentHalfRate(source, payer) }, - territory(String(action.territory)).type, Number(action.amount)); + const destinationType = territory(String(action.territory)).type; + const cost = action.type === 'guildShip' + ? guildShipmentCost(destinationType, Number(action.amount), source.karamaShipping?.player === actor || + !(source.guildRateBlocked?.turn === source.turn && source.guildRateBlocked.player === actor)) + : reserveShipmentCost({ faction: payer.faction, halfRate: shipmentHalfRate(source, payer) }, + destinationType, Number(action.amount)); if (share > cost || share > (aidFor(source, payer)?.amount ?? 0) || cost - share > payer.spice) return null; try { return groundMovementProbe(applyActionInner(source, actor, action, 'movementPreparation'), actor); } catch (error) { if (error instanceof RuleError) return null; throw error; } @@ -18789,7 +18934,8 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises: 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); + .some(promise => !positive || promise.answer) || + liveTravelPromises(g.travelPromises ?? [], player, g.turn).some(promise => !positive || promise.answer); } function movementExpressionIntegrity(g: Game) { try { validateMovementExpressionPromises(g.movementExpressionPromises ?? [], g.players.map(player => player.id)); } @@ -18852,6 +18998,7 @@ function bindMovementTruth(g: Game, p: Player, claim: MovementClaim, answer: boo const promise: MovementPromise = { ...claim, event: crypto.randomUUID(), turn: g.turn, player: p.id, asker, answer }; (g.movementPromises ??= []).push(promise); (g.movementPromiseSequence ??= []).push(promise.event!); + appendTravelOrder(g, 'movement', g.movementPromises!.length - 1); } function movementExpressionTruthAnswers(g: Game, p: Player, claim: MovementExpressionClaim): TruthAnswer[] { if (claim.turn !== g.turn || !movementPromiseTargetAvailable(g, p.id)) return []; @@ -18876,6 +19023,7 @@ function bindMovementExpressionTruth(g: Game, p: Player, claim: MovementExpressi player: p.id, asker, answer, progress: [] }; (g.movementExpressionPromises ??= []).push(promise); (g.movementPromiseSequence ??= []).push(promise.event); + appendTravelOrder(g, 'movementExpression', g.movementExpressionPromises!.length - 1); } function compileMixedMovement(g: Game, p: Player, claim: MixedMovementClaim, answer: boolean) { const compiled = compileMixedActionExpression(claim.mixed, @@ -18952,6 +19100,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.travelPromises) projected.travelPromises = travelRule(() => + recordTravelMovement(projected.travelPromises!, p.id, g.turn, event)); projected.movementPromises = settleMovementEvent(g.movementPromises ?? [], p.id, g.turn, event); try { projected.movementExpressionPromises = recordMovementExpressionEvent(g.movementExpressionPromises ?? [], p.id, g.turn, event); } catch (error) { if (error instanceof MovementExpressionPromiseError) throw new RuleError(error.message); throw error; } @@ -18960,6 +19110,8 @@ function checkGroundMovementPromises(g: Game, p: Player, move: MovementOrder) { } function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) { if (move.source === 'ambassador') return; + if (g.travelPromises) g.travelPromises = travelRule(() => + recordTravelMovement(g.travelPromises!, p.id, g.turn, groundMovementEvent(move))); const old = g.movementPromises ?? [], expressions = g.movementExpressionPromises ?? []; if (g.movementPromises) { g.movementPromises = settleMovementEvent(old, p.id, g.turn, groundMovementEvent(move)); @@ -19430,6 +19582,7 @@ 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.travelPromises) g.travelPromises = travelRule(() => finishTravelTurn(g.travelPromises!, id, g.turn)); try { if (g.movementPromises) g.movementPromises = finishMovementPromises(g.movementPromises, p.id, g.turn); for (const promise of liveMovementExpressionPromises(g.movementExpressionPromises ?? [], p.id, g.turn)) { @@ -27204,6 +27357,8 @@ function findShipmentCompletion( state.turn, ), ): ShipmentCompletion | null { + if (liveTravelPromises(state.travelPromises ?? [], owner.id, state.turn).length) + return travelCompletion(state, owner.id, undefined, undefined, promises); if ( !shipmentPromiseTurnAvailable(state, owner.id, shipmentAvailable(state,owner)) ) @@ -27442,6 +27597,7 @@ function bindShipmentTruth( ...(mixed ? {mixed} : {}), answer, }); + appendTravelOrder(g, 'shipment', g.shipmentPromises!.length - 1); } function checkShipmentPromises( g: Game, @@ -27461,6 +27617,8 @@ function finishShipmentPromises( shipment: { territory: string; amount: number } | null, ) { checkShipmentPromises(g, p, shipment); + if (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( g.shipmentPromises ?? [], p.id, @@ -27481,6 +27639,7 @@ 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.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; else { @@ -36157,6 +36316,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.travelPromiseEvent !== undefined) { + const guide = projectedTravelGuide(state, getPlayer(state, id)); + requireRule(guide && movementGuideActionMatches(action, guide.action), + 'This travel guide is stale or belongs to another opportunity.'); + } if (action.movementExpressionPromiseEvent !== undefined) { const guide = projectedMovementExpressionGuide(state, getPlayer(state, id)); requireRule(guide && guide.phase === state.phase && movementGuideActionMatches(action, guide.action), @@ -36187,6 +36351,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { 'This revival commitment guide is stale or belongs to another native opportunity.'); } const g = applyActionInner(state, id, action); + reconcileTravelPromises(g, id, action); reconcileBidPromises(g, { actor: id, action }); reconcileRevivalPromises(g, { actor: id, action }); reconcileBribePromises(g, { actor: id, action }); @@ -36513,6 +36678,7 @@ function settleAutomaticContinuations(g: Game) { if (g.pendingNexusEmperorSellerPurchase || g.pendingAmbassador?.nexusPurchase) return; // Opposing/automatic consequences can release a now-impossible promise; // they are not the original actor voluntarily spending a promised resource. + reconcileTravelPromises(g); reconcileBattlePromises(g); reconcileShipmentPromises(g); reconcileBidPromises(g); @@ -36617,6 +36783,7 @@ export function normalizeAutomaticGame(state: Game): Game { treacheryDiscardIntegrity(g); return g; } + reconcileTravelPromises(g); reconcileBattlePromises(g); reconcileShipmentPromises(g); reconcileBidPromises(g); @@ -36896,6 +37063,8 @@ function applyActionInner( bindMixedBid: bindMixedBidTruth, bidAnswers: bidTruthAnswers, bindBid: bindBidTruth, + travelAnswers: travelTruthAnswers, + bindTravel: bindTravelTruth, mixedTraitorAnswers: mixedTraitorTruthAnswers, bindMixedTraitor: bindMixedTraitorTruth, traitorAnswers: traitorTruthAnswers, @@ -40687,6 +40856,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), + 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, + travelPromises: projectedTravelPromises(g, id), + travelPromiseGuide: projectedTravelGuide(g, me), truthMixedTraitorTargets: g.players.filter(p => { const context = traitorTruthContext(g, p.id); return context && context.submitted === undefined; }).map(p => p.id), truthMixedTraitorAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'mixedTraitorCall' && g.truthtrance.question.target === id ? mixedTraitorTruthAnswers(g, me, g.truthtrance.question) : null, diff --git a/game/mixed-action-question.ts b/game/mixed-action-question.ts index 90d90bac..afe8d57c 100644 --- a/game/mixed-action-question.ts +++ b/game/mixed-action-question.ts @@ -17,6 +17,8 @@ export type MixedActionExpressionLimits = { maxLeaves?: number; maxNodes?: number; maxTerms?: number; + /** Travel expressions may consist entirely of future actions. */ + requireFact?: boolean; }; export class MixedActionQuestionError extends Error {} @@ -65,7 +67,8 @@ export function parseMixedActionExpression
( check(Number.isSafeInteger(maxDepth) && maxDepth >= 0 && Number.isSafeInteger(maxLeaves) && maxLeaves >= 1 && (limits.maxNodes === undefined || Number.isSafeInteger(limits.maxNodes) && limits.maxNodes >= 1) && - (limits.maxTerms === undefined || Number.isSafeInteger(limits.maxTerms) && limits.maxTerms >= 2), + (limits.maxTerms === undefined || Number.isSafeInteger(limits.maxTerms) && limits.maxTerms >= 2) && + (limits.requireFact === undefined || typeof limits.requireFact === 'boolean'), 'Choose valid logical depth and clause limits.'); let leaves = 0; let nodes = 0; @@ -115,8 +118,9 @@ export function parseMixedActionExpression( return { kind: node.kind, terms: node.terms.map(term => parse(term, depth + 1)) }; }; const mixed = parse(value, 0); - check(hasFact && hasAction, - 'A mixed question must include a current fact and a future-action statement.'); + check(hasAction && (limits.requireFact === false || hasFact), limits.requireFact === false + ? 'An action question must include a future-action statement.' + : 'A mixed question must include a current fact and a future-action statement.'); return mixed; } diff --git a/game/movement-shipment-preparation.ts b/game/movement-shipment-preparation.ts index d90bfcde..b91d562b 100644 --- a/game/movement-shipment-preparation.ts +++ b/game/movement-shipment-preparation.ts @@ -2,13 +2,15 @@ import { gameDistance, gameTerritories, splitLocation, territory } from './board import type { Action, Game } from './engine'; import { liveMovementPromises } from './movement-promises'; -/** Ordinary physical reserve shipments, not an AI shortlist. The authoritative +/** Physical reserve shipments, not an AI shortlist. The authoritative * dispatcher supplies cost, funding, route and arrival legality. */ export function* movementShipmentPreparations(game: Game, actor: string, project: (game: Game, actor: string, action: Action) => Game | null, ): Generator<{ game: Game; actions: Action[] }> { const player = game.players.find(player => player.id === actor); if (!player || player.shipped || game.pendingNullentropy) return; + const guildReserve = player.faction === 'fremen' && game.players.some(ally => + ally.faction === 'guild' && ally.id === player.ally && ally.ally === actor); const present = (territory: string) => Object.entries(player.forces) .filter(([key]) => splitLocation(key).territory === territory) .reduce((sum, [, amount]) => sum + amount, 0); @@ -43,6 +45,11 @@ export function* movementShipmentPreparations(game: Game, actor: string, const action: Action = { type: 'ship', territory: destination.id, sector, amount, elite, allyPayment }; const next = project(game, actor, action); if (next) yield { game: next, actions: [action] }; + if (guildReserve) { + const transport: Action = { ...action, type: 'guildShip', from: 'reserves' }; + const transported = project(game, actor, transport); + if (transported) yield { game: transported, actions: [transport] }; + } } } } diff --git a/game/reference.ts b/game/reference.ts index fd374b24..ef465cf5 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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-travel', + title: 'Truthtrance: combined shipment and ground movements', + category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped', + summary: 'Combine future native reserve shipment and physical ground-movement conditions in one whole answer, optionally with current facts.', + steps: [ + 'During Shipment and Movement, choose Combine this turn’s shipment and ground movements and an active or unused waiting native respondent. Full AND/OR groups can include ship, move and optional current-fact clauses. Earlier-phase and other cross-action questions remain unfinished.', + 'Each shipment condition refers to the same one future original reserve shipment after answering. A used or skipped shipment does not count. Moving, cross-planet transport and reserve shipment retain their original distinct meanings and costs.', + 'Each movement minimum requires one actual future physical ground group to that destination. Smaller moves never add together. Original owned Hajr, Ornithopter, card preparation, types, routes and movement allowances stay authoritative.', + 'Yes requires the whole expression; OR preserves legal alternatives. No negates the whole question and stays binding through your actual movement end. A qualifying Yes completes immediately. Original legal actions must leave a possible completion; an opposing completed effect may release impossibility.', + 'Current facts freeze at the actual answer and impose no promise to keep the same holdings. Unknown facts require an aggregate guaranteed without guessing; constant answers need no future action. Separate frozen values and private branch choices are not published.', + 'Review the exact native next step or use another legal manual route. Private own history persists after the interval. Four minimal profiles participate legally; complete modes, broader compositions and final deployed acceptance remain unfinished.', + ], + related: ['card-truthtrance', 'truthtrance-shipment', 'truthtrance-movement-expression', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Private signed branches share the original shipment and movement completion search, recording distinct native physical receipts and accepted-order release.', evidence: ['game/travel-expression-promises.ts', 'game/mixed-action-question.ts', 'game/movement-shipment-preparation.ts', 'game/engine.ts', 'game/truthtrance.ts', 'tests/travel-expression-promises.test.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Full optional-fact editor, raw Ask/private answers, persistent aggregate history and exact guarded native guide.', evidence: ['components/travel-claim-fields.tsx', 'components/travel-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-travel-controls.test.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and consume the native guide; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-travel-runtime.test.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'Single-shipment, physical group maxima, whole No, snapshot, native order and provisional scope are explicit.', evidence: ['docs/TRUTHTRANCE_TRAVEL_PROMISES.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused helper/control/native journeys; broader optional/faction/preparation/cross-action and deployed acceptance remain open.', evidence: ['tests/fixture-truth-travel.ts', 'tests/truth-travel-runtime.test.ts', 'tests/fixture-truth-travel-order.ts', 'tests/truth-travel-order.test.ts', 'tests/truth-travel-guild-fremen.test.ts', 'tests/truth-travel-controls.test.tsx', 'tests/travel-expression-promises.test.ts'] }, + ], + }, { id: 'truthtrance-mixed-traitor', title: 'Truthtrance: current facts and this Traitor declaration', @@ -3793,6 +3815,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'truthtrance-plan-expression', 'truthtrance-shipment', 'truthtrance-movement', + 'truthtrance-travel', 'truthtrance-movement-expression', 'truthtrance-mixed-movement', 'truthtrance-bid', diff --git a/game/travel-expression-promises.ts b/game/travel-expression-promises.ts new file mode 100644 index 00000000..08b5edce --- /dev/null +++ b/game/travel-expression-promises.ts @@ -0,0 +1,311 @@ +import { TERRITORIES } from './board'; +import { + parseMixedActionExpression, + type CompiledMixedAction, + type MixedActionExpression, +} from './mixed-action-question'; +import { MovementClaimError, parseMovementClaim } from './movement-promises'; +import { parseShipmentClaim, ShipmentClaimError } from './shipment-promises'; +import type { TruthFact } from './truthtrance'; + +export const TRAVEL_MAX_DEPTH = 4; +export const TRAVEL_MAX_LEAVES = 16; + +/** Shipment leaves all describe one original future reserve-shipment event. */ +export type TravelActionClaim = { kind: 'ship' | 'move'; territory: string; minimum: number }; +export type TravelClaim = { turn: number; mixed: MixedActionExpression }; +export type TravelEvent = { territory: string; amount: number }; +export type TravelBranchLeaf = TravelActionClaim & { answer: boolean }; +export type TravelPromise = TravelClaim & { + event: string; + player: string; + asker: string; + answer: boolean; + /** Private residual: facts are frozen at this answer, never reevaluated. */ + compiled: CompiledMixedAction; + shipment: { pending: boolean; event: TravelEvent | null }; + /** Largest actual physical ground group per destination after this answer. */ + progress: { territory: string; maximum: number }[]; + movementClosed: boolean; + fulfilled: boolean; + released: boolean; +}; + +export class TravelPromiseError extends Error { + constructor(message: string) { + super(message); + this.name = 'TravelPromiseError'; + } +} + +function check(value: unknown, message: string): asserts value { + if (!value) throw new TravelPromiseError(message); +} +function object(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} +const keys = (value: Record, 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 turnNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 1; +const physical = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 20; +const printedTerritory = (value: unknown): value is string => + typeof value === 'string' && TERRITORIES.some(destination => destination.id === value); + +/** Only original kind/minimum/destination fields cross the question boundary. */ +export function parseTravelActionClaim(value: unknown): TravelActionClaim { + check(object(value) && keys(value, 'kind,minimum,territory') && + (value.kind === 'ship' || value.kind === 'move'), + 'Choose one reserve shipment or physical ground movement, with only its minimum and printed destination.'); + try { + const claim = value.kind === 'ship' ? parseShipmentClaim(value) : parseMovementClaim(value); + // parseShipmentClaim receives a flat leaf above, never its expression form. + return { kind: value.kind, territory: claim.territory!, minimum: claim.minimum! }; + } catch (error) { + if (error instanceof ShipmentClaimError || error instanceof MovementClaimError) + throw new TravelPromiseError(error.message); + throw error; + } +} + +/** Fact source authority remains with Truthtrance's game-aware fact decoder. + * Here the raw tree is retained and bounded, without reading private answers. */ +function storedFact(value: unknown, depth = 0): TruthFact { + check(object(value) && identifier(value.kind) && depth <= TRAVEL_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 <= TRAVEL_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: TravelActionClaim): string { + return `${action.kind}:${action.territory}:${action.minimum}`; +} + +function readCompiled(value: unknown, originals: ReadonlySet): CompiledMixedAction { + let leaves = 0; + const read = (input: unknown, depth: number): CompiledMixedAction => { + check(object(input) && depth <= TRAVEL_MAX_DEPTH, + 'Retain the bounded private answer-time travel expression.'); + if (input.kind === 'and' || input.kind === 'or') { + check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 && + input.terms.length <= TRAVEL_MAX_LEAVES, + 'Retain the private answer-time AND/OR travel groups.'); + return { kind: input.kind, terms: input.terms.map(term => read(term, depth + 1)) }; + } + check(++leaves <= TRAVEL_MAX_LEAVES, 'Retain at most sixteen private travel 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 travel action.'); + const action = parseTravelActionClaim(input.action); + check(originals.has(actionKey(action)), 'A private travel 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: TravelPromise): CompiledMixedAction { + check(object(promise) && turnNumber(promise.turn), 'Retain the travel commitment and its current turn.'); + const mixed = parseMixedActionExpression(promise.mixed, storedFact, parseTravelActionClaim, { requireFact: false }); + const originals = new Set(); + const destinations = new Set(); + const visit = (node: MixedActionExpression): void => { + if (node.kind === 'action') { + originals.add(actionKey(node.action)); + if (node.action.kind === 'move') destinations.add(node.action.territory); + } 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.movementClosed === 'boolean' && + !(promise.fulfilled && promise.released), + 'Retain a definite whole travel 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.movementClosed || !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 seen = new Set(); + for (const entry of promise.progress) { + check(object(entry) && keys(entry, 'maximum,territory') && printedTerritory(entry.territory) && + destinations.has(entry.territory) && physical(entry.maximum) && !seen.has(entry.territory), + 'Retain unique named movement destinations and actual physical group maxima.'); + seen.add(entry.territory); + } + return compiled; +} + +function actionValue(action: TravelActionClaim, promise: TravelPromise): boolean { + 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, promise: TravelPromise): 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)); +} + +/** Current value uses this record's actual future events, never projected sums. */ +export function travelPromiseValue(promise: TravelPromise): boolean { + return evaluate(readPromise(promise), promise); +} + +export function liveTravelPromises(promises: readonly T[], player: string, turn: number): T[] { + check(Array.isArray(promises) && promises.every((promise): boolean => object(promise)) && identifier(player) && turnNumber(turn), + 'Choose the travel respondent and turn.'); + return promises.filter(promise => promise.player === player && promise.turn === turn && + !promise.fulfilled && !promise.released); +} + +function mergeBranches(left: TravelBranchLeaf[], right: TravelBranchLeaf[]): TravelBranchLeaf[] | null { + const merged = left.map(leaf => ({ ...leaf })); + for (const leaf of right) { + const same = merged.find(other => other.kind === leaf.kind && other.territory === leaf.territory && + 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 => other.kind === leaf.kind && other.territory === leaf.territory && other.answer); + const no = merged.find(other => other.kind === leaf.kind && other.territory === leaf.territory && !other.answer); + if (yes && no && yes.minimum >= no.minimum) return null; + // One reserve shipment cannot have two different printed destinations. + 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. A whole No negates the whole tree; no branch is selected + * at answer time. Native quotes still decide physical feasibility and routes. */ +export function* travelBranches(promise: TravelPromise): Generator { + const compiled = readPromise(promise); + if (promise.fulfilled || promise.released) { yield []; return; } + function* alternatives(node: CompiledMixedAction, answer: boolean): Generator { + if (node.kind === 'constant') { if (node.value === answer) yield []; return; } + if (node.kind === 'action') { + const achieved = actionValue(node.action, promise); + const closed = node.action.kind === 'ship' ? !promise.shipment.pending : promise.movementClosed; + if (achieved || closed) { if (achieved === answer) yield []; return; } + yield [{ ...node.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: TravelBranchLeaf[]): Generator { + 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(promise: T, update: T): T { + const satisfied = travelPromiseValue(update); + check(promise.answer || !satisfied, 'This travel action conflicts with your whole Truthtrance travel No answer.'); + return promise.answer && satisfied ? { ...update, fulfilled: true } : update; +} + +/** Call once for the completed ordinary reserve shipment, or its actual skip. + * A later answer starts with pending=false and never receives an older event. */ +export function recordTravelShipment(promises: readonly T[], player: string, turn: number, + event: TravelEvent | null): T[] { + if (event !== null) validateEvent(event); + const live = liveTravelPromises(promises, player, turn); + const updates = new Map(); + for (const promise of live) { + 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); +} + +/** Call only for an actual ordinary physical ground group. Several smaller + * groups never combine into one minimum; markers and shipments do not count. */ +export function recordTravelMovement(promises: readonly T[], player: string, turn: number, + event: TravelEvent): T[] { + validateEvent(event); + const updates = new Map(); + for (const promise of liveTravelPromises(promises, player, turn)) { + readPromise(promise); + if (promise.movementClosed || event.amount === 0) continue; + let named = false; + const visit = (node: MixedActionExpression): void => { + if (node.kind === 'action') named ||= node.action.kind === 'move' && node.action.territory === event.territory; + else if (node.kind !== 'fact') node.terms.forEach(visit); + }; + visit(promise.mixed); + if (!named) 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); +} + +/** Close this actual respondent's travel turn. Ending cannot evade a Yes. */ +export function finishTravelTurn(promises: readonly T[], player: string, turn: number): T[] { + liveTravelPromises(promises, player, turn); + return promises.map(promise => { + if (promise.player !== player || promise.turn !== turn) return promise; + readPromise(promise); + const closed = { ...promise, movementClosed: true, shipment: { ...promise.shipment, pending: false } }; + check(promise.released || travelPromiseValue(closed) === promise.answer, + 'Complete your whole Truthtrance travel Yes answer before ending movement.'); + return { ...closed, fulfilled: !promise.released }; + }); +} + +/** Validate stored structure/progress; game-aware raw fact authority and native + * movement/shipment legality remain the integrating engine's responsibility. */ +export function validateTravelPromises(promises: readonly TravelPromise[], players: readonly string[]): void { + check(Array.isArray(promises) && Array.isArray(players), 'Retain the travel commitment records and participants.'); + const events = new Set(); + for (const promise of promises) { + const compiled = readPromise(promise); + const satisfied = evaluate(compiled, promise); + check(identifier(promise.event) && !events.has(promise.event) && identifier(promise.player) && identifier(promise.asker) && + players.includes(promise.player) && players.includes(promise.asker) && promise.player !== promise.asker && + (promise.released || (promise.answer || !satisfied) && + (!promise.answer || promise.fulfilled === satisfied) && + (!promise.fulfilled || satisfied === promise.answer) && + (!promise.movementClosed || promise.fulfilled)), + 'The travel commitment lost its event, participants or whole-answer progress.'); + events.add(promise.event); + } +} diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 4baf60ad..d43cc4f0 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,4 @@ +import { parseTravelActionClaim, type TravelClaim } from './travel-expression-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'; @@ -78,6 +79,7 @@ export type TruthFact = | { kind: 'and' | 'or'; terms: TruthFact[] }; export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression }; export type TruthQuestion = + | ({ kind: 'travel'; target: string } & TravelClaim) | ({ kind: 'mixedTraitorCall'; target: string } & MixedTraitorClaim) | ({ kind: 'mixedBattlePlan'; target: string } & MixedBattleClaim) | ({ kind: 'allianceExpression'; target: string } & AllianceExpressionClaim) @@ -370,6 +372,15 @@ 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 === 'travel') { + check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && g.status === 'playing' && + g.phase === 5 && v.turn === g.turn, + 'Choose a current Shipment and Movement turn for this combined travel question.'); + const respondent = g.players.find(player => player.id === v.target)!; + return { kind: 'travel', target: v.target as string, turn: g.turn, + mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseTravelActionClaim, + { requireFact: false }) }; + } if (v.kind === 'mixedTraitorCall') { check(Object.keys(v).sort().join(',') === 'battle,kind,mixed,target,territory' && g.status === 'playing' && g.phase === 6 && g.battle?.revealed && typeof v.battle === 'string' && @@ -670,6 +681,8 @@ export function truthQuestionText( return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`; return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`; }; + if (q.kind === '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, action => action.call ? 'you declare the opposing leader a Traitor in this battle' : 'you decline to declare the opposing leader a Traitor in this battle')}?`; @@ -739,6 +752,8 @@ export function resolveTruthAction( bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) => void; bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[]; 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; 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[]; @@ -906,6 +921,9 @@ export function resolveTruthAction( if (w.question.kind === 'bid') check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a bidder with a remaining ordinary opportunity in this lot.'); + 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 === '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.'); @@ -1030,6 +1048,11 @@ export function resolveTruthAction( 'Choose an answer consistent with this lot, your legal bids and earlier answers.'); if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player); } + if (q.kind === 'travel') { + check(effects.travelAnswers && effects.bindTravel && effects.travelAnswers(g, p, q).includes(a.answer), + '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 === '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.'); diff --git a/tests/fixture-truth-travel-order.ts b/tests/fixture-truth-travel-order.ts new file mode 100644 index 00000000..4c110edf --- /dev/null +++ b/tests/fixture-truth-travel-order.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { type Action, type Game } from '../game/engine'; +import type { TruthQuestion } from '../game/truthtrance'; +import { + actTruthBid, advanceTruthBid, createTruthBidFixture, truthBidClean, truthBidInventory, + truthBidPlayer, type TruthBidStep, +} from './fixture-truth-bid'; +import { + settleTruthTravel, truthTravelGroup, truthTravelMove, truthTravelShip, + type TruthTravelExpression, +} from './fixture-truth-travel'; + +export type TruthTravelOrderStep = TruthBidStep; +export const actTruthTravelOrder = actTruthBid; +export const truthTravelOrderClean = truthBidClean; +export const truthTravelOrderInventory = truthBidInventory; +export const truthTravelOrderPlayer = truthBidPlayer; +export const settleTruthTravelOrder = settleTruthTravel; + +/** The existing public H/A/E base33 start genuinely deals both printed + * Truthtrances to Harkonnen. Continue its actual auction/Revival and Harkonnen + * movement ending until Atreides' first unused native turn. Only the inherited + * unopened deal/ordinary lots/Spice selections and scalar lotteries are used. + * No hand, card custody, wallet, army, played clock, promise or receipt is set. */ +export function createTruthTravelOrderFixture() { + const base = createTruthBidFixture({ window: 'waiting' }); + const actions = structuredClone(base.sourceActions), suffixFrom = actions.length; + const game = advanceTruthBid(structuredClone(base.game), state => state.turn === 1 && state.phase === 5 && + state.active === base.opponent && truthBidClean(state), actions); + const asker = base.asker, target = base.opponent, observer = base.target; + assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []); + assert.deepEqual(truthBidPlayer(game, asker).hand.map(card => card.id), base.truths); + assert.equal(base.truths.length, 2); assert.equal(new Set(base.truths).size, 2); + assert.equal(truthBidPlayer(game, target).shipped, false); assert.equal(truthBidPlayer(game, target).moved, 0); + assert.deepEqual(truthBidPlayer(game, target).forces, { 'arrakeen:10': 10 }); + assert.equal(truthBidPlayer(game, target).reserves, 10); + assert.ok(!game.movementRemaining?.includes(asker)); assert.ok(game.movementRemaining?.includes(target)); + assert.equal(game.travelPromises?.length ?? 0, 0); assert.equal(game.shipmentPromises?.length ?? 0, 0); + assert.equal(game.movementPromises?.length ?? 0, 0); assert.equal(game.movementExpressionPromises?.length ?? 0, 0); + assert.deepEqual(truthBidInventory(game), base.originalCards); + const mixed = truthTravelGroup('or', truthTravelShip(), truthTravelMove()); + return { + game, source: structuredClone(game), base, asker, target, observer, truths: structuredClone(base.truths), + setup: base.setup, afterSetup: base.afterSetup, beforeStartingDeal: base.beforeStartingDeal, + originalCards: base.originalCards, originalSpice: base.originalSpice, + dealOrder: base.dealOrder, spiceOrder: base.spiceOrder, + actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length, + continuation: structuredClone(actions.slice(suffixFrom)), + sourceCounts: { inheritedBidding: suffixFrom, nativeContinuation: actions.length - suffixFrom, selected: actions.length }, + mixed, + travelQuestion: { kind: 'travel', target, turn: game.turn, mixed } satisfies TruthQuestion, + shipmentQuestion: { kind: 'shipment', target, territory: 'polar_sink', minimum: 1 } satisfies TruthQuestion, + movementQuestion: { kind: 'movement', target, territory: 'imperial_basin', minimum: 1 } satisfies TruthQuestion, + shipment: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } satisfies Action, + alternativeShipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action, + move: { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 } satisfies Action, + alternativeMove: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action, + end: { type: 'endMovement' } satisfies Action, + sourceDisclosure: `${base.sourceDisclosure} This adapter continues the recorded original auction, normal Revival and ` + + 'Harkonnen endMovement to clean phase5 Atreides. Both native opening Truthtrances remain physically in Harkonnen custody; ' + + 'each later declaration uses a distinct genuine card. All selected action counts come from actual recorded arrays. ' + + 'No native stopping card is present to demonstrate an opponent-caused loss of the final route, so this source proves ' + + 'actual accepted-order binding and native compatibility, without claiming an opponent-release path.', + }; +} +export type TruthTravelOrderFixture = ReturnType; + +export function declareTruthTravelOrder(f: TruthTravelOrderFixture, source = f.game, copy: 0 | 1 = 0, + actions: TruthTravelOrderStep[] = []) { + const before = structuredClone(source), from = actions.length; + let game = actTruthTravelOrder(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 = actTruthTravelOrder(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 askTruthTravelOrder(game: Game, asker: string, question: TruthQuestion, + actions: TruthTravelOrderStep[] = []): Game { + return actTruthTravelOrder(game, asker, { type: 'truthAsk', question: structuredClone(question) }, actions); +} +export function answerTruthTravelOrder(game: Game, target: string, answer: 'yes' | 'no', + actions: TruthTravelOrderStep[] = []): Game { + return actTruthTravelOrder(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthTravelOrder(f: TruthTravelOrderFixture, source: Game, copy: 0 | 1, + question: TruthQuestion, answer: 'yes' | 'no' = 'yes', actions: TruthTravelOrderStep[] = []) { + const declared = declareTruthTravelOrder(f, source, copy, actions); + const asked = askTruthTravelOrder(declared.game, f.asker, question, actions); + const game = answerTruthTravelOrder(asked, f.target, answer, actions); + return { game, declared, asked, actions }; +} +export function truthTravelOrderQuestion(f: TruthTravelOrderFixture, mixed: TruthTravelExpression): TruthQuestion { + return { kind: 'travel', target: f.target, turn: f.game.turn, mixed: structuredClone(mixed) }; +} diff --git a/tests/fixture-truth-travel.ts b/tests/fixture-truth-travel.ts new file mode 100644 index 00000000..2b0120a8 --- /dev/null +++ b/tests/fixture-truth-travel.ts @@ -0,0 +1,169 @@ +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 { TravelActionClaim } from '../game/travel-expression-promises'; +import type { TruthFact } from '../game/truthtrance'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { + actTruthMixedMovement, createTruthMixedMovementFixture, truthMixedMovementClean, + truthMixedMovementInventory, truthMixedMovementPlayer, type TruthMixedMovementStep, +} from './fixture-truth-mixed-movement'; +import { createTruthMovementWaitingFixture } from './fixture-truth-movement-waiting'; + +export type TruthTravelExpression = MixedActionExpression; +export type TruthTravelStep = TruthMixedMovementStep; +export const actTruthTravel = actTruthMixedMovement; +export const truthTravelClean = truthMixedMovementClean; +export const truthTravelInventory = truthMixedMovementInventory; +export const truthTravelPlayer = truthMixedMovementPlayer; +export const truthTravelFact = (fact: TruthFact): TruthTravelExpression => ({ kind: 'fact', fact }); +export const truthTravelShip = (territory = 'polar_sink', minimum = 1): TruthTravelExpression => + ({ kind: 'action', action: { kind: 'ship', territory, minimum } }); +export const truthTravelMove = (territory = 'imperial_basin', minimum = 1): TruthTravelExpression => + ({ kind: 'action', action: { kind: 'move', territory, minimum } }); +export const truthTravelGroup = (kind: 'and' | 'or', ...terms: TruthTravelExpression[]): TruthTravelExpression => + ({ kind, terms }); + +/** One original public Basic base33 start. Active stops immediately before the + * source's paid Polar Sink shipment; shipped retains that one actual action. + * Waiting replays the original unopened deal reversal and native phase prefix. + * Charity is only an explicitly unsupported-window source for a boundary test. + * Every selected count comes from the corresponding recorded action array. */ +export function createTruthTravelFixture(options: { + hajr?: boolean; window?: 'active' | 'waiting' | 'shipped' | 'charity'; mixed?: TruthTravelExpression; +} = {}) { + const window = options.window ?? 'active'; + const activeSource = window === 'waiting' ? null : createTruthMixedMovementFixture({ + hajr: options.hajr, window: window === 'charity' ? 'charity' : 'active', + }); + const waitingSource = window === 'waiting' ? createTruthMovementWaitingFixture({ hajr: options.hajr }) : null; + const original = activeSource ?? waitingSource!; + let game = structuredClone(original.game), actions = structuredClone(original.actions); + let shipmentIndex: number | null = null; + if (window === 'active') { + assert.ok(activeSource); + shipmentIndex = activeSource.actions.findIndex(step => step.actor === activeSource.shipmentStep.actor && + JSON.stringify(step.action) === JSON.stringify(activeSource.shipmentStep.action)); + assert.ok(shipmentIndex >= 0, 'The source records its original paid shipment explicitly'); + game = structuredClone(activeSource.beforeShipment); + actions = structuredClone(activeSource.actions.slice(0, shipmentIndex)); + } + const asker = original.asker, target = original.target; + const shipmentAction: Action = waitingSource ? structuredClone(waitingSource.shipment) : + structuredClone(activeSource!.shipmentStep.action); + const firstMove: Action = waitingSource ? structuredClone(waitingSource.move) : structuredClone(activeSource!.firstMove); + const secondMove: Action = waitingSource + ? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 } + : structuredClone(activeSource!.secondMove); + const shipmentTerritory = String(shipmentAction.territory), movementTerritory = String(firstMove.territory); + const mixed = options.mixed ?? truthTravelGroup('and', truthTravelShip(shipmentTerritory), truthTravelMove(movementTerritory)); + const question = { kind: 'travel' as const, target, turn: game.turn, mixed }; + const hajrCard = original.setup.deck.find(card => card.effect === 'hajr'); assert.ok(hajrCard); + const projectile = original.setup.deck.find(card => card.kind === 'projectile'); assert.ok(projectile); + const hajrFact: TruthFact = { kind: 'hand', name: hajrCard.name }; + const projectileFact: TruthFact = { kind: 'hand', name: projectile.name }; + const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: truthTravelPlayer(game, target).spice }; + assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []); + assert.ok(truthTravelClean(game)); assert.equal(game.pendingShipment ?? null, null); + assert.ok(truthTravelPlayer(game, asker).hand.some(card => card.id === original.truth)); + assert.deepEqual(truthTravelInventory(game), original.originalCards); + assert.equal(game.travelPromises?.length ?? 0, 0); + return { + game, source: structuredClone(game), base: original, activeSource, waitingSource, window, + asker, target, truth: original.truth, card: original.truth, hajr: original.hajr, + setup: original.setup, lobby: original.lobby, beforeStartingDeal: original.beforeStartingDeal, + afterSetup: original.afterSetup, beforeShipment: activeSource?.beforeShipment ?? null, + originalCards: original.originalCards, originalSpice: original.originalSpice, + dealOrder: original.dealOrder, spiceOrder: original.spiceOrder, startingPositions: original.startingPositions, + actions, sourceActions: actions, sourceActionCount: actions.length, sourceCount: actions.length, + sourceCounts: { selected: actions.length, inherited: original.sourceActionCount, shipment: shipmentIndex }, + mixed, question, hajrFact, projectileFact, spiceFact, + shipmentAction, shipmentStep: { actor: target, action: shipmentAction }, + shipmentTerritory, movementTerritory, firstMove, secondMove, sameDestinationMove: { ...firstMove }, + shipmentCost: waitingSource ? waitingSource.shipmentCost : 2, + hajrAction: original.hajr ? { type: 'card', card: original.hajr } satisfies Action : null, + declaration: { type: 'card', card: original.truth } satisfies Action, + questionAction: { type: 'truthAsk', question } satisfies Action, + currentMove: waitingSource?.currentMove ?? null, + currentEnd: { type: 'endMovement' } satisfies Action, + sourceDisclosure: `${'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' ')} ` + + `This travel adapter selects only the recorded ${window} native prefix and original raw structured question. ` + + 'Its shipment leaves all refer to one future original reserve shipment after answering, and ground leaves refer to actual native ground groups. ' + + 'The source has one actually dealt Truthtrance, so simultaneous prior scalar-plus-travel answers are outside this fixture. ' + + 'No additional physical card, dealt hand, purse, force, played clock, promise, receipt or compiled fact is installed.', + }; +} +export type TruthTravelFixture = ReturnType; + +export function declareTruthTravel(f: TruthTravelFixture, source = f.game) { + const before = structuredClone(source), actions: TruthTravelStep[] = []; + let game = actTruthTravel(source, f.asker, f.declaration, actions); + const opened = structuredClone(game); + while (game.truthtrance?.stage === 'priority') { + const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor); + game = actTruthTravel(game, actor.id, { type: 'truthPass' }, actions); + } + assert.equal(game.truthtrance?.stage, 'ask'); + return { game, before, opened, actions, actionCount: actions.length }; +} +export function askTruthTravel(game: Game, asker: string, target: string, mixed: TruthTravelExpression, + actions: TruthTravelStep[] = []): Game { + return actTruthTravel(game, asker, { type: 'truthAsk', question: { + kind: 'travel', target, turn: game.turn, mixed: structuredClone(mixed), + } }, actions); +} +export function answerTruthTravel(game: Game, target: string, answer: 'yes' | 'no', actions: TruthTravelStep[] = []): Game { + return actTruthTravel(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthTravel(f: TruthTravelFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed, source = f.game) { + const declared = declareTruthTravel(f, source), actions = [...declared.actions]; + const asked = askTruthTravel(declared.game, f.asker, f.target, mixed, actions); + const game = answerTruthTravel(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} + +/** The private aggregate exposes the original question and its status only. */ +export function truthTravelPromise(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id) { + const row = viewGame(game, target).travelPromises.find(promise => promise.player === target); + assert.ok(row, 'The respondent retains the raw travel question and aggregate answer'); + return row; +} +export function nextTruthTravel(game: Game): TruthTravelStep | null { + if (game.truthtrance) return null; + for (const player of game.players) { + const guide = viewGame(game, player.id).travelPromiseGuide; + if (guide) return { actor: player.id, action: guide.action }; + } + return nextStrongholdFactionsNativeStep(game); +} +export function advanceTruthTravel(game: Game, until: (state: Game) => boolean, actions: TruthTravelStep[] = []): Game { + for (let count = 0; count < 800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original travel source ended before its requested native boundary'); + const next = nextTruthTravel(game); assert.ok(next, 'Supply the original Truthtrance question or answer'); + game = actTruthTravel(game, next.actor, next.action, actions); + } + throw Error('The original travel source did not reach its finite boundary'); +} +export function settleTruthTravel(game: Game, actions: TruthTravelStep[] = []): Game { + return advanceTruthTravel(game, state => truthTravelClean(state) && !state.pendingShipment, actions); +} +export function completeTruthTravel(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id, + actions: TruthTravelStep[] = []): Game { + for (let count = 0; count < 16; count++) { + const promise = truthTravelPromise(game, target); + if (promise.fulfilled || promise.released) return game; + game = settleTruthTravel(game, actions); + const guide = viewGame(game, target).travelPromiseGuide; assert.ok(guide, 'The accepted travel answer has a native completion'); + game = settleTruthTravel(actTruthTravel(game, target, guide.action, actions), actions); + } + throw Error('The exact original native travel guide did not settle the accepted aggregate'); +} +export function finishTruthTravel(game: Game, target = game.travelPromises?.[0]?.player ?? game.players[0].id, + actions: TruthTravelStep[] = []): Game { + game = settleTruthTravel(game, actions); + assert.equal(game.phase, 5); assert.equal(game.active, target); + game = settleTruthTravel(actTruthTravel(game, target, { type: 'endMovement' }, actions), actions); + assert.ok(!game.movementRemaining?.includes(target), 'Only actual owner endMovement closes this native interval'); + return game; +} diff --git a/tests/mixed-action-question.test.ts b/tests/mixed-action-question.test.ts index acf67df5..71e32dfd 100644 --- a/tests/mixed-action-question.test.ts +++ b/tests/mixed-action-question.test.ts @@ -69,6 +69,21 @@ void test('mixed action parsing requires both families and preserves raw nested assert.throws(() => decode(input), MixedActionQuestionError); }); +void test('optional current facts permit all-action travel questions without weakening other defaults or budgets', () => { + for (const original of [action(), group('or', action(), action('arrakeen')), + group('and', fact(), action())]) + assert.deepEqual(decode(original, { requireFact: false }), original); + for (const original of [fact(), group('and', fact(), fact(10))]) + assert.throws(() => decode(original, { requireFact: false }), MixedActionQuestionError); + assert.throws(() => decode(action(), { requireFact: true }), MixedActionQuestionError); + assert.throws(() => decode(group('and', action(), action()), { requireFact: false, maxLeaves: 1 }), + MixedActionQuestionError); + assert.throws(() => decode(group('and', action(), action()), { requireFact: false, maxDepth: 0 }), + MixedActionQuestionError); + for (const requireFact of [null, 0, 'false']) + assert.throws(() => decode(action(), { requireFact } as unknown as MixedActionExpressionLimits), MixedActionQuestionError); +}); + void test('mixed action public parsing rejects frozen constants, metadata and malformed groups everywhere', () => { const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] }, { kind: 'or', terms: [fact()] }, { kind: 'xor', terms: [fact(), action()] }, diff --git a/tests/movement-shipment-preparation.test.ts b/tests/movement-shipment-preparation.test.ts index 84415985..b85f841f 100644 --- a/tests/movement-shipment-preparation.test.ts +++ b/tests/movement-shipment-preparation.test.ts @@ -26,6 +26,22 @@ void test('spent shipment and paid Box decisions cannot start another preparatio assert.deepEqual([...movementShipmentPreparations(game, 'owner', () => assert.fail('Finish the paid decision'))], []); }); +void test('Fremen reserve preparation retains Guild transport variants only with a reciprocal native ally', () => { + const game = source(), owner = game.players[0]; owner.faction = 'fremen'; + const guild: Game['players'][number] = { ...owner, id: 'ally', faction: 'guild', ally: owner.id }; + game.players.push(guild); + const matching = (action: Record) => action.type === 'guildShip' && action.from === 'reserves' && + action.territory === 'carthag' && action.sector === 11 && action.amount === 2 && action.allyPayment === 1; + const choices = [...movementShipmentPreparations(game, owner.id, (state, _actor, action) => + matching(action) ? structuredClone(state) : null)]; + assert.equal(choices.length, 2); + assert.deepEqual(choices.map(choice => choice.actions[0].elite), [0, 1]); + guild.ally = null; + assert.deepEqual([...movementShipmentPreparations(game, owner.id, (_state, _actor, action) => { + assert.notEqual(action.type, 'guildShip'); return null; + })], []); +}); + void test('owned No-Field shipment candidates preserve marker identity, one-force tariff shares and last-use restrictions', () => { const game = source(), owner = game.players[0]; owner.faction = 'richese'; owner.noField = { tokens: [{ id: 'zero', value: 0 }, { id: 'three', value: 3 }, { id: 'five', value: 5 }], diff --git a/tests/travel-expression-promises.test.ts b/tests/travel-expression-promises.test.ts new file mode 100644 index 00000000..f09d9032 --- /dev/null +++ b/tests/travel-expression-promises.test.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question'; +import { + finishTravelTurn, + liveTravelPromises, + parseTravelActionClaim, + recordTravelMovement, + recordTravelShipment, + travelBranches, + travelPromiseValue, + TravelPromiseError, + validateTravelPromises, + type TravelActionClaim, + type TravelBranchLeaf, + type TravelPromise, +} from '../game/travel-expression-promises'; + +type Expression = MixedActionExpression; +const action = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3): Expression => ({ + kind: 'action', action: { kind, territory, minimum }, +}); +const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } }); +const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms }); +const leaf = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3, answer = true): TravelBranchLeaf => + ({ kind, territory, minimum, answer }); +function promise(mixed: Expression, answer = true, event = 'travel:1'): TravelPromise { + return { + turn: 1, mixed, event, player: 'respondent', asker: 'asker', answer, + compiled: compileMixedActionExpression(mixed, () => 'yes', answer), + shipment: { pending: true, event: null }, progress: [], movementClosed: false, + fulfilled: false, released: false, + }; +} +const event = (territory = 'carthag', amount = 3) => ({ territory, amount }); + +void test('travel action leaves use native destination/minimum validation and reject every extra field', () => { + for (const kind of ['ship', 'move'] as const) { + const original = { kind, territory: 'carthag', minimum: 3 }; + assert.deepEqual(parseTravelActionClaim(original), original); + assert.notEqual(parseTravelActionClaim(original), original); + for (const invalid of [null, [], {}, { ...original, kind: 'revive' }, + { ...original, minimum: 0 }, { ...original, minimum: 21 }, { ...original, minimum: 1.5 }, + { ...original, minimum: '3' }, { ...original, territory: 'not-a-territory' }, + { ...original, turn: 1 }, { ...original, answer: true }, { ...original, maximum: 3 }, + { ...original, claim: { territory: 'carthag', minimum: 3 } }]) + assert.throws(() => parseTravelActionClaim(invalid), TravelPromiseError); + } +}); + +void test('whole Yes AND requires both native events while OR keeps either alternative available', () => { + const and = promise(group('and', action('ship'), action('move', 'arrakeen', 2))); + const or = promise(group('or', action('ship'), action('move', 'arrakeen', 2))); + assert.deepEqual([...travelBranches(and)], [[leaf('ship'), leaf('move', 'arrakeen', 2)]]); + assert.deepEqual([...travelBranches(or)], [[leaf('ship')], [leaf('move', 'arrakeen', 2)]]); + const shipped = recordTravelShipment([and, or], 'respondent', 1, event()); + assert.equal(travelPromiseValue(shipped[0]), false); + assert.equal(shipped[0].fulfilled, false); + assert.equal(shipped[1].fulfilled, true); + assert.deepEqual([...travelBranches(shipped[0])], [[leaf('move', 'arrakeen', 2)]]); + const moved = recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 2)); + assert.equal(moved[0].fulfilled, true); + assert.deepEqual([...travelBranches(moved[0])], [[]]); + assert.equal(liveTravelPromises(moved, 'respondent', 1).length, 0); + assert.equal(and.shipment.pending, true); + assert.deepEqual(and.progress, []); +}); + +void test('whole No applies De Morgan and permits changing branches after a real action', () => { + const andNo = promise(group('and', action('ship'), action('move', 'arrakeen', 2)), false); + const orNo = promise(group('or', action('ship'), action('move', 'arrakeen', 2)), false); + assert.deepEqual([...travelBranches(andNo)], [[leaf('ship', 'carthag', 3, false)], + [leaf('move', 'arrakeen', 2, false)]]); + assert.deepEqual([...travelBranches(orNo)], [[leaf('ship', 'carthag', 3, false), + leaf('move', 'arrakeen', 2, false)]]); + const shipped = recordTravelShipment([andNo], 'respondent', 1, event()); + assert.equal(shipped[0].fulfilled, false); + assert.deepEqual([...travelBranches(shipped[0])], [[leaf('move', 'arrakeen', 2, false)]]); + const before = structuredClone(shipped); + assert.throws(() => recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 2)), TravelPromiseError); + assert.deepEqual(shipped, before); + assert.throws(() => recordTravelShipment([orNo], 'respondent', 1, event()), TravelPromiseError); + const skipped = recordTravelShipment([andNo], 'respondent', 1, null); + assert.ok([...travelBranches(skipped[0])].some(branch => branch.length === 0), + 'Skipping the shipment leaves an unrestricted completion, regardless of redundant narrower DNF branches'); + assert.doesNotThrow(() => recordTravelMovement(skipped, 'respondent', 1, event('arrakeen', 2))); +}); + +void test('nested branches keep all signed alternatives and remove impossible positive shipment conjunctions', () => { + const asked = group('and', group('or', action('ship'), action('move')), + group('or', action('ship', 'arrakeen', 2), action('move', 'arrakeen', 4))); + const branches = [...travelBranches(promise(asked))]; + assert.deepEqual(branches, [ + [leaf('ship'), leaf('move', 'arrakeen', 4)], + [leaf('move'), leaf('ship', 'arrakeen', 2)], + [leaf('move'), leaf('move', 'arrakeen', 4)], + ]); + assert.deepEqual([...travelBranches(promise(group('and', action('ship'), action('ship', 'arrakeen'))))], []); + const no = [...travelBranches(promise(asked, false))]; + assert.deepEqual(no, [ + [leaf('ship', 'carthag', 3, false), leaf('move', 'carthag', 3, false)], + [leaf('ship', 'arrakeen', 2, false), leaf('move', 'arrakeen', 4, false)], + ]); +}); + +void test('same-destination signed bounds keep strongest minimum or lowest negative cap', () => { + const asked = group('and', action('move', 'carthag', 2), action('move', 'carthag', 5)); + assert.deepEqual([...travelBranches(promise(asked))], [[leaf('move', 'carthag', 5)]]); + const negative = promise(group('or', action('move', 'carthag', 2), action('move', 'carthag', 5)), false); + assert.deepEqual([...travelBranches(negative)], [[leaf('move', 'carthag', 2, false)]]); + const atCap = recordTravelMovement([negative], 'respondent', 1, event('carthag', 1)); + assert.equal(atCap[0].progress[0].maximum, 1); + assert.deepEqual([...travelBranches(atCap[0])], [[leaf('move', 'carthag', 2, false)]]); + assert.throws(() => recordTravelMovement(atCap, 'respondent', 1, event('carthag', 2)), TravelPromiseError); +}); + +void test('shipment leaves share one event and later answers receive no earlier shipment or movement', () => { + const expression = group('or', action('ship', 'carthag', 5), action('move', 'arrakeen', 5)); + const old = promise(expression); + const shipped = recordTravelShipment([old], 'respondent', 1, event('carthag', 4)); + const moved = recordTravelMovement(shipped, 'respondent', 1, event('arrakeen', 4)); + const fresh = promise(expression, true, 'travel:2'); + fresh.shipment.pending = false; + const otherPlayer = { ...promise(expression, true, 'travel:3'), player: 'other' }; + const otherTurn = { ...promise(expression, true, 'travel:4'), turn: 2 }; + const list = [moved[0], fresh, otherPlayer, otherTurn]; + const next = recordTravelMovement(list, 'respondent', 1, event('arrakeen', 3)); + assert.deepEqual(next[0].progress, [{ territory: 'arrakeen', maximum: 4 }]); + assert.deepEqual(next[1].progress, [{ territory: 'arrakeen', maximum: 3 }]); + assert.equal(next[2], otherPlayer); + assert.equal(next[3], otherTurn); + const repeatedShipment = recordTravelShipment(next, 'respondent', 1, event('carthag', 20)); + assert.equal(repeatedShipment[0].shipment.event?.amount, 4); + assert.equal(repeatedShipment[1].shipment.event, null); + assert.deepEqual([...travelBranches(repeatedShipment[1])], [[leaf('move', 'arrakeen', 5)]]); +}); + +void test('only the largest physical movement group counts and event updates are immutable', () => { + const original = promise(action('move', 'carthag', 5)); + const once = recordTravelMovement([original], 'respondent', 1, event('carthag', 3)); + const twice = recordTravelMovement(once, 'respondent', 1, event('carthag', 3)); + assert.equal(travelPromiseValue(twice[0]), false); + assert.equal(twice[0], once[0]); + assert.equal(twice[0].progress[0].maximum, 3); + assert.deepEqual(recordTravelMovement(twice, 'respondent', 1, event('carthag', 0)), twice); + assert.equal(recordTravelMovement(twice, 'respondent', 1, event('arrakeen', 20))[0], twice[0]); + const complete = recordTravelMovement(twice, 'respondent', 1, event('carthag', 5)); + assert.equal(complete[0].fulfilled, true); + assert.deepEqual(original.progress, []); + assert.equal(once[0].progress[0].maximum, 3); +}); + +void test('frozen known and unknown facts constrain only the whole selected answer', () => { + 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(), action('move')); + const record = promise(mixed, answer); + record.compiled = compileMixedActionExpression(mixed, () => factAnswer, answer); + assert.deepEqual(record.mixed, mixed); + if (record.compiled.kind === 'constant') { + assert.equal(travelPromiseValue(record), record.compiled.value); + assert.deepEqual([...travelBranches(record)], record.compiled.value === answer ? [[]] : []); + if (record.compiled.value === answer) { + record.fulfilled = true; + validateTravelPromises([record], ['respondent', 'asker']); + } + } else { + assert.deepEqual([...travelBranches(record)], [[leaf('move', 'carthag', 3, answer)]]); + } + } + } + } +}); + +void test('turn closure rejects unmet Yes, fulfills compatible No and folds every closed opportunity', () => { + const yes = promise(group('or', action('ship'), action('move'))); + const before = structuredClone(yes); + assert.throws(() => finishTravelTurn([yes], 'respondent', 1), TravelPromiseError); + assert.deepEqual(yes, before); + const no = promise(group('and', action('ship'), action('move')), false); + const closed = finishTravelTurn([no], 'respondent', 1); + assert.equal(closed[0].fulfilled, true); + assert.equal(closed[0].movementClosed, true); + assert.equal(closed[0].shipment.pending, false); + validateTravelPromises(closed, ['respondent', 'asker']); + assert.equal(recordTravelMovement(closed, 'respondent', 1, event())[0], closed[0]); + const projectedClosed = { ...yes, movementClosed: true, shipment: { pending: false, event: null } }; + assert.deepEqual([...travelBranches(projectedClosed)], []); + assert.deepEqual([...travelBranches({ ...projectedClosed, answer: false })], [[]]); + const released = { ...yes, released: true }; + const finishedRelease = finishTravelTurn([released], 'respondent', 1)[0]; + assert.equal(finishedRelease.released, true); + assert.equal(finishedRelease.fulfilled, false); + assert.deepEqual([...travelBranches(finishedRelease)], [[]]); +}); + +void test('stored structure, whole-answer progress and strict native events reject malformed input', () => { + const original = promise(group('and', action('ship'), action('move'))); + validateTravelPromises([original], ['respondent', 'asker']); + const corruptions: unknown[] = [ + { ...original, turn: 0 }, { ...original, answer: 'yes' }, { ...original, fulfilled: undefined }, + { ...original, fulfilled: true, released: true }, + { ...original, shipment: { pending: true, event: event() } }, + { ...original, shipment: { pending: false, event: { ...event(), elite: 1 } } }, + { ...original, progress: [{ territory: 'carthag', maximum: 21 }] }, + { ...original, progress: [{ territory: 'arrakeen', maximum: 3 }] }, + { ...original, progress: [{ territory: 'carthag', maximum: 1 }, { territory: 'carthag', maximum: 2 }] }, + { ...original, compiled: { kind: 'constant', value: 'unknown' } }, + { ...original, compiled: { kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 3 } } }, + { ...original, compiled: { kind: 'and', terms: [] } }, + { ...original, mixed: { kind: 'constant', value: true } }, + { ...original, mixed: fact() }, + ]; + for (const corrupted of corruptions) + assert.throws(() => travelPromiseValue(corrupted as TravelPromise)); + for (const record of [{ ...original, event: '' }, { ...original, asker: 'respondent' }, + { ...original, player: 'missing' }, { ...original, fulfilled: true }]) + assert.throws(() => validateTravelPromises([record], ['respondent', 'asker']), TravelPromiseError); + assert.throws(() => validateTravelPromises([original, original], ['respondent', 'asker']), TravelPromiseError); + 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(() => recordTravelMovement([original], 'respondent', 1, invalid as never), TravelPromiseError); + assert.throws(() => recordTravelShipment([original], 'respondent', 1, invalid as never), TravelPromiseError); + } + const violatedNo = { ...original, answer: false, + shipment: { pending: false, event: event() }, progress: [{ territory: 'carthag', maximum: 3 }] }; + assert.throws(() => validateTravelPromises([violatedNo], ['respondent', 'asker']), TravelPromiseError); +}); diff --git a/tests/truth-travel-controls.test.tsx b/tests/truth-travel-controls.test.tsx new file mode 100644 index 00000000..e9d19f93 --- /dev/null +++ b/tests/truth-travel-controls.test.tsx @@ -0,0 +1,272 @@ +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 { TravelClaimFields, travelClaimInputError } from '../components/travel-claim-fields'; +import { TravelAnswers, TravelPromiseGuideButton, TravelPromises } from '../components/travel-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 type { TravelActionClaim } from '../game/travel-expression-promises'; +import type { TruthFact } from '../game/truthtrance'; + +type Expression = MixedActionExpression; +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value }); +const action = (kind: 'ship' | 'move' = 'ship', minimum = 1, territory = 'carthag'): Expression => + ({ kind: 'action', action: { kind, territory, minimum } }); +const claim = (): Expression => ({ kind: 'or', terms: [action('ship', 3), { kind: 'and', terms: [fact(), action('move', 4, 'arrakeen')] }] }); + +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function find(tree: ReactNode, key: string, value: string): Element { + const found = elements(tree).find(element => element.props[key] === value); + assert.ok(found, `${key}=${value}`); return found; +} +const control = (tree: ReactNode, key: string) => find(tree, 'data-travel-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-travel-${path}-${field}`).props.onChange as + (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); +} +function minimum(tree: ReactNode, path: string, valueAsNumber: number) { + (find(tree, 'id', `truth-travel-${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-travel-fact-path', path); + const fields = elements(scope).find(element => element.type === TruthFactFields); + assert.ok(fields); + (fields.props.onChange as (next: TruthFact) => void)(value); +} + +/** Explicitly controlled projections verify UI callbacks. They do not claim + * native acquisition, force movement or promise fulfillment evidence. */ +function projected(next: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'arrakeen:11': 1 }, + eliteForces: { 'arrakeen:10': 1 }, territory: 'carthag', sector: 11 }): GameView { + const source = createGame('TRUTHTRAVELCONTROLS', newPlayer('p', 'Respondent', 'emperor')); + joinGame(source, newPlayer('a', 'Asker', 'atreides')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.turn = 2; game.phase = 5; game.active = 'p'; game.ready = []; + game.travelPromises = [{ event: 'controlled-travel-event', player: 'p', asker: 'a', turn: 2, + mixed: claim(), answer: true, fulfilled: false, released: false }]; + game.travelPromiseGuide = { event: 'controlled-travel-event', action: { ...next, travelPromiseEvent: 'controlled-travel-event' } }; + game.truthTravelAnswers = 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: () => TravelClaimFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }), + html: () => renderToStaticMarkup(createElement(TravelClaimFields, { value, game, target: 'p', disabled, onChange() {} })), + error: () => travelClaimInputError(value, game, 'p'), + }; +} +function guide(props: Parameters[0]) { + const button = TravelPromiseGuideButton(props); assert.ok(button); return button; +} +const press = (button: ReturnType) => button.props.onClick(); +const markup = (game: GameView) => renderToStaticMarkup(createElement(TravelPromises, { game, busy: false, act() {} })); +function answering(): GameView { + const game = projected(); + game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth-card' }], passed: [], + question: { kind: 'travel', target: 'p', turn: 2, mixed: claim() } }; + game.truthTravelAnswers = ['yes', 'no', 'unknown']; + return game; +} + +void test('travel editor accepts action-only expressions and preserves shipment and movement alternatives through edits', () => { + const e = editor(action('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, action('move', 5, 'arrakeen')); + select(e.tree(), '1', 'action', 'ship'); + assert.deepEqual(e.value, action('ship', 5, 'arrakeen')); + 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: [action('ship', 5, 'arrakeen'), action('move', 4)] }); + assert.equal(e.error(), null); + const html = e.html(); + assert.match(html, /Ship from reserves/); assert.match(html, /Make ground movement/); + assert.match(html, /single future original reserve shipment/); assert.match(html, /smaller moves never add together/); + assert.match(html, /No makes the whole expression false/); assert.match(html, /Original costs, movement rights/); + click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, action('move', 4)); + select(e.tree(), '1', 'timing', 'fact'); assert.ok(e.error(), 'A pure-fact tree still needs a future action'); +}); + +void test('travel editor retains sixteen optional-fact clauses and four grouping levels without truncation', () => { + const e = editor({ kind: 'or', terms: [action('ship', 3), action('move', 4)] }); + for (let count = 3; count <= 16; count++) { + click(control(e.tree(), 'add-action-1')); + minimum(e.tree(), `1.${count}`, count); + if (count % 2) select(e.tree(), `1.${count}`, 'action', 'move'); + } + 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(), action('move')] }); + click(control(deep.tree(), 'or-1.2')); select(deep.tree(), '1.2', 'meaning', 'and'); + 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('travel fields preserve invalid numeric drafts and reject disabled callbacks', () => { + for (const invalid of [NaN, 0, -1, 1.5, 21, Infinity]) { + const e = editor(action()); minimum(e.tree(), '1', invalid); + assert.ok(e.error()); + if (Number.isNaN(invalid)) assert.match(e.html(), /value=""/); + minimum(e.tree(), '1', 20); assert.equal(e.error(), null); + } + const malformed = editor(action()); select(malformed.tree(), '1', 'territory', 'invented-place'); + assert.ok(malformed.error()); assert.match(malformed.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', 'territory', 'arrakeen'); + 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('travel inputs use printed destinations and current-fact schema without private eligibility or custody reads', () => { + const game = projected(); + 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 [action('ship', 20, 'arrakeen'), { kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), action('move')] } as Expression]) { + const e = editor(expression, false, game); + assert.equal(e.error(), null); assert.match(e.html(), /Carthag/); assert.match(e.html(), /Arrakeen/); + assert.match(e.html(), /Only the aggregate answer is shared/); + } +}); + +void test('travel history retains own original whole questions and terminal statuses without compiled branches or foreign history', () => { + const game = projected(); game.travelPromiseGuide = null; + game.travelPromises.push( + { ...game.travelPromises[0], event: 'controlled-no', answer: false }, + { ...game.travelPromises[0], event: 'controlled-complete', turn: 1, fulfilled: true }, + { ...game.travelPromises[0], event: 'controlled-release', released: true }, + { ...game.travelPromises[0], event: 'controlled-foreign', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), action('ship', 19, 'sietch_tabr')] } }, + ); + for (const promise of game.travelPromises) for (const key of ['compiled', 'binding', 'factAnswers', 'residual', 'route']) + Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + const html = markup(game); + assert.match(html, /Turn 1/); assert.match(html, /You answered Yes/); assert.match(html, /You answered No/); + assert.match(html, /you hold Shield/); assert.match(html, / OR /); assert.match(html, / AND /); + assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/); + assert.match(html, /No negates the whole expression, not each condition separately/); + assert.match(html, /maximum physical forces in one actual future move, never the sum/); + assert.match(html, /Individual fact answers and any reduced future branch remain private/); + assert.doesNotMatch(html, /Poison Blade|19 physical|controlled-foreign|controlled-complete|factAnswers/); + assert.equal(TravelPromises({ game: { ...game, me: 'missing' }, busy: false, act() {} }), null); + game.travelPromises = game.travelPromises.filter(promise => promise.fulfilled || promise.released); + assert.doesNotMatch(markup(game), / { + const actions: Action[] = [ + projected().travelPromiseGuide!.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(action), sent: Action[] = []; + const button = guide({ game, busy: false, act(value) { sent.push(value); } }); + assert.equal(button.props.disabled, false); press(button); + assert.deepEqual(sent, [game.travelPromiseGuide!.action]); assert.equal(sent[0], game.travelPromiseGuide!.action); + assert.match(markup(game), /data-testid="travel-promise-guide"/); + if (action.type === 'guildShip') assert.match(markup(game), /using Guild transport/); + } + const game = projected({ type: 'decision', event: 'controlled-paid-search', card: 'controlled-recovered-card' }); + 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-card', name: 'Hajr', kind: 'special', effect: 'hajr' }] } }; + 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.travelPromiseGuide!.action); + game.nullentropy.search!.event = 'changed-search'; press(button); assert.equal(sent.length, 1); +}); + +void test('travel guide rejects stale view, actor, source, turn, phase, native ownership 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.travelPromiseGuide = null; }, + game => { game.travelPromiseGuide = { ...game.travelPromiseGuide!, action: { ...game.travelPromiseGuide!.action } }; }, + game => { game.travelPromiseGuide!.event = 'changed'; }, + game => { game.travelPromiseGuide!.action.travelPromiseEvent = 'changed'; }, + game => { (game.travelPromiseGuide!.action.forces as Record)['arrakeen:10'] = 2; }, + game => { (game.travelPromiseGuide!.action.eliteForces as Record)['arrakeen:10'] = 0; }, + game => { game.travelPromises[0].event = 'expired'; }, game => { game.travelPromises[0].player = 'a'; }, + game => { game.travelPromises[0].turn = 1; }, game => { game.travelPromises[0].fulfilled = true; }, + game => { game.travelPromises[0].released = true; }, game => { game.travelPromises = []; }, + 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 replacedButton = guide(replaced); replaced.game = { ...replaced.game }; press(replacedButton); +}); + +void test('whole travel answers offer only native choices and forward guarded original answer packets', () => { + const game = answering(), sent: Action[] = [], props = { game, busy: false, act(value: Action) { sent.push(value); } }; + for (const answer of game.truthTravelAnswers!) { + const tree = TravelAnswers(props); assert.ok(tree); + click(find(tree, 'data-travel-answer', answer)); + } + assert.deepEqual(sent, ['yes', 'no', 'unknown'].map(answer => ({ type: 'truthAnswer', answer }))); + const html = renderToStaticMarkup(createElement(TravelAnswers, props)); + assert.match(html, /Answer Yes/); assert.match(html, /Answer No/); assert.match(html, /I don’t know yet/); + game.truthTravelAnswers = ['no']; + const restricted = renderToStaticMarkup(createElement(TravelAnswers, { game, busy: false, act() {} })); + assert.match(restricted, /Answer No/); assert.doesNotMatch(restricted, /Answer Yes|I don’t know yet/); + assert.equal(TravelAnswers({ game: { ...game, me: 'a' }, busy: false, act() {} }), null); + assert.equal(TravelAnswers({ game: { ...game, truthTravelAnswers: null }, busy: false, act() {} }), null); +}); + +void test('travel answer callbacks reject stale source, offered choices, view identity, turn 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.truthTravelAnswers = ['yes']; }, game => { game.truthTravelAnswers!.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 = TravelAnswers({ game, busy: false, act(value) { sent.push(value); } }); + const button = find(tree, 'data-travel-answer', 'yes'); change(game); click(button); assert.deepEqual(sent, []); + } + const busy = { game: answering(), busy: false, act() { assert.fail('Busy answer cannot send'); } }; + const button = find(TravelAnswers(busy), 'data-travel-answer', 'yes'); busy.busy = true; click(button); + const replaced = { game: answering(), busy: false, act() { assert.fail('Replaced view cannot send'); } }; + const original = find(TravelAnswers(replaced), 'data-travel-answer', 'yes'); replaced.game = { ...replaced.game }; click(original); +}); diff --git a/tests/truth-travel-guild-fremen.test.ts b/tests/truth-travel-guild-fremen.test.ts new file mode 100644 index 00000000..3e388cb5 --- /dev/null +++ b/tests/truth-travel-guild-fremen.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; + +/** Controlled native state, conserving the original two physical Truths. This + * isolates the printed Fremen/Guild transport rule; no setup/earned-alliance + * history is claimed by this fixture. */ +function controlled(paidByAlly = false) { + const game = createGame('TRAVELGUILDFREMEN', newPlayer('a', 'Asker', 'harkonnen'), false); + game.players.push(newPlayer('p', 'Fremen', 'fremen'), newPlayer('g', 'Guild', 'guild')); + Object.assign(game, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'p', + order: ['p', 'a', 'g'], movementRemaining: ['p', 'a', 'g'], deck: baseDeck(), discard: [] }); + for (const player of game.players) { + player.hand = []; player.forces = {}; player.reserves = 20; player.spice = 10; player.traitorChoices = []; + } + game.players[1].ally = 'g'; game.players[2].ally = 'p'; game.players[1].spice = paidByAlly ? 0 : 3; + const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2); + game.deck = game.deck.filter(card => card.effect !== 'truthtrance'); game.players[0].hand = truths; + const inventory = (g: Game) => [...g.deck, ...g.discard, ...g.players.flatMap(player => player.hand)].map(card => card.id).sort(); + const original = inventory(game); + return { game: paidByAlly ? applyAction(game, 'g', { type: 'pledgeAid', amount: 3 }) : game, + truths: truths.map(card => card.id), inventory, original }; +} +const ship = (minimum: number) => ({ kind: 'action' as const, action: { kind: 'ship' as const, territory: 'carthag', minimum } }); +const move = { kind: 'action' as const, action: { kind: 'move' as const, territory: 'imperial_basin', minimum: 6 } }; +function asked(game: Game, card: string, question: unknown) { + game = applyAction(game, 'a', { type: 'card', card }); + while (game.truthtrance?.stage === 'priority') { + const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor); + game = applyAction(game, actor.id, { type: 'truthPass' }); + } + return applyAction(game, 'a', { type: 'truthAsk', question }); +} +void test('controlled Guild-allied Fremen travel uses the paid reserve transport outside ordinary reinforcement radius and then moves the physical group', () => { + for (const paidByAlly of [false, true]) { + const f = controlled(paidByAlly); let game = asked(f.game, f.truths[0], { + kind: 'travel', target: 'p', turn: 2, mixed: { kind: 'and', terms: [ship(6), move] }, + }); + assert.ok(viewGame(game, 'p').truthTravelAnswers?.includes('yes')); + game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' }); + const before = structuredClone(game); + assert.throws(() => applyAction(game, 'p', { type: 'ship', territory: 'carthag', sector: 11, amount: 6 })); + assert.deepEqual(game, before); + const guide = viewGame(game, 'p').travelPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'guildShip'); assert.equal(guide.action.from, 'reserves'); + assert.equal(guide.action.territory, 'carthag'); assert.equal(guide.action.amount, 6); + assert.equal(guide.action.allyPayment, paidByAlly ? 3 : 0); + game = applyAction(game, 'p', guide.action); + assert.equal(game.players[1].spice, 0); assert.equal(game.players[1].reserves, 14); + if (paidByAlly) assert.equal(game.aid.g.amount, 0); + assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, false); + assert.deepEqual(game.travelPromises![0].progress, []); + const moving = viewGame(game, 'p').travelPromiseGuide; assert.ok(moving); assert.equal(moving.action.type, 'move'); + game = applyAction(game, 'p', moving.action); + assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, true); + assert.equal(Object.entries(game.players[1].forces).filter(([key]) => key.startsWith('imperial_basin:')).reduce((sum, [, count]) => sum + count, 0), 6); + assert.deepEqual(f.inventory(game), f.original); + } +}); +void test('controlled earlier travel No retains the legal Guild-allied Fremen route for a later scalar shipment Yes', () => { + const f = controlled(); let game = asked(f.game, f.truths[0], { kind: 'travel', target: 'p', turn: 2, mixed: ship(7) }); + game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'no' }); + game = asked(game, f.truths[1], { kind: 'shipment', target: 'p', territory: 'carthag', minimum: 6 }); + assert.ok(viewGame(game, 'p').truthShipmentAnswers?.includes('yes')); + game = applyAction(game, 'p', { type: 'truthAnswer', answer: 'yes' }); + const action: Action = { type: 'guildShip', from: 'reserves', territory: 'carthag', sector: 11, amount: 6 }; + game = applyAction(game, 'p', action); assert.equal(game.shipmentPromises?.[0].fulfilled, true); + assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, false); + game = applyAction(game, 'p', { type: 'endMovement' }); + assert.equal(viewGame(game, 'p').travelPromises[0].fulfilled, true); assert.deepEqual(f.inventory(game), f.original); +}); +void test('controlled Fremen cannot use a free ordinary price to fund paid Guild transport', () => { + const f = controlled(); f.game.players[1].spice = 2; + const game = asked(f.game, f.truths[0], { kind: 'travel', target: 'p', turn: 2, mixed: ship(6) }); + assert.deepEqual(viewGame(game, 'p').truthTravelAnswers, ['no']); +}); diff --git a/tests/truth-travel-order.test.ts b/tests/truth-travel-order.test.ts new file mode 100644 index 00000000..28d5e7fb --- /dev/null +++ b/tests/truth-travel-order.test.ts @@ -0,0 +1,152 @@ +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 { truthTravelGroup as group, truthTravelMove as move, truthTravelShip as ship } from './fixture-truth-travel'; +import { + actTruthTravelOrder as act, answerTruthTravelOrder as answer, askTruthTravelOrder as ask, + bindTruthTravelOrder as bind, createTruthTravelOrderFixture as fixture, declareTruthTravelOrder as declare, + settleTruthTravelOrder as settle, truthTravelOrderInventory as inventory, truthTravelOrderPlayer as player, + truthTravelOrderQuestion as travelQuestion, type TruthTravelOrderFixture, type TruthTravelOrderStep, +} from './fixture-truth-travel-order'; + +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); +} +function physicalTruthsOnce(game: Game, f: TruthTravelOrderFixture) { + for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1); + assert.equal(player(game, f.asker).hand.some(card => f.truths.includes(card.id)), false); + assert.deepEqual(inventory(game), f.originalCards); +} +function scalarQuestion(f: TruthTravelOrderFixture, kind: 'shipment' | 'movement' | 'movementExpression'): TruthQuestion { + if (kind === 'shipment') return f.shipmentQuestion; + if (kind === 'movement') return f.movementQuestion; + return { kind: 'movementExpression', target: f.target, turn: f.game.turn, claim: { kind: 'or', terms: [ + { kind: 'move', territory: 'imperial_basin', minimum: 1 }, + { kind: 'move', territory: 'carthag', minimum: 1 }, + ] } }; +} +function nativeRow(game: Game, kind: 'shipment' | 'movement' | 'movementExpression') { + const row = kind === 'shipment' ? game.shipmentPromises?.[0] + : kind === 'movement' ? game.movementPromises?.[0] : game.movementExpressionPromises?.[0]; + assert.ok(row); return row; +} + +void test('native travel-order source genuinely owns both original opening Truthtrances before any question', () => { + const f = fixture(); + 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.sourceCount, f.actions.length); assert.equal(f.sourceActionCount, f.sourceActions.length); + assert.equal(f.sourceCounts.inheritedBidding + f.sourceCounts.nativeContinuation, f.sourceCount); + assert.deepEqual(f.sourceActions.slice(0, f.sourceCounts.inheritedBidding), f.base.sourceActions); + assert.equal(f.continuation.length, f.sourceCounts.nativeContinuation); + assert.ok(f.continuation.some(step => step.action.type === 'passBid')); + assert.ok(f.continuation.some(step => step.action.type === 'ready')); + assert.ok(f.continuation.some(step => step.actor === f.asker && step.action.type === 'endMovement')); + 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(f.game.truthHistory?.length ?? 0, 0); assert.equal(f.game.travelPromiseOrder, undefined); + assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33); +}); + +for (const kind of ['shipment', 'movement', 'movementExpression'] as const) + for (const first of ['native', 'travel'] as const) + void test(`two native opening Truths retain ${kind} No and travel OR Yes in actual ${first}-first acceptance order`, () => { + const f = fixture(), actions: TruthTravelOrderStep[] = []; + const native = scalarQuestion(f, kind), travel = f.travelQuestion; + let game = f.game; + game = bind(f, game, 0, first === 'native' ? native : travel, first === 'native' ? 'no' : 'yes', actions).game; + const firstHistory = structuredClone(game.truthHistory!.at(-1)); + game = bind(f, game, 1, first === 'native' ? travel : native, first === 'native' ? 'yes' : 'no', actions).game; + assert.deepEqual(game.travelPromiseOrder, first === 'native' + ? [{ kind, index: 0 }, { kind: 'travel', index: 0 }] + : [{ kind: 'travel', index: 0 }, { kind, index: 0 }]); + assert.deepEqual(game.truthHistory!.at(-2), firstHistory); + assert.equal(nativeRow(game, kind).answer, false); assert.ok(!nativeRow(game, kind).released); + assert.equal(game.travelPromises![0].answer, true); assert.ok(!game.travelPromises![0].released); + assert.deepEqual(viewGame(game, f.observer).travelPromises, []); + if (kind === 'shipment') { + rejected(game, f.target, f.shipment); + const beforeSpice = player(game, f.target).spice; + game = settle(act(game, f.target, f.alternativeShipment, actions), actions); + assert.equal(player(game, f.target).spice, beforeSpice - 1); + assert.ok(!game.travelPromises![0].fulfilled); + game = settle(act(game, f.target, f.move, actions), actions); + assert.equal(player(game, f.target).forces['imperial_basin:9'], 1); + } else { + rejected(game, f.target, f.move); + const beforeSpice = player(game, f.target).spice; + game = settle(act(game, f.target, f.shipment, actions), actions); + assert.equal(player(game, f.target).spice, beforeSpice - 2); + assert.equal(player(game, f.target).forces['polar_sink:0'], 1); + if (kind === 'movement') game = settle(act(game, f.target, f.alternativeMove, actions), actions); + } + assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!nativeRow(game, kind).released); + game = settle(act(game, f.target, f.end, actions), actions); + assert.equal(nativeRow(game, kind).fulfilled, true); assert.ok(!nativeRow(game, kind).released); + assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!game.travelPromises![0].released); + assert.equal(game.truthHistory!.length, 2); assert.equal(actions.filter(step => step.action.type === 'truthAsk').length, 2); + physicalTruthsOnce(game, f); + }); + +void test('two native opening Truths keep whole travel No with an earlier or later compatible scalar shipment Yes', () => { + const f = fixture(), travel = travelQuestion(f, group('and', ship(), move())); + for (const first of ['native', 'travel'] as const) { + const actions: TruthTravelOrderStep[] = []; + let game = bind(f, f.game, 0, first === 'native' ? f.shipmentQuestion : travel, + first === 'native' ? 'yes' : 'no', actions).game; + const earlier = structuredClone(game.truthHistory!.at(-1)); + game = bind(f, game, 1, first === 'native' ? travel : f.shipmentQuestion, + first === 'native' ? 'no' : 'yes', actions).game; + assert.deepEqual(game.travelPromiseOrder, first === 'native' + ? [{ kind: 'shipment', index: 0 }, { kind: 'travel', index: 0 }] + : [{ kind: 'travel', index: 0 }, { kind: 'shipment', index: 0 }]); + rejected(game, f.target, f.end); + game = settle(act(game, f.target, f.shipment, actions), actions); + assert.equal(game.shipmentPromises![0].fulfilled, true); + assert.ok(!game.travelPromises![0].fulfilled); rejected(game, f.target, f.move); + game = settle(act(game, f.target, f.end, actions), actions); + assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!game.travelPromises![0].released); + assert.equal(game.shipmentPromises![0].fulfilled, true); assert.ok(!game.shipmentPromises![0].released); + assert.equal(player(game, f.target).moved, 0); assert.deepEqual(game.truthHistory!.at(-2), earlier); + physicalTruthsOnce(game, f); + } +}); + +void test('an incompatible second definite answer cannot erase the first native or travel receipt', () => { + const f = fixture(); + // Earlier original shipment Yes forces the later incompatible-destination + // travel AND to No. The second native card remains present after rejection. + let game = bind(f, f.game, 0, f.shipmentQuestion, 'yes').game; + const earlierShipment = structuredClone(game.shipmentPromises![0]), earlierHistory = structuredClone(game.truthHistory![0]); + const incompatibleTravel = travelQuestion(f, group('and', ship('arrakeen'), move())); + game = ask(declare(f, game, 1).game, f.asker, incompatibleTravel); + assert.deepEqual(viewGame(game, f.target).truthTravelAnswers, ['no']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + assert.deepEqual(game.shipmentPromises![0], earlierShipment); assert.deepEqual(game.truthHistory![0], earlierHistory); + assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1])); + game = answer(game, f.target, 'no'); game = settle(act(game, f.target, f.shipment)); + game = settle(act(game, f.target, f.end)); physicalTruthsOnce(game, f); + assert.equal(game.shipmentPromises![0].fulfilled, true); assert.equal(game.travelPromises![0].fulfilled, true); + + // Reverse actual acceptance: the original travel AND Yes requires Polar + // Sink. A later scalar cannot answer No and erase that earlier raw receipt. + game = bind(f, f.game, 0, travelQuestion(f, group('and', ship(), move())), 'yes').game; + const earlierTravel = structuredClone(game.travelPromises![0]); + game = ask(declare(f, game, 1).game, f.asker, f.shipmentQuestion); + assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['yes']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); + assert.deepEqual(game.travelPromises![0], earlierTravel); + assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1])); + game = answer(game, f.target, 'yes'); + assert.deepEqual(game.travelPromiseOrder, [{ kind: 'travel', index: 0 }, { kind: 'shipment', index: 0 }]); + game = settle(act(game, f.target, f.shipment)); game = settle(act(game, f.target, f.move)); + game = settle(act(game, f.target, f.end)); + assert.equal(game.travelPromises![0].fulfilled, true); assert.ok(!game.travelPromises![0].released); + assert.equal(game.shipmentPromises![0].fulfilled, true); assert.ok(!game.shipmentPromises![0].released); + physicalTruthsOnce(game, f); +}); diff --git a/tests/truth-travel-runtime.test.ts b/tests/truth-travel-runtime.test.ts new file mode 100644 index 00000000..481afafe --- /dev/null +++ b/tests/truth-travel-runtime.test.ts @@ -0,0 +1,305 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { truthFactAnswer } from '../game/truthtrance'; +import { + actTruthTravel as act, answerTruthTravel as answer, askTruthTravel as ask, bindTruthTravel as bind, + completeTruthTravel as complete, createTruthTravelFixture as fixture, declareTruthTravel as declare, + finishTruthTravel as finish, settleTruthTravel as settle, truthTravelClean as clean, + truthTravelFact as fact, truthTravelGroup as group, truthTravelInventory as inventory, + truthTravelMove as move, truthTravelPlayer as player, truthTravelPromise as promise, + truthTravelShip as ship, type TruthTravelExpression, type TruthTravelStep, +} from './fixture-truth-travel'; + +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); +} +function physicalTruthOnce(game: Game, truth: string) { + assert.equal(game.discard.filter(card => card.id === truth).length, 1); +} +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 }; +} +const progress = (game: Game, target: string) => game.travelPromises!.find(row => row.player === target)!.progress; + +void test('native travel source freezes only the original Basic prefix before its one paid reserve shipment', () => { + const f = fixture(); + 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.game.advanced, false); assert.deepEqual(f.game.expansions, []); + assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 5); assert.equal(f.game.active, f.target); + assert.equal(f.sourceCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.actions.length); + assert.deepEqual(f.actions, f.activeSource!.actions.slice(0, f.sourceCounts.shipment!)); + assert.deepEqual(f.activeSource!.actions[f.sourceActionCount], f.shipmentStep); + assert.equal(f.sourceCounts.inherited, f.sourceCount + 1); + assert.deepEqual(player(f.game, f.target).forces, { 'arrakeen:10': 10 }); + assert.equal(player(f.game, f.target).reserves, 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.travelPromises?.length ?? 0, 0); assert.equal(f.game.truthtrance ?? null, null); + assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33); + assert.equal(f.setup.deck.filter(card => card.effect === 'truthtrance').length, 2); + assert.equal(f.game.players.flatMap(player => player.hand).filter(card => card.effect === 'truthtrance').length, 1, + 'Only one original Truthtrance has actually been dealt in this source; an undrawn copy is not a second owned answer'); +}); + +void test('native AND Yes requires the original shipment and a distinct actual ground move before own end', () => { + const f = fixture(), source = structuredClone(f.game), beforeSpice = player(f.game, f.target).spice; + const bound = bind(f); let game = bound.game; + assert.deepEqual(f.game, source); assert.deepEqual(nativeControls(game), nativeControls(source)); + assert.deepEqual(promise(game, f.target).mixed, f.mixed); assert.deepEqual(progress(game, f.target), []); + physicalTruthOnce(game, f.truth); rejected(game, f.target, f.currentEnd); + game = settle(act(game, f.target, f.shipmentAction)); + assert.equal(player(game, f.target).spice, beforeSpice - f.shipmentCost); + assert.equal(player(game, f.target).reserves, 9); assert.equal(player(game, f.target).forces['polar_sink:0'], 1); + assert.deepEqual(progress(game, f.target), []); assert.ok(!promise(game, f.target).fulfilled); + rejected(game, f.target, f.currentEnd); + game = settle(act(game, f.target, f.firstMove)); + assert.equal(player(game, f.target).forces['imperial_basin:9'], 1); + assert.equal(player(game, f.target).forces['arrakeen:10'], 9); + assert.deepEqual(progress(game, f.target), [{ territory: 'imperial_basin', maximum: 1 }]); + assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released); + game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native OR Yes retains both legal manual routes without selecting a fixed branch', () => { + const f = fixture(), mixed = group('or', ship(), move()), bound = bind(f, 'yes', mixed).game; + for (const action of [f.shipmentAction, f.firstMove]) { + 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).moved, action.type === 'move' ? 1 : 0); + if (action.type === 'move') assert.equal(player(game, f.target).reserves, 10); + game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards); + } +}); + +void test('native No to AND retains either true component and settles only at the respondent actual end', () => { + const f = fixture(), bound = bind(f, 'no').game; + for (const action of [f.shipmentAction, f.firstMove]) { + let game = settle(act(bound, f.target, action)); + assert.ok(!promise(game, f.target).fulfilled); assert.ok(!promise(game, f.target).released); + if (action.type === 'ship') rejected(game, f.target, f.firstMove); + else rejected(game, f.target, f.shipmentAction); + game = finish(game, f.target); + assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released); + physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards); + } +}); + +void test('native whole OR No forbids either matching action while preserving nonmatching shipment and movement', () => { + const f = fixture(), mixed = group('or', ship(), move()); let game = bind(f, 'no', mixed).game; + rejected(game, f.target, f.shipmentAction); rejected(game, f.target, f.firstMove); + game = settle(act(game, f.target, { ...f.shipmentAction, territory: 'carthag', sector: 11 })); + assert.equal(player(game, f.target).forces['carthag:11'], 1); assert.deepEqual(progress(game, f.target), []); + game = settle(act(game, f.target, f.secondMove)); + assert.equal(player(game, f.target).forces['carthag:11'], 2); assert.ok(!promise(game, f.target).fulfilled); + game = finish(game, f.target); assert.equal(promise(game, f.target).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native shipment leaves describe the same one future original shipment rather than independent events', () => { + const f = fixture(), mixed = group('and', ship(), ship('arrakeen')); + const asked = ask(declare(f).game, f.asker, f.target, mixed); + assert.deepEqual(viewGame(asked, f.target).truthTravelAnswers, ['no']); + rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); + let game = answer(asked, f.target, 'no'); + game = settle(act(game, f.target, f.shipmentAction)); + assert.equal(player(game, f.target).shipped, true); assert.equal(player(game, f.target).reserves, 9); + game = finish(game, f.target); assert.equal(promise(game, f.target).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native shipment into a named destination contributes no physical ground movement progress', () => { + const f = fixture(), mixed = group('and', ship('imperial_basin'), move()); + let game = bind(f, 'yes', mixed).game; + game = settle(act(game, f.target, { ...f.shipmentAction, territory: 'imperial_basin', sector: 9 })); + assert.equal(player(game, f.target).forces['imperial_basin:9'], 1); + assert.deepEqual(progress(game, f.target), []); assert.ok(!promise(game, f.target).fulfilled); + rejected(game, f.target, f.currentEnd); game = settle(act(game, f.target, f.firstMove)); + assert.equal(player(game, f.target).forces['imperial_basin:9'], 2); + assert.deepEqual(progress(game, f.target), [{ territory: 'imperial_basin', maximum: 1 }]); + assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards); +}); + +void test('native future shipment excludes the original shipment already completed before the answer', () => { + const f = fixture({ window: 'shipped' }); assert.equal(player(f.game, f.target).shipped, true); + assert.equal(f.sourceCount, f.sourceCounts.inherited); + const impossible = group('and', ship(), ship('polar_sink', 1)); + const asked = ask(declare(f).game, f.asker, f.target, impossible); + assert.deepEqual(viewGame(asked, f.target).truthTravelAnswers, ['no']); + rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' }); + const mixed = group('or', ship(), move()); let game = bind(f, 'yes', mixed).game; + assert.ok(!promise(game, f.target).fulfilled); assert.deepEqual(progress(game, f.target), []); + rejected(game, f.target, f.currentEnd); game = settle(act(game, f.target, f.firstMove)); + assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards); +}); + +void test('native smaller repeated Hajr groups retain the maximum move rather than their accumulated force total', () => { + const f = fixture(), mixed = group('and', ship(), move('imperial_basin', 2)); + let game = bind(f, 'no', mixed).game; + game = settle(act(game, f.target, f.shipmentAction)); + game = settle(act(game, f.target, f.firstMove)); game = settle(act(game, f.target, f.hajrAction!)); + game = settle(act(game, f.target, f.sameDestinationMove)); + assert.equal(player(game, f.target).forces['imperial_basin:9'], 2); + assert.equal(player(game, f.target).moved, 2); + assert.deepEqual(progress(game, f.target), [{ territory: 'imperial_basin', maximum: 1 }]); + assert.ok(!promise(game, f.target).fulfilled); game = finish(game, f.target); + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native voluntary spending or consuming the last move cannot erase an accepted travel Yes', () => { + const f = fixture({ hajr: false }); let game = bind(f).game; + rejected(game, f.target, { type: 'bribe', target: f.asker, amount: player(game, f.target).spice - 1 }); + game = settle(act(game, f.target, f.shipmentAction)); + rejected(game, f.target, f.secondMove); rejected(game, f.target, f.currentEnd); + assert.ok(!promise(game, f.target).released); game = settle(act(game, f.target, f.firstMove)); + assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards); +}); + +void test('native nonqualifying move remains legal when the genuinely held Hajr preserves the missing route', () => { + const f = fixture(); let game = bind(f).game; + game = settle(act(game, f.target, f.shipmentAction)); game = settle(act(game, f.target, f.secondMove)); + assert.ok(!promise(game, f.target).fulfilled); + const guide = viewGame(game, f.target).travelPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'card'); assert.equal(guide.action.card, f.hajr); + game = complete(game, f.target); assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(player(game, f.target).moved, 2); assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1); + assert.deepEqual(inventory(finish(game, f.target)), f.originalCards); +}); + +void test('native mixed travel freezes the actual current-spice fact before an ordinary paid bribe', () => { + const f = fixture(), mixed = group('and', fact(f.spiceFact), ship(), move()); + const beforeSpice = player(f.game, f.target).spice, beforeBribes = player(f.game, f.asker).bribes; + assert.ok(beforeSpice >= 5); assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes'); + let game = bind(f, 'yes', mixed).game; + game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 }); + assert.equal(player(game, f.target).spice, beforeSpice - 3); + assert.equal(player(game, f.asker).bribes, beforeBribes + 3); + assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); + assert.ok(!promise(game, f.target).fulfilled); rejected(game, f.target, f.currentEnd); + game = settle(act(game, f.target, f.shipmentAction)); game = settle(act(game, f.target, f.firstMove)); + assert.equal(player(game, f.target).spice, beforeSpice - 3 - f.shipmentCost); + assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed); + assert.deepEqual(inventory(finish(game, f.target)), f.originalCards); +}); + +void test('native true held-card OR travel and false held-card AND travel settle without unnecessary actions', () => { + const f = fixture(); + for (const [mixed, answerValue] of [ + [group('or', fact(f.hajrFact), ship(), move()), 'yes'], + [group('and', fact(f.projectileFact), ship(), move()), 'no'], + ] as const) { + const asked = ask(declare(f).game, f.asker, f.target, mixed); + assert.deepEqual(viewGame(asked, f.target).truthTravelAnswers, [answerValue]); + const game = answer(asked, f.target, answerValue); + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(viewGame(game, f.target).travelPromiseGuide, null); + assert.equal(player(game, f.target).shipped, false); assert.equal(player(game, f.target).moved, 0); + assert.deepEqual(inventory(finish(game, f.target)), f.originalCards); + } +}); + +void test('native waiting travel answer preserves the current owner queue until the respondent real shipment and move', () => { + const f = fixture({ window: 'waiting' }); assert.equal(f.game.active, f.asker); + assert.equal(f.sourceCount, f.actions.length); assert.equal(player(f.game, f.target).reserves, 20); + assert.deepEqual(player(f.game, f.target).forces, {}); + const before = nativeControls(f.game); let game = bind(f).game; + assert.deepEqual(nativeControls(game), before); assert.equal(viewGame(game, f.target).travelPromiseGuide, null); + rejected(game, f.target, f.shipmentAction); rejected(game, f.target, f.firstMove); + rejected(game, f.target, f.currentEnd); + game = settle(act(game, f.asker, f.currentEnd)); + assert.equal(game.active, f.target); assert.ok(!promise(game, f.target).fulfilled); + assert.deepEqual(progress(game, f.target), []); + const beforeSpice = player(game, f.target).spice; + game = settle(act(game, f.target, f.shipmentAction)); + assert.equal(player(game, f.target).spice, beforeSpice - f.shipmentCost); + assert.equal(player(game, f.target).reserves, 19); assert.equal(player(game, f.target).forces['arrakeen:10'], 1); + assert.deepEqual(progress(game, f.target), []); assert.ok(!promise(game, f.target).fulfilled); + game = settle(act(game, f.target, f.firstMove)); assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(player(game, f.target).forces['carthag:11'], 1); + game = finish(game, f.target); physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native travel authoring excludes earlier phases and a respondent whose own movement interval has closed', () => { + const charity = fixture({ window: 'charity' }); assert.equal(charity.game.phase, 2); + const declaredCharity = declare(charity); + assert.ok(!viewGame(declaredCharity.game, charity.asker).truthTravelTargets.includes(charity.target)); + rejected(declaredCharity.game, charity.asker, charity.questionAction); + assert.equal(declaredCharity.game.discard.filter(card => card.id === charity.truth).length, 0); + const f = fixture(); let game = finish(f.game, f.target); + assert.equal(game.active, f.asker); assert.ok(!game.movementRemaining?.includes(f.target)); + game = declare(f, game).game; + assert.ok(!viewGame(game, f.asker).truthTravelTargets.includes(f.target)); rejected(game, f.asker, f.questionAction); + assert.ok(player(game, f.asker).hand.some(card => card.id === f.truth)); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native travel projection keeps only the owner raw aggregate while exact next-step guides retain event authority', () => { + const f = fixture(), declared = declare(f); + assert.ok(viewGame(declared.game, f.asker).truthTravelTargets.includes(f.target)); + rejected(declared.game, f.target, f.questionAction); + rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } }); + const asked = ask(declared.game, f.asker, f.target, f.mixed); + assert.equal(viewGame(asked, f.asker).truthTravelAnswers, null); + assert.ok(viewGame(asked, f.target).truthTravelAnswers?.includes('yes')); + rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' }); + let game = answer(asked, f.target, 'yes'); + const own = viewGame(game, f.target), other = viewGame(game, f.asker), row = promise(game, f.target); + const 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(other.travelPromises, []); assert.equal(other.travelPromiseGuide, null); + assert.deepEqual(own.truthHistory.at(-1)?.question, f.question); + assert.deepEqual(own.travelPromises, [row]); + const guide = own.travelPromiseGuide; assert.ok(guide); + assert.equal(guide.event, row.event); assert.equal(guide.action.travelPromiseEvent, row.event); + rejected(game, f.asker, guide.action); + rejected(game, f.target, { ...guide.action, travelPromiseEvent: 'stale-native-travel-event' }); + 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)); + assert.equal(promise(game, f.target).event, row.event); rejected(game, f.target, wire); + game = complete(game, f.target); rejected(game, f.target, guide.action); + physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(finish(game, f.target)), f.originalCards); +}); + +void test('all four minimal profiles answer the raw travel question and perform its exact native completion', () => { + 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: TruthTravelStep[] = []; + 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); + assert.ok(answering.truthTravelAnswers?.includes('yes')); 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.travelPromiseGuide; assert.ok(guide, difficulty); + const action = botActions(view).find(action => action.travelPromiseEvent === 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); + assert.ok(actions.some(step => step.action.type === 'ship'), difficulty); + assert.ok(actions.some(step => step.action.type === 'move'), difficulty); + assert.equal(player(game, f.target).moved, 1); assert.equal(player(game, f.target).reserves, 9); + physicalTruthOnce(game, f.truth); assert.deepEqual(inventory(finish(game, f.target, actions)), f.originalCards); + } +}); + +void test('native raw travel validates every leaf without consuming the physical card at a rejected Ask', () => { + const f = fixture(), declared = declare(f), before = structuredClone(declared.game); + const invalid: TruthTravelExpression[] = [ + group('or', fact(f.hajrFact), ship('not-a-territory'), move()), + group('and', ship(), move('imperial_basin', 0)), + group('and', ship('polar_sink', 21), move()), + ]; + for (const mixed of invalid) rejected(declared.game, f.asker, { type: 'truthAsk', question: { + kind: 'travel', target: f.target, turn: f.game.turn, mixed, + } }); + assert.deepEqual(declared.game, before); assert.ok(clean(f.game)); + assert.ok(player(declared.game, f.asker).hand.some(card => card.id === f.truth)); + assert.deepEqual(inventory(declared.game), f.originalCards); +});