From 8dbec058445357ff2b055f51bdbd17d83989e549 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:17:37 +1100 Subject: [PATCH] Support earlier combined shipment and movement promises --- components/travel-promises.tsx | 80 +++- components/truthtrance.tsx | 2 +- docs/CURRENT_STATUS.md | 41 ++ docs/IMPLEMENTATION_STATUS.md | 44 ++ docs/RULE_DECISIONS.md | 14 + ...RUTHTRANCE_NONBATTLE_READINESS_20260907.md | 7 +- docs/TRUTHTRANCE_TRAVEL_PROMISES.md | 85 ++++ game/bots.ts | 3 +- game/engine.ts | 75 +++- game/reference.ts | 12 +- game/truthtrance.ts | 4 +- tests/fixture-truth-travel-early.ts | 188 +++++++++ tests/truth-travel-controls.test.tsx | 4 +- tests/truth-travel-early-bid.test.ts | 61 +++ tests/truth-travel-early-controls.test.tsx | 273 +++++++++++++ tests/truth-travel-early-revival.test.ts | 84 ++++ tests/truth-travel-early-runtime.test.ts | 381 ++++++++++++++++++ tests/truth-travel-runtime.test.ts | 9 +- 18 files changed, 1321 insertions(+), 46 deletions(-) create mode 100644 tests/fixture-truth-travel-early.ts create mode 100644 tests/truth-travel-early-bid.test.ts create mode 100644 tests/truth-travel-early-controls.test.tsx create mode 100644 tests/truth-travel-early-revival.test.ts create mode 100644 tests/truth-travel-early-runtime.test.ts diff --git a/components/travel-promises.tsx b/components/travel-promises.tsx index ebc880a4..c2ad06d3 100644 --- a/components/travel-promises.tsx +++ b/components/travel-promises.tsx @@ -13,21 +13,58 @@ 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 signature = JSON.stringify(action), auction = game.auction, decision = game.decision; + const native = game.richeseAuction, order = game.revivalOrder, revival = game.revival; + const auctionSource = (current: GameView) => current.auction && JSON.stringify({ + bid: current.auction.bid, bidder: current.auction.bidder, active: current.auction.active, + remaining: current.auction.remaining, allyPayment: current.auction.allyPayment, + }); + const nativeSource = (current: GameView) => current.richeseAuction && JSON.stringify({ + event: current.richeseAuction.event, method: current.richeseAuction.method, + eligible: current.richeseAuction.eligible, submitted: current.richeseAuction.submitted, + active: current.richeseAuction.active, outcome: current.richeseAuction.outcome, + bid: current.richeseAuction.bid, bidder: current.richeseAuction.bidder, + passed: current.richeseAuction.passed, acted: current.richeseAuction.acted, + }); + const auctionSignature = auctionSource(game), nativeSignature = nativeSource(game), orderSignature = JSON.stringify(order); + const box = game.nullentropy, search = box?.search; const canAct = () => { const current = props.game, me = current.players.find(player => player.id === actor); - const paidBox = action.type === 'decision' && current.decision?.kind === 'nullentropy' && + const paidBox = action.type === 'decision' && current.decision === decision && + current.decision?.kind === 'nullentropy' && current.nullentropy === box && current.nullentropy?.search === search && current.decision.player === actor && !!current.nullentropy?.search && current.nullentropy.search.event === action.event && !current.nullentropy.blocked; - const opportunity = paidBox || action.type === 'guildShip' && action.from === 'reserves' || - ['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type); + const currentAuction = current.auction === auction && auctionSource(current) === auctionSignature && !!auction; + const payment = phase === 3 && action.type === 'decision' && typeof action.karama === 'boolean' && + (!action.karama || typeof action.card === 'string') && + current.decision === decision && current.decision?.kind === 'auctionPayment' && current.decision.player === actor && + !current.richeseAuction && currentAuction && current.auction?.bidder === actor; + const nativePass = phase === 3 && action.type === 'richeseBid' && current.richeseAuction === native && + nativeSource(current) === nativeSignature && native && native.event === action.event && !native.outcome && + native.eligible.includes(actor) && (native.method === 'silent' + ? !native.submitted.includes(actor) && action.amount === 0 + : native.active === actor && action.amount === null) && action.allyPayment === 0; + const requiredBid = phase === 3 && action.type === 'bid' && !current.richeseAuction && currentAuction && + current.auction?.active === actor && typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && + action.amount > 0; + const revivalTurn = current.revival === revival && !current.revival.pending && !current.revival.orderBlocked && + current.revivalOrder === order && JSON.stringify(current.revivalOrder) === orderSignature && + (!order || order.turn === turn && order.remaining[0] === actor && current.active === actor); + const opportunity = paidBox || (phase === 2 + ? !current.ready.includes(actor) && ['charity', 'ready', 'card'].includes(action.type) + : phase === 3 + ? payment || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor + : phase === 4 + ? !current.ready.includes(actor) && revivalTurn && ['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready', 'card'].includes(action.type) + : phase === 5 && current.active === actor && (action.type === 'guildShip' && action.from === 'reserves' || + ['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type))); return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' && - phase === 5 && current.phase === phase && current.turn === turn && current.me === actor && current.active === actor && + Number.isSafeInteger(phase) && phase >= 0 && phase <= 5 && current.phase === phase && current.turn === turn && current.me === actor && current.travelPromiseGuide === guide && guide.event === event && guide.action === action && - action.travelPromiseEvent === event && JSON.stringify(action) === signature && opportunity && + action.travelPromiseEvent === event && action.travelPromisePhase === phase && 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) && + !!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || payment) && !current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; }; const amount = action.forces && typeof action.forces === 'object' @@ -35,10 +72,27 @@ export function TravelPromiseGuideButton(props: TravelProps) { : 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` + // A projected own face supplies the label only; the native guide owns legality. + const paymentCard = action.type === 'decision' && action.karama === true + ? game.players.find(player => player.id === actor)?.hand?.find(card => card.id === action.card) : undefined; + const label = action.type === 'charity' ? 'Claim Charity for your later shipment and movement' + : action.type === 'ready' ? 'Finish this preparation phase while preserving your whole answer' + : action.type === 'passBid' ? 'Pass this bid and preserve later shipment and movement' + : action.type === 'bid' ? `Place the required bid of ${amount} while preserving later shipment and movement` + : action.type === 'richeseBid' ? action.amount === 0 + ? 'Submit zero in this Silent auction and preserve later shipment and movement' + : 'Pass this Richese bid and preserve later shipment and movement' + : action.type === 'revive' ? `Revive ${amount} forces for your later shipment and movement` + : action.type === 'reviveLeader' ? 'Revive the quoted leader for your later shipment and movement' + : action.type === 'acceptLeaderRevival' ? 'Accept the quoted leader revival for your later shipment and movement' + : action.type === 'reviveKwisatz' ? 'Revive your Kwisatz Haderach for your later shipment and movement' + : 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 === 'decision' ? game.decision?.kind === 'auctionPayment' + ? action.karama === true ? `Use ${paymentCard?.name ?? 'the quoted Karama'} for the current auction payment while preserving your whole answer` + : 'Pay the current auction price while preserving your whole answer' + : 'Complete your paid Box search 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'; @@ -71,8 +125,9 @@ export function TravelPromises(props: TravelProps) { 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 && (game.phase < 5 || game.active !== game.me) &&

Your Movement turn is still ahead. Keep the needed forces, spice and cards available; + movement controls appear on your turn. + {game.phase < 5 && ' Complete any offered current preparation below before confirming readiness.'}

} {live && } ; } @@ -85,7 +140,8 @@ export function TravelAnswers(props: TravelProps) { 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 && + return !props.busy && current === game && current.status === 'playing' && + Number.isSafeInteger(phase) && phase >= 0 && 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 && diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index ea65196a..ebcb6b02 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -589,7 +589,7 @@ export function Truthtrance({ <> - {!g.truthTravelTargets.includes(target) &&

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

} + {!g.truthTravelTargets.includes(target) &&

Choose this turn’s upcoming, active or unused native Shipment and Movement opportunity.

}

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

) : kind === 'mixedTraitorCall' ? ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f16c0831..7037a93a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,47 @@ open mode gates. ## Current checkpoint and work +**Truthtrance / earlier combined shipment and movement — 26 October. Prototyped / Partial.** +The combined travel question now uses the existing clean native phases0–5 +forecast in Basic/Advanced. Original current Charity/Revival/auction obligations +share funding; required native bid/Revival actions precede pass or Ready. +An old prior-turn shipped flag never counts as current progress or closes the +future shipment. Every definite answer uniformly reopens only concurrent +Charity/legacy Revival readiness, including constants; completed ordered +Advanced Revival remains complete. Exact current-phase native packets include +phase/event metadata, preserving owned cash/Karama invoices and paid Box search. +Mandatory early progress can release impossibility; discretionary spending or +completion cannot remove the last route. Private histories and four minimal +profiles retain the original whole-answer/native-event meaning. + +Root integration with disjoint controls/native lanes and bounded review passes +**23 files,197/197**, types/lint/build and **three native paths**,25 GameTable SSR +renders/three actual answer callbacks/16 guide callbacks. Original owned-Karama +Revival-source Charity53 adds32 for native Ready, bid2, actual owned cash invoice, +Revival and later paid ship2/ground2. Original Hajr Charity13 adds16 for frozen +spice, real bribe3, paid shipment, ground move, physical Hajr and second move. +Original earned post-Storm41 adds24: the real prior shipment flag remains true +when answering, current progress starts empty, then the later ship/move fulfills. +Original physical card inventories remain; no played source state is installed. + +Nineteen native cases cover phases0–4, readiness, native auctions, current costs, +prior-turn flags and four profiles; ten early control cases guard current source/ +phase/owner. Two genuine two-Truth bid cases retain funding and required bids in +both orders. Three conserved controlled Revival cases reject double reservation +and perform the required return before Ready, including four minimal profiles. +V1 catches an allied-destination test assumption; the corrected Basin escrow +case defers release to actual foreign income/cancellation. Native bid/Revival +resource and guide repairs pass focused regressions. V2 passes all source checks; +only its private smoke driver over-settles the invoice. Corrected runtime v3 +passes on the identical snapshot and explicitly reuses v2's unchanged checks. +Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-travel-early-proof-v3/`; +journeys/HTML: adjacent `20261026-truth-travel-early-runtime/`. +No saved-game/database/server/reset/restart/deployment operation is performed. +Other cross-action families, wider preparation/module compositions and final +rules/browser/deployed/assurance remain. No complete mode is certified. +[Authority, early forecast and source-bound evidence](TRUTHTRANCE_TRAVEL_PROMISES.md#earlier-native-phases--26-october-2026). + **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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index a575a650..6f4b3522 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,49 @@ # Dune implementation status +## 26 October 2026 — Earlier combined shipment and movement commitments + +**Prototyped / Partial.** +The combined travel question now uses the existing clean native phases0–5 +forecast in Basic/Advanced. Original current Charity/Revival/auction obligations +share funding; required native bid/Revival actions precede pass or Ready. +An old prior-turn shipped flag never counts as current progress or closes the +future shipment. Every definite answer uniformly reopens only concurrent +Charity/legacy Revival readiness, including constants; completed ordered +Advanced Revival remains complete. Exact current-phase native packets include +phase/event metadata, preserving owned cash/Karama invoices and paid Box search. +Mandatory early progress can release impossibility; discretionary spending or +completion cannot remove the last route. Private histories and four minimal +profiles retain the original whole-answer/native-event meaning. + +Root integration with disjoint controls/native lanes and bounded review passes +**23 files,197/197**, types/lint/build and **three native paths**,25 GameTable SSR +renders/three actual answer callbacks/16 guide callbacks. Original owned-Karama +Revival-source Charity53 adds32 for native Ready, bid2, actual owned cash invoice, +Revival and later paid ship2/ground2. Original Hajr Charity13 adds16 for frozen +spice, real bribe3, paid shipment, ground move, physical Hajr and second move. +Original earned post-Storm41 adds24: the real prior shipment flag remains true +when answering, current progress starts empty, then the later ship/move fulfills. +Original physical card inventories remain; no played source state is installed. + +Nineteen native cases cover phases0–4, readiness, native auctions, current costs, +prior-turn flags and four profiles; ten early control cases guard current source/ +phase/owner. Two genuine two-Truth bid cases retain funding and required bids in +both orders. Three conserved controlled Revival cases reject double reservation +and perform the required return before Ready, including four minimal profiles. +V1 catches an allied-destination test assumption; the corrected Basin escrow +case defers release to actual foreign income/cancellation. Native bid/Revival +resource and guide repairs pass focused regressions. V2 passes all source checks; +only its private smoke driver over-settles the invoice. Corrected runtime v3 +passes on the identical snapshot and explicitly reuses v2's unchanged checks. +Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-travel-early-proof-v3/`; +journeys/HTML: adjacent `20261026-truth-travel-early-runtime/`. +No saved-game/database/server/reset/restart/deployment operation is performed. +Other cross-action families, wider preparation/module compositions and final +rules/browser/deployed/assurance remain. No complete mode is certified. +[Authority, early forecast and source-bound evidence](TRUTHTRANCE_TRAVEL_PROMISES.md#earlier-native-phases--26-october-2026). + + ## 26 October 2026 — Combined shipment and physical movement commitments **Prototyped / Partial.** diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 969fa432..cdb3d20c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Earlier combined travel and native preparation — 26 October 2026 + +Extend the recorded combined shipment/ground prototype through the existing +native phases0–5 envelope, reusing the provisional owned-stage forecast and +publisher FAQp8/current-turn plus supplied Advanced physical39 authority. +Prior-turn shipment flags never become current future-event progress. Earlier +Charity/Revival readiness uniformly reopens for every definite answer, including +constants, so public Ready does not disclose a frozen private branch. Completed +ordered Advanced Revival stays complete. Prior Revival/bribe obligations share +funding with travel. Current-phase native guides carry exact phase/event source; +mandatory early progress can release impossibility, discretionary spending or +preparation completion cannot evade it. Broader cross-action/module/full-mode +acceptance remains unfinished. [Contract/evidence](TRUTHTRANCE_TRAVEL_PROMISES.md#earlier-native-phases--26-october-2026). + ## Combined shipment and physical movement commitments — 26 October 2026 Publisher November2020 FAQp8 permits current-turn AND/OR action commitments; diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md index baae9127..5263f7e0 100644 --- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md +++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md @@ -1,9 +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. +shipment and physical ground-movement conditions, optionally with frozen facts. +Its later early-phase follow-up uses the existing native phases0–5 envelope and +current preparation controls. Each event retains its physical meaning; other +cross-action families, wider timing/compositions and full 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. diff --git a/docs/TRUTHTRANCE_TRAVEL_PROMISES.md b/docs/TRUTHTRANCE_TRAVEL_PROMISES.md index d8e7653d..f88e180a 100644 --- a/docs/TRUTHTRANCE_TRAVEL_PROMISES.md +++ b/docs/TRUTHTRANCE_TRAVEL_PROMISES.md @@ -1,5 +1,90 @@ # Truthtrance combined shipment and physical movement +## Earlier native phases — 26 October 2026 + +**Prototyped / Partial.** The same raw question now uses the existing clean +native movement-target envelope in Storm, Spice Blow, Charity, Bidding, Revival +and Shipment and Movement (phases0–5), in both Basic and Advanced. Registered +factions retain their existing selected native-deck and optional-module scope. +This supersedes only the earlier phase5 entry restriction documented below. +No shipment, movement, payment or card action happens merely by answering. + +Reuse the existing owned-stage forecast described in +[earlier native movement](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-early-advanced-and-choam-opening--26-october-2026). +Original Charity/normal Revival/current auction liabilities and owned known +preparations remain authoritative. No unknown future card, foreign gift or +favorable future Storm supplies a witness. Prior Revival and bribe obligations +share the same future purse; a final Revival aggregate must retain combined +travel feasibility rather than spend the same spice twice. + +An early record starts with its future shipment pending even when the actual +player still has last turn's `shipped` flag. That old physical event never +counts as this answer's progress. Original phase5 initialization remains in +charge of real allowances. Actual current-turn native shipment/ground hooks +record the same distinct receipts and physical maxima; no new event is granted. + +A definite answer uniformly reopens only the respondent's concurrent Charity +or legacy Revival readiness, including constant answers. This avoids exposing +a private fact branch through a public Ready difference. Constant answers are +still terminal without action receipts or a private future guide. Ordered +Advanced Revival never reopens an already completed owned turn. A required +owned preparation or optional spending must preserve the last legal route; +mandatory early progress and completed opposing effects can release actual +impossibility rather than trapping the actor. Pending native outcomes retain +priority. + +The private guide now offers only an entitled action in the current phase: +Charity, Ready, ordinary Revival, current native bid pass or an owned invoice, +then the existing phase5 transport/movement/card controls. Original cash or +physical Karama payment remains a choice of the actual invoice; exact projected +packets preserve that source. Required native bid/Revival commitments take +priority over passing or Ready in the guide; their native joint completion +retains travel funding. Pending allied auction escrow waits for its actual +foreign payment/income outcome before impossible travel can release. A source-bound paid Box search retains its actual +phase. No future transport is exposed before phase5. Each packet includes +`travelPromisePhase` as well as its event: an old Charity Ready cannot be +replayed as a later Revival Ready. The server and saved callbacks verify the +current source and exact packet. Four minimal profiles use that same path. + +New source/control evidence: `tests/fixture-truth-travel-early.ts`, +`tests/truth-travel-early-runtime.test.ts`, +`tests/truth-travel-early-controls.test.tsx`. Separate conserved controlled +`tests/truth-travel-early-revival.test.ts` cases actually declare both original +Truth cards in opposite acceptance orders to reject a shared-purse conflict; +they claim no setup/casualty history. Native +`tests/truth-travel-early-bid.test.ts` uses genuine Harkonnen opening custody to +protect pending bid funding and legal required declarations in both answer +orders. + +Frozen verification passes **23 files,197/197**, types/lint/build and three native +runtime journeys with 25 GameTable SSR renders, three actual answer callbacks and +16 guide callbacks. Original owned-Karama Charity53 adds32: real bid2 and current +cash invoice precede Revival and later paid ship2/ground2. Hajr Charity13 adds16: +frozen spice, actual bribe3, paid shipment and two real groups with original Hajr. +Earned post-Storm41 adds24, retaining its true prior shipped flag while the new +future shipment remains pending with empty progress. Every original physical +card inventory remains. Nineteen native and ten early control cases supplement +existing movement/shipment/Revival/funding evidence. Two original Harkonnen +opening-custody bid cases exercise both answer orders; three conserved controlled +Revival cases exercise incompatibility and required actual returns. Four minimal +profiles consume legal current-phase guides. + +V1 source feedback finds a controlled ally's Carthag garrison invalidates the +chosen destination, so the escrow case uses the original audit's Basin route. +Actual foreign invoice/income deferral and native required-bid/Revival guide/ +funding corrections pass focused regressions. V2 passes types/lint, the full +197-case union and build. Its private smoke driver then auto-settles an owned +invoice before inspecting it. Only that driver changes; corrected runtime v3 +advances one native action at a time. `verified-proof.json` explicitly reuses +v2's three passing checks on an identical before/after source snapshot. +Proof and per-file manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-travel-early-proof-v3/`; +journeys/HTML: adjacent `20261026-truth-travel-early-runtime/`. +This is local engine/SSR/actual-callback evidence, not DOM/hydration/browser/ +mobile/deployed acceptance or complete-mode certification. +Other cross-action families, wider owned preparations/module compositions and +final rules/browser/mobile/deployed/recovery/privacy/custody acceptance remain. + ## Native travel prototype — 26 October 2026 **Prototyped / Partial.** The question is `{kind:'travel',target,turn,mixed}`. diff --git a/game/bots.ts b/game/bots.ts index baa27f94..9af859c2 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -4263,7 +4263,8 @@ 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 && + if (g.travelPromiseGuide && g.status === 'playing' && g.phase >= 0 && g.phase <= 5 && + (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' && diff --git a/game/engine.ts b/game/engine.ts index 6baea348..42a4bdcf 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -15708,6 +15708,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? [] // The detached accepted declaration satisfies its own Bid Yes before // another promise checks the remaining purse; it is not a second bid. trial.bidPromises = recordPromisedBid(promises, p.id, opportunity, action.amount as number); + reconcileTravelPromises(trial, p.id, action, g.phase); reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase }); reconcileGroundMovementPromises(trial, p.id, action, g.phase); reconcileRevivalPromises(trial, { actor: p.id, action }); @@ -18661,7 +18662,7 @@ function travelRule(operation: () => T): T { 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); + return movementPromiseTargetAvailable(g, actor); } function appendTravelOrder(g: Game, kind: NonNullable[number]['kind'], index: number) { g.travelPromiseOrder?.push({ kind, index }); @@ -18717,10 +18718,18 @@ function travelTruthAnswers(g: Game, p: Player, claim: TravelClaim): TruthAnswer 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, + shipment: { pending: g.phase < 5 || 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'); + const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) + ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; + // Candidate witnesses must include the new row, which has no accepted-order + // slot until the real answer binds. The authoritative order stays untouched. + const trial = { ...answerSource, travelPromises: [...(g.travelPromises ?? []), promise], travelPromiseOrder: undefined }; + const lot = bidOpportunity(trial); + if (travelCompletion(trial, p.id) && revivalCompatibleLaterPromises(trial, p, {}) && + bribeCompatibleLaterPromises(trial, p, {}) && + (!lot || !bidRequired(trial.bidPromises ?? [], p.id, lot) || !!bidPromiseCompletion(trial, p))) + answers.push(answer ? 'yes' : 'no'); } return answers.length ? answers : ['unknown']; } @@ -18729,30 +18738,39 @@ function bindTravelTruth(g: Game, p: Player, claim: TravelClaim, answer: boolean '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); + reopenMovementPreparation(g, p); 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, + compiled, shipment: { pending: g.phase < 5 || 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) { +function reconcileTravelPromises(g: Game, actor?: string, action?: Action, sourcePhase = g.phase) { 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; + if (g.phase < 0 || 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 || + const early = g.phase < 5 || sourcePhase < 5; + const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action && + (['card', 'bribe', 'alliance', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', + 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) || + action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 || + action.type === 'ready' && [2, 4].includes(sourcePhase))); + if (g.truthtrance || !voluntary && 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; + g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || g.pendingTreacheryDiscard || + g.phase === 3 && !g.currentAuctionSale && + (g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 || + g.richeseAuction && !g.richeseAuction.outcome))) 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 @@ -18780,9 +18798,31 @@ function projectedTravelPromises(g: Game, actor: string) { } 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 lot = promise && g.phase === 3 && !g.decision ? bidOpportunity(g) : null; + const needsBid = !!lot && bidRequired(g.bidPromises ?? [], player.id, lot); + const requiredBid = needsBid ? projectedBidPromiseGuide(g, player.id) : null; + const needsRevival = !!promise && g.phase <= 4 && [ + ...liveRevivalPromises(g.revivalPromises ?? [], player.id, g.turn), + ...liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], player.id, g.turn), + ...liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player.id, g.turn), + ].some(record => record.answer); + const requiredRevival = !requiredBid && needsRevival + ? projectedRevivalExpressionPromiseGuide(g, player.id) ?? projectedLeaderRevivalPromiseGuide(g, player.id) ?? + projectedRevivalPromiseGuide(g, player.id) : null; + const prior = requiredBid ?? requiredRevival; + const paidBox = g.decision?.kind === 'nullentropy' && g.pendingNullentropy?.player === player.id; + if (promise && (needsBid || needsRevival) && !paidBox) { + if (!prior) return null; + const action = { ...structuredClone(prior.action) }; + delete action.bidPromiseEvent; + delete action.revivalExpressionPromiseEvent; + delete action.leaderRevivalPromiseEvent; + delete action.revivalPromiseEvent; + return { event: promise.event, action: { ...action, travelPromiseEvent: promise.event, travelPromisePhase: g.phase } }; + } + 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; + return promise && action ? { event: promise.event, action: { ...structuredClone(action), travelPromiseEvent: promise.event, travelPromisePhase: g.phase } } : null; } function groundMovementCompletion(source: Game, actor: string, promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn), @@ -27617,7 +27657,7 @@ 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, + if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn, shipment ? { territory: shipment.territory, amount: shipment.amount } : null)); for (const promise of liveShipmentPromises( g.shipmentPromises ?? [], @@ -27639,7 +27679,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)); + if (g.phase === 5 && g.travelPromises) g.travelPromises = travelRule(() => recordTravelShipment(g.travelPromises!, p.id, g.turn, null)); for (const promise of liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn)) { if (matchesShipment(promise, null) === promise.answer) promise.fulfilled = true; else { @@ -31016,6 +31056,7 @@ function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival) if (!trial.ready.includes(p.id)) trial.ready.push(p.id); try { const action = { type: 'ready' }; + reconcileTravelPromises(trial, p.id, action, g.phase); reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase }); reconcileGroundMovementPromises(trial, p.id, action, g.phase); return true; @@ -36316,9 +36357,9 @@ 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) { + if (action.travelPromiseEvent !== undefined || action.travelPromisePhase !== undefined) { const guide = projectedTravelGuide(state, getPlayer(state, id)); - requireRule(guide && movementGuideActionMatches(action, guide.action), + requireRule(guide && action.travelPromisePhase === state.phase && movementGuideActionMatches(action, guide.action), 'This travel guide is stale or belongs to another opportunity.'); } if (action.movementExpressionPromiseEvent !== undefined) { @@ -36351,7 +36392,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); + reconcileTravelPromises(g, id, action, state.phase); reconcileBidPromises(g, { actor: id, action }); reconcileRevivalPromises(g, { actor: id, action }); reconcileBribePromises(g, { actor: id, action }); diff --git a/game/reference.ts b/game/reference.ts index ef465cf5..a98ee62b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3191,20 +3191,20 @@ export const RULE_TOPICS: RuleTopic[] = [ 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.', + 'From Storm through Shipment and Movement, choose Combine this turn’s shipment and ground movements for a native respondent with an upcoming or active opportunity. Full AND/OR groups can include ship, move and optional current-fact clauses. Other action families and wider compositions remain unfinished.', + 'Each shipment condition refers to the same one future original reserve shipment after answering. A current-turn shipment used before answering does not count; last turn’s shipment does not close this turn’s upcoming opportunity. 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.', + 'Review the entitled current-phase preparation, bid pass or owned invoice, then the native shipment/movement step; other legal manual routes remain. A definite answer uniformly reopens concurrent Charity or legacy Revival readiness, including constants. Completed ordered Advanced Revival stays complete. Prior Revival and bribe payments retain their funding. Private own history persists; 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: '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', 'tests/truth-travel-early-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', 'tests/truth-travel-early-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'] }, + { 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', 'tests/fixture-truth-travel-early.ts', 'tests/truth-travel-early-runtime.test.ts', 'tests/truth-travel-early-revival.test.ts', 'tests/truth-travel-early-bid.test.ts', 'tests/truth-travel-early-controls.test.tsx'] }, ], }, { diff --git a/game/truthtrance.ts b/game/truthtrance.ts index d43cc4f0..e1c177a4 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -374,8 +374,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { } 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.'); + Number.isInteger(g.phase) && g.phase >= 0 && g.phase <= 5 && v.turn === g.turn, + 'Choose this turn’s upcoming or active Shipment and Movement 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, diff --git a/tests/fixture-truth-travel-early.ts b/tests/fixture-truth-travel-early.ts new file mode 100644 index 00000000..c4a33cf6 --- /dev/null +++ b/tests/fixture-truth-travel-early.ts @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { createTruthMovementEarlyFixture } from './fixture-truth-movement-early'; +import { + createTruthMovementNativeEarlyFixture, nextTruthMovementNativeEarlyStep, + type TruthMovementNativeEarlyAuction, type TruthMovementNativeEarlyRoster, +} from './fixture-truth-movement-native-early'; +import { createTruthMixedMovementFixture } from './fixture-truth-mixed-movement'; +import { createTruthRevivalEarlyFixture } from './fixture-truth-revival-early'; +import { + actTruthTravel, truthTravelClean, truthTravelFact, truthTravelGroup, + truthTravelInventory, truthTravelMove, truthTravelPlayer, truthTravelPromise, + truthTravelShip, type TruthTravelExpression, type TruthTravelStep, +} from './fixture-truth-travel'; + +export type TruthTravelEarlyStep = TruthTravelStep; +export const actTruthTravelEarly = actTruthTravel; +export const truthTravelEarlyClean = truthTravelClean; +export const truthTravelEarlyInventory = truthTravelInventory; +export const truthTravelEarlyPlayer = truthTravelPlayer; +export const truthTravelEarlyPromise = truthTravelPromise; +export { truthTravelFact, truthTravelGroup, truthTravelMove, truthTravelShip }; +export type { TruthTravelExpression }; + +/** Replay current native controls. A private guide always belongs to this + * phase and event; the source's real auction and response controls do the rest. */ +export function nextTruthTravelEarly(game: Game, + auction: TruthMovementNativeEarlyAuction = {}): TruthTravelEarlyStep | 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 }; + } + if (game.players.some(player => ['richese', 'choam', 'ecaz', 'moritani', 'ixians', 'tleilaxu'].includes(player.faction))) + return nextTruthMovementNativeEarlyStep(game, auction); + if (!game.response && !game.phaseOpening && game.decision?.kind === 'revivalStop') + return { actor: game.decision.player, action: { type: 'decision', decline: true } }; + return nextStrongholdFactionsNativeStep(game); +} +export function advanceTruthTravelEarly(game: Game, until: (state: Game) => boolean, + actions: TruthTravelEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original early travel source ended before its native boundary'); + const next = nextTruthTravelEarly(game, auction); + assert.ok(next, 'Supply the actual Truthtrance question, answer or owned movement before continuing'); + game = actTruthTravelEarly(game, next.actor, next.action, actions); + } + throw Error('The original early travel source did not reach its finite boundary'); +} +export function settleTruthTravelEarly(game: Game, actions: TruthTravelEarlyStep[] = [], + auction: TruthMovementNativeEarlyAuction = {}): Game { + return advanceTruthTravelEarly(game, state => truthTravelEarlyClean(state) && !state.pendingShipment && + !state.pendingRevival, actions, auction); +} + +/** Original sources only. Earlier snapshots exclude the later factory suffix; + * unopened Spice selections are reapplied before their real phase controls. + * The earned Revival source includes a real paid shipment and Storm casualties, + * so its previous-turn shipment flag needs no controlled installation. */ +export function createTruthTravelEarlyFixture(options: { + source?: 'classic' | 'hajr' | 'native' | 'revival'; phase?: 0 | 1 | 2 | 3 | 4; + advanced?: boolean; hajr?: boolean; poor?: boolean; roster?: TruthMovementNativeEarlyRoster; + targetKarama?: boolean; minimum?: number; mixed?: TruthTravelExpression; +} = {}) { + const kind = options.source ?? 'classic', phase = options.phase ?? 2; + const classicSource = kind === 'classic' + ? createTruthMovementEarlyFixture({ phase: phase < 2 ? 0 : 2 }) : null; + const hajrSource = kind === 'hajr' + ? createTruthMixedMovementFixture({ advanced: options.advanced, hajr: options.hajr, window: 'charity' }) : null; + const nativeSource = kind === 'native' ? createTruthMovementNativeEarlyFixture({ roster: options.roster }) : null; + const revivalSource = kind === 'revival' ? createTruthRevivalEarlyFixture({ + advanced: options.advanced, phase: phase === 4 ? 3 : phase, poor: options.poor, + targetKarama: options.targetKarama, + }) : null; + const original = classicSource ?? hajrSource ?? nativeSource ?? revivalSource!; + let game = structuredClone(original.game), actions: TruthTravelEarlyStep[] = structuredClone(original.actions); + const initialPrefixCount = actions.length; + const selectedSpice: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + if (phase < 2 && (hajrSource || nativeSource)) { + const base = hajrSource ?? nativeSource!; + assert.ok(base.afterSetupActionCount !== null); + game = structuredClone(base.afterSetup); + actions = structuredClone(base.actions.slice(0, base.afterSetupActionCount)); + for (const { position, card } of base.spiceOrder) { + const from = game.spiceDeck.findIndex((candidate, index) => index >= position && + JSON.stringify(candidate) === JSON.stringify(card)); + assert.ok(from >= position, 'Select the same original still-unopened Spice card'); + const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected); + selectedSpice.push({ position, from, card: structuredClone(selected) }); + } + } + if (game.phase !== phase) game = advanceTruthTravelEarly(game, + state => state.phase === phase && truthTravelEarlyClean(state), actions); + const asker = original.asker, target = original.target; + const truth = classicSource?.truth ?? hajrSource?.truth ?? nativeSource?.truth ?? revivalSource!.truth; + const originalCards = original.originalCards; + const minimum = options.minimum ?? (hajrSource ? 1 : revivalSource ? 2 : 8); + const shipmentAction: Action = hajrSource + ? { type: 'ship', territory: 'polar_sink', sector: 0, amount: minimum, elite: 0, allyPayment: 0 } + : { type: 'ship', territory: 'arrakeen', sector: 10, amount: minimum, elite: 0, allyPayment: 0 }; + const firstMove: Action = hajrSource + ? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: minimum } + : { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: minimum }; + const secondMove: Action = { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: minimum }; + const mixed = options.mixed ?? truthTravelGroup('and', + truthTravelShip(String(shipmentAction.territory), minimum), truthTravelMove(String(firstMove.territory), minimum)); + const question = { kind: 'travel' as const, target, turn: game.turn, mixed }; + assert.equal(game.status, 'playing'); assert.equal(game.phase, phase); + assert.ok(truthTravelEarlyClean(game)); + assert.equal(game.travelPromises?.length ?? 0, 0); + assert.deepEqual(truthTravelEarlyInventory(game), originalCards); + assert.ok(truthTravelEarlyPlayer(game, asker).hand.some(card => card.id === truth)); + const sourceDisclosure = 'sourceDisclosure' in original ? original.sourceDisclosure : original.staging.join(' '); + return { + game, source: structuredClone(game), kind, phase, base: original, + classicSource, hajrSource, nativeSource, revivalSource, + asker, target, truth, card: truth, hajr: hajrSource?.hajr ?? null, + setup: original.setup, lobby: original.lobby, beforeStartingDeal: original.beforeStartingDeal, + afterSetup: original.afterSetup, originalCards, originalSpice: original.originalSpice, + dealOrder: original.dealOrder, spiceOrder: original.spiceOrder, selectedSpice, + actions, sourceActions: actions, sourceActionCount: actions.length, + sourceCounts: { inherited: initialPrefixCount, selected: actions.length, + setup: actions.findIndex(step => step.action.type === 'start') + 1, + priorShipments: actions.filter(step => step.actor === target && step.action.type === 'ship').length, + priorRevivals: actions.filter(step => step.actor === target && step.action.type === 'revive').length }, + minimum, mixed, question, shipmentAction, firstMove, secondMove, + shipmentCost: minimum * (hajrSource ? 2 : 1), + declaration: { type: 'card', card: truth } satisfies Action, + questionAction: { type: 'truthAsk', question } satisfies Action, + hajrAction: hajrSource?.hajr ? { type: 'card', card: hajrSource.hajr } satisfies Action : null, + currentEnd: { type: 'endMovement' } satisfies Action, + sourceDisclosure: `${sourceDisclosure} This early travel adapter retains only the recorded ${actions.length}-action ` + + `phase${phase} prefix. The caller supplies the whole travel question, answer and all later real actions. ` + + 'No card, dealt hand, wallet, army, clock, ready list, old shipment flag, promise or receipt is installed.', + }; +} +export type TruthTravelEarlyFixture = ReturnType; + +export function declareTruthTravelEarly(f: TruthTravelEarlyFixture, source = f.game, + actions: TruthTravelEarlyStep[] = []) { + let game = actTruthTravelEarly(source, f.asker, f.declaration, actions); + const opened = structuredClone(game); + while (game.truthtrance?.stage === 'priority') { + const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player); + game = actTruthTravelEarly(game, player.id, { type: 'truthPass' }, actions); + } + assert.equal(game.truthtrance?.stage, 'ask'); + return { game, opened, actions }; +} +export function askTruthTravelEarly(f: TruthTravelEarlyFixture, source = f.game, + mixed = f.mixed, actions: TruthTravelEarlyStep[] = []) { + const declared = declareTruthTravelEarly(f, source, actions); + assert.ok(viewGame(declared.game, f.asker).truthTravelTargets.includes(f.target)); + const game = actTruthTravelEarly(declared.game, f.asker, { type: 'truthAsk', question: { + kind: 'travel', target: f.target, turn: source.turn, mixed: structuredClone(mixed), + } }, actions); + return { game, declared, actions }; +} +export function bindTruthTravelEarly(f: TruthTravelEarlyFixture, answer: 'yes' | 'no' = 'yes', + mixed = f.mixed, source = f.game, actions: TruthTravelEarlyStep[] = []) { + const asked = askTruthTravelEarly(f, source, mixed, actions); + assert.ok(viewGame(asked.game, f.target).truthTravelAnswers?.includes(answer)); + const game = settleTruthTravelEarly(actTruthTravelEarly(asked.game, f.target, + { type: 'truthAnswer', answer }, actions), actions); + return { game, asked, actions, actionCount: actions.length }; +} +export function reachTruthTravelEarlyMovement(f: TruthTravelEarlyFixture, game: Game, + actions: TruthTravelEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}) { + return advanceTruthTravelEarly(game, state => state.phase === 5 && state.active === f.target && + truthTravelEarlyClean(state) && !state.pendingShipment, actions, auction); +} +export function finishTruthTravelEarly(f: TruthTravelEarlyFixture, game: Game, + actions: TruthTravelEarlyStep[] = []): Game { + game = settleTruthTravelEarly(game, actions); + assert.equal(game.phase, 5); assert.equal(game.active, f.target); + game = settleTruthTravelEarly(actTruthTravelEarly(game, f.target, f.currentEnd, actions), actions); + assert.ok(!game.movementRemaining?.includes(f.target)); + return game; +} +export function completeTruthTravelEarly(f: TruthTravelEarlyFixture, game: Game, + actions: TruthTravelEarlyStep[] = []): Game { + return advanceTruthTravelEarly(game, state => { + const row = truthTravelEarlyPromise(state, f.target); + return !!row.fulfilled || !!row.released; + }, actions); +} diff --git a/tests/truth-travel-controls.test.tsx b/tests/truth-travel-controls.test.tsx index e9d19f93..ff11264a 100644 --- a/tests/truth-travel-controls.test.tsx +++ b/tests/truth-travel-controls.test.tsx @@ -54,7 +54,8 @@ function projected(next: Action = { type: 'move', forces: { 'arrakeen:10': 3, 'a 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.travelPromiseGuide = { event: 'controlled-travel-event', action: { ...next, + travelPromiseEvent: 'controlled-travel-event', travelPromisePhase: game.phase } }; game.truthTravelAnswers = null; return game; } @@ -206,6 +207,7 @@ void test('travel guide rejects stale view, actor, source, turn, phase, native o game => { game.travelPromiseGuide = { ...game.travelPromiseGuide!, action: { ...game.travelPromiseGuide!.action } }; }, game => { game.travelPromiseGuide!.event = 'changed'; }, game => { game.travelPromiseGuide!.action.travelPromiseEvent = 'changed'; }, + game => { game.travelPromiseGuide!.action.travelPromisePhase = 4; }, 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'; }, diff --git a/tests/truth-travel-early-bid.test.ts b/tests/truth-travel-early-bid.test.ts new file mode 100644 index 00000000..b8943e1d --- /dev/null +++ b/tests/truth-travel-early-bid.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { createTruthBidFixture, truthBidInventory } from './fixture-truth-bid'; + +/** Genuine Harkonnen two-Truth opening at the original current ordinary lot. + * Both questions use distinct actually dealt cards; no ledger is installed. */ +function ask(game: Game, asker: string, card: string, question: unknown) { + game = applyAction(game, asker, { 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, asker, { type: 'truthAsk', question }); +} +function questions(f: ReturnType, minimum: number) { + return { + bid: { kind: 'bid', target: f.target, turn: f.game.turn, lot: f.game.auction!.index + 1, minimum: 4 }, + travel: { kind: 'travel', target: f.target, turn: f.game.turn, mixed: { kind: 'and', terms: [ + { kind: 'action', action: { kind: 'ship', territory: 'arrakeen', minimum } }, + { kind: 'action', action: { kind: 'move', territory: 'carthag', minimum } }, + ] } }, + }; +} +void test('native compatible bid and travel answers in either order offer the required declaration before any pass', () => { + for (const bidFirst of [true, false]) { + const f = createTruthBidFixture({ window: 'active' }), q = questions(f, 1); + let game = ask(f.game, f.asker, f.truths[0], bidFirst ? q.bid : q.travel); + game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = ask(game, f.asker, f.truths[1], bidFirst ? q.travel : q.bid); + game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + const guide = viewGame(game, f.target).travelPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4); + assert.equal(guide.action.travelPromisePhase, 3); assert.ok(!('bidPromiseEvent' in guide.action)); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty; + const action = botActions(view)[0]; assert.deepEqual(action, view.travelPromiseGuide!.action); + const done = applyAction(game, f.target, action); + assert.equal(done.bidPromises?.[0].fulfilled, true); assert.equal(done.travelPromises?.[0].released, false); + assert.equal(done.auction?.bid, 4); assert.deepEqual(truthBidInventory(done), f.originalCards); + } + assert.equal(game.truthHistory?.filter(row => row.answer === 'yes').length, 2); + } +}); +void test('native pending bid invoice and future travel cannot reserve the same original spice in either answer order', () => { + for (const bidFirst of [true, false]) { + const f = createTruthBidFixture({ window: 'active' }), q = questions(f, 8); + let game = ask(f.game, f.asker, f.truths[0], bidFirst ? q.bid : q.travel); + game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = ask(game, f.asker, f.truths[1], bidFirst ? q.travel : q.bid); + const view = viewGame(game, f.target); + assert.deepEqual(bidFirst ? view.truthTravelAnswers : view.truthBidAnswers, ['no']); + const before = structuredClone(game); + assert.throws(() => applyAction(game, f.target, { type: 'truthAnswer', answer: 'yes' })); assert.deepEqual(game, before); + game = applyAction(game, f.target, { type: 'truthAnswer', answer: 'no' }); + assert.equal(game.truthHistory?.length, 2); assert.deepEqual(truthBidInventory(game), f.originalCards); + assert.equal(game.discard.filter(card => f.truths.includes(card.id)).length, 2); + } +}); diff --git a/tests/truth-travel-early-controls.test.tsx b/tests/truth-travel-early-controls.test.tsx new file mode 100644 index 00000000..ddc640e9 --- /dev/null +++ b/tests/truth-travel-early-controls.test.tsx @@ -0,0 +1,273 @@ +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 { TravelAnswers, TravelPromiseGuideButton, TravelPromises } from '../components/travel-promises'; +import { baseDeck } from '../game/cards'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import { createRevivalOrder } from '../game/revival-order'; +import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction'; + +type Props = Parameters[0]; +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +const event = 'controlled-early-travel'; + +/** Controlled personalized quotes test current UI ownership and dispatch only. + * They do not establish native acquisition, answer feasibility or progress. */ +function projected(phase: number, next: Action = { type: 'ready' }): GameView { + const source = createGame('EARLYTRAVELCONTROLS', newPlayer('p', 'Respondent', 'emperor')); + joinGame(source, newPlayer('a', 'Asker', 'atreides')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'a'; game.ready = []; + game.travelPromises = [{ event, player: 'p', asker: 'a', turn: 2, + mixed: { kind: 'or', terms: [ + { kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 3 } }, + { kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 4 } }, + ] }, answer: true, fulfilled: false, released: false }]; + game.travelPromiseGuide = { event, action: { ...next, travelPromiseEvent: event, travelPromisePhase: phase } }; + game.truthTravelAnswers = null; + return game; +} +function auction(next: Action = { type: 'passBid' }): GameView { + const game = projected(3, next); + game.auction = { bid: 7, bidder: next.type === 'decision' ? 'p' : 'a', active: 'p', + remaining: 2, inspectionBlocked: null, card: null }; + if (next.type === 'decision') { + game.auction.active = 'a'; + game.decision = { kind: 'auctionPayment', player: 'p' }; + } + return game; +} +function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView { + const game = projected(4, next); + game.active = 'p'; + game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a'], ready: [] }); + return game; +} +function richese(method: 'silent' | 'onceAround'): GameView { + const native = createRicheseAuction({ event: 'controlled-native-lot', cardId: 'controlled-native-card', + owner: 'a', source: 'cache', method, eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] }); + const game = projected(3, { type: 'richeseBid', event: native.event, amount: method === 'silent' ? 0 : null, allyPayment: 0 }); + game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null, + ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null }; + return game; +} +function guide(props: Props) { 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 elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function answer(game: GameView, send: (action: Action) => void) { + const tree = TravelAnswers({ game, busy: false, act: send }); + const button = elements(tree).find(element => element.props['data-travel-answer'] === 'yes'); + assert.ok(button); return button; +} +const click = (button: Element) => (button.props.onClick as () => void)(); +function answering(phase: number): GameView { + const game = projected(phase); game.travelPromiseGuide = null; + game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth' }], passed: [], + question: { kind: 'travel', target: 'p', turn: 2, mixed: game.travelPromises[0].mixed } }; + game.truthTravelAnswers = ['yes', 'no']; + return game; +} + +void test('early travel guide forwards a required native bid and rejects a changed ordinary opportunity', () => { + const game = auction({ type: 'bid', amount: 8, allyPayment: 0 }), sent: Action[] = []; + const button = guide({ game, busy: false, act(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); + assert.deepEqual(sent, [game.travelPromiseGuide!.action]); + assert.match(markup(game), /Place the required bid of 8/); + game.auction!.active = 'a'; press(button); assert.equal(sent.length, 1); +}); + +void test('early whole-travel history stays readable before Movement and forwards only current Charity, Ready or card preparation', () => { + for (const [phase, action, label] of [ + [2, { type: 'charity' }, /Claim Charity for your later shipment and movement/], + [2, { type: 'ready' }, /Finish this preparation phase while preserving your whole answer/], + [4, { type: 'ready' }, /Finish this preparation phase while preserving your whole answer/], + [2, { type: 'card', card: 'controlled-preparation', amount: 3 }, /Use the offered native card preparation/], + [4, { type: 'card', card: 'controlled-preparation', amount: 3 }, /Use the offered native card preparation/], + ] satisfies [number, Action, RegExp][]) { + const game = projected(phase, action), sent: Action[] = []; + const props = { game, busy: false, act(value: Action) { sent.push(value); } }; + const html = markup(game); + assert.match(html, /Your Movement turn is still ahead/); + assert.match(html, /Complete any offered current preparation below before confirming readiness/); + assert.match(html, label); assert.doesNotMatch(html, />Ship \d|>Move \d/); + const button = guide(props); assert.equal(button.props.disabled, false); press(button); + assert.deepEqual(sent, [game.travelPromiseGuide!.action]); assert.equal(sent[0], game.travelPromiseGuide!.action); + props.busy = true; press(button); assert.equal(sent.length, 1); + props.busy = false; game.ready.push('p'); press(button); assert.equal(sent.length, 1); + } + for (const phase of [0, 1]) { + const game = projected(phase); game.travelPromiseGuide = null; + assert.match(markup(game), /Your Movement turn is still ahead/); assert.doesNotMatch(markup(game), /