diff --git a/components/movement-promises.tsx b/components/movement-promises.tsx
index 4aaf9401..95018f1e 100644
--- a/components/movement-promises.tsx
+++ b/components/movement-promises.tsx
@@ -23,7 +23,8 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac
: `${next?.movementCard ? 'Use Ornithopter and move' : 'Move'} ${amount} forces to ${destinationLabel}, sector ${sector}`;
return
Your ground-movement promises
- {own.map((promise, index) => {promise.answer ? 'Yes: you must' : 'No: you must not'} {movementClaimText(promise)} before this Movement turn ends.
)}
+ {game.active !== game.me && Your Movement turn is still ahead. Keep the needed forces, spice and cards available; movement controls appear on your turn.{next?.type === 'decision' && ' Your already-paid Box search can finish now.'}
}
+ {own.map((promise, index) => {promise.answer ? 'Yes: you must' : 'No: you must not'} {movementClaimText(promise)} before your Movement turn ends this round.
)}
One qualifying ground move fulfills Yes; a shipment can prepare that move but does not fulfill it. No remains binding through every later Hajr or Ornithopter move. A paid Box search must finish before moving; its fee is paid only once. Opposing effects release an answer only if it becomes impossible.
{next && { if (!busy) act(next); }}>{label} }
;
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index bf83497a..1b9ee958 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -349,7 +349,7 @@ export function Truthtrance({
<>
One ground movement of the chosen physical minimum, from now until the respondent’s Movement turn ends. Shipment, HMS relocation and Ambassador relocation do not count. Owned Hajr, Ornithopter, native Kulon and paid Box recovery of a known discard can prepare a later move. No stays binding after earlier nonmatching moves.
- {!g.truthMovementTargets.includes(target) && Choose the active Basic player and finish pending decisions. Optional-module and Advanced movement promises remain unfinished.
}
+ {!g.truthMovementTargets.includes(target) && Choose an active or still-waiting Basic player this round and finish pending decisions. Optional-module and Advanced movement promises remain unfinished.
}
{truthQuestionText(question, leaderName, locationName)}
>
) : kind === 'shipment' || kind === 'mixedShipment' ? (
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 1c2687eb..1a3bff97 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,31 @@ open mode gates.
## Current checkpoint and work
+**Waiting-turn Basic movement promises — 24 October. Prototyped / Partial.**
+Unused respondents in the current Movement queue can now answer. Forecast their
+current board/resources without resolving foreign controls or granting future
+income/discards. Another player's move/end cannot settle the promise; ordinary
+guidance waits for the owned turn. An original paid Box choice remains available
+while waiting, including ordinary cards that preserve the route. Deferred
+gift/accompaniment costs retain owner preflight, and pending native movement
+defers only its own moving player's reconciliation.
+
+A disjoint original-source lane, bounded continuation review and owner
+integration pass **18 files125/125**, types/lint, retained production build and
+four actual-control/table-rendering programmes. Source19 reverses the unopened
+classic deal; native source44 earns Box1/Hajr1 and genuinely spends Richese's
+first Truth before Guild's question supplies a second ordinary paid candidate.
+Eight existing-profile runs make32 accepted proposals plus12 separate neutral
+acknowledgements. Controlled pact/gift, BG losses, acquired foreign Ornithopter
+and native Fremen/Box edges disclose their staging. Review repaired paid-choice
+timing, deferred voluntary costs and native-owner deferral. The final paired
+regression proves both rejected and affordable interruptions; its test-only
+amendment rechecks types/tests/runtime and retains the unchanged app build by
+source identity. [Current source, exact proof and limits](TRUTHTRANCE_MOVEMENT_PROMISES.md#same-phase-waiting-respondents--24-october-2026).
+Earlier phases/later rounds, other preparations, No-Field/modules, full modes,
+comprehensive assurance and deployed acceptance remain open. No strategy tuning
+or saved-room/reset/restart/deployment operation.
+
**Basic movement promises before shipment — 24 October. Prototyped / Partial.**
Clean active Basic turns now include ordinary reserve shipment preparation, with
actual tariffs, typed withdrawals and pledged payment splits. Shipment itself
@@ -6271,7 +6296,7 @@ These links define bounded working behavior, not complete module certification.
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md) and [bounded retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Orgiz transfers, Jacurutu income, Testing Station and Shrine. Orgiz's per-deposit interpretation, contested/shared benefits and mixed physical allocation remain provisional or pending. The Orgiz path in current source is not yet deployed. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Secret Ally trade and bounded after-victory inspection](NEXUS_CHOAM_SECRET_ALLY.md), [Moritani Secret Ally losing-card retention](NEXUS_MORITANI_RULES.md#secret-ally-losing-card-retention-prototype), [Ecaz private yes/no inquiry and bounded native Duke Cunning](NEXUS_ECAZ_RULES.md#bounded-native-cunning-duke-vidal), [Emperor extra revival and bounded bank-auction purchase](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected. Uniform CHOAM winner/Moritani loser windows, Ecaz inquiry scope/audience and Ecaz Cunning's Battle-only window are product inferences, not publisher rulings. Source audits or helpers alone do not certify full module games. |
-| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), [base Basic/Advanced reserve-shipment promises, including Guild](TRUTHTRANCE_SHIPMENT_PROMISES.md), and [active Basic ground-movement promises](TRUTHTRANCE_MOVEMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
+| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), [base Basic/Advanced reserve-shipment promises, including Guild](TRUTHTRANCE_SHIPMENT_PROMISES.md), and [Basic ground-movement promises](TRUTHTRANCE_MOVEMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) and [Reinforcements](REINFORCEMENTS_RUNTIME.md) connect bounded battle effects in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. |
| Multiplayer | [Public and private discussion](TABLE_DISCUSSION.md), saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), [named-player AI permission](SEAT_AI_PERMISSION.md) and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 0883d07a..e96db9b7 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,30 @@
# Dune implementation status
+## 24 October 2026 — Waiting-turn Basic movement promises
+
+Allow unused respondents in the current Movement queue to answer against their
+current owned board/resources. Isolate foreign controls in the detached forecast
+without future income/discard assumptions. Other players' moves/ends cannot
+fulfill or close the promise. Ordinary guidance waits until active; the original
+already-paid Box decision can finish while waiting, including an ordinary card
+that preserves the route. Retain owner preflight for deferred gift/accompaniment
+costs; native movement deferral now belongs only to its actual moving owner.
+Minimal accompaniment policy may decline under a positive promise.
+
+A disjoint original-source lane, bounded review and owner integration pass
+18 files125/125, types/lint, production build and four actual-control/table
+programmes. Source19 preserves the original reversed classic deal. Native44 buys Box1/
+Hajr1 and spends the first Truth; the actual later question supplies the second. Eight profile runs
+make32 legal proposals, with12 separate neutral acknowledgements. Controlled
+pact/gift, BG losses, acquired Ornithopter and native Fremen/Box cases distinguish
+their staging. Paired affordable/unaffordable interruption feedback passes;
+only that regression changed after the first build, whose application identity
+is verified while final types/tests/runtime run again.
+[Authority, exact proof and remaining scope](TRUTHTRANCE_MOVEMENT_PROMISES.md#same-phase-waiting-respondents--24-october-2026).
+Earlier phases/later rounds, wider preparations/modules, full modes, final
+assurance and deployed acceptance remain open. No tuning or saved-room/reset/
+restart/deployment operation.
+
## 24 October 2026 — Basic movement promises before shipment
Extend active Basic questions to unused ordinary shipment. Shared declaration,
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index c71d5bbd..f94c5cc3 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -907,7 +907,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Advanced Kull specific effect | Supplied physical29/logical27 restricts the attempted Karama for a specific effect, unlike publisher Basic phase-wide wording. New signed Advanced attempts bind semantic purpose; purchase/payment and native special variants normalize, as do enhanced Prescience/advisor conversion. CHOAM effects and special-force faction powers stay distinct. Retaining turn/phase expiry is explicit provisional composition under the 14 October policy, not supplied wording. Legacy unstamped pending/restriction frames retain broad scope. Prior distinct counter, BG-before-conversion and deferred overbid selections stay. | [Source cutover and keys](CHOAM_KULL_SOURCE_UPDATE.md#advanced-specific-effect-scope--18-october-2026), [connected implementation and limits](CHOAM_KULL_DESIGN.md#advanced-effect-scope--18-october-2026) |
| Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) |
| Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) |
-| Truthtrance / Basic ground movement | Compose the general FAQ binding-while-possible rule with one future qualifying physical ground movement before the active turn ends, now before or after shipment. Ordinary reserve preparation uses actual prices, typed physical withdrawals and pledged payment splits; shipment itself never fulfills movement. Joint answers govern completion, and pending ordinary/Guild declarations retain their exact frame. Yes survives nonmatches while an owned completion remains; No forbids every later match through Hajr/Ornithopter until actual closure. Commit before arrival/disposal, preserve native controls and compatible promises after opposing loss. Held native Kulon and paid Box use actual legality and known current-discard identities before payment; retain original paid event, fee and provisional full-hand exchange without unknown inspection or erased historical memory. Shipment/HMS/Ambassador relocation and the No-Field marker do not count as ordinary physical ground movement. This predicate/release policy is software composition, not a movement-specific publisher FAQ. Earlier phases/waiting turns, native transport preparation, wider/counter combinations, optional/Advanced and full acceptance remain unfinished. | [Authority, behavior and bounded evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md) |
+| Truthtrance / Basic ground movement | Compose the general FAQ binding-while-possible/current-round rule with one future qualifying physical ground movement before the respondent's actual own turn ends. Active or unused waiting turns may answer; forecast current board/resources without foreign outcomes, and defer ordinary execution until active. Other players' moves/ends cannot settle this ledger. Actual paid Box selection remains actionable while waiting and may choose any entitled card preserving the route. Deferred gift/accompaniment costs retain owner preflight; native pending moves defer only their own moving player. Ordinary reserve preparation retains prices, typed withdrawals and pledged splits; joint answers and exact pending declarations remain. Yes survives nonmatches while possible; No forbids later matches through Hajr/Ornithopter until actual closure. Commit before arrival/disposal, preserve native controls and compatible answers after opposing loss. Held Kulon and known-discard Box retain fee/event/full-hand policy; no unknown inspection or erased historical memory. Shipment/HMS/Ambassador relocation and No-Field markers do not fulfill physical ground movement. Predicate/release/forecast policy is software composition, not a movement-specific publisher FAQ. Earlier phases/later rounds, native transport preparation, wider/module combinations and full acceptance remain unfinished. | [Authority, behavior and bounded evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md) |
| Semuta / native Basic entries | Fresh bare public Basic Richese previews initialize the existing protocol before genuine setup with selected native E1/E2/E3 families; no optional/Advanced inheritance or old-game retrofit. Clean source-witnessed shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: keep CHOAM's committed credit and draw the Ixian replacement only after the reaction. This composes printed component rules, not a new timing FAQ. Existing Basic Terror overflow retains the neutral all-seat response, committed-only face access, original victim/draw, removed token, payment, forces and optional gift. Nested parents, completed movement-card provenance and wider module/full-game coverage remain unfinished implementation boundaries, not printed Semuta prohibitions. | [Fresh Basic and Ambassador scope](SEMUTA_SOURCE_UPDATE_20260907.md#fresh-basic-starts-and-ambassador-discards--24-october-2026), [native Terror suffix](TERROR_DISCARD_CONTINUATIONS.md) |
Shipment and mixed current-fact/shipment AND/OR follow the FAQ's whole-question
diff --git a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md
index 0a479d6a..4886f471 100644
--- a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md
+++ b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md
@@ -17,11 +17,12 @@ movement statement, the owned-action witness and accepted-order release policy
below are explicit software compositions, not a publisher movement-specific FAQ
or Basic adoption of the unofficial Advanced revision.
-Fresh or existing Basic native tables can ask at a clean **active Shipment and
-Movement turn**, before or after shipment, including selected E1/E2/E3 factions. No optional modules or Advanced
+Fresh or existing Basic native tables can ask at a clean **active or still-unused
+waiting turn in the current Shipment and Movement queue**, before or after the
+active respondent's shipment, including selected E1/E2/E3 factions. No optional modules or Advanced
inherit this entry. The actual native roster, physical inventory and route
validation remain authoritative; no mode or publication gate opens. Earlier
-phase/waiting-turn movement questions, compound movement/fact questions and wider
+phase/later-round movement questions, compound movement/fact questions and wider
owned preparations/interactions remain unfinished. Existing saves are not
reconfigured and absent promise fields remain absent until a real answer binds.
@@ -55,9 +56,85 @@ reconfigured and absent promise fields remain absent until a real answer binds.
This is bounded reachability, not complete arbitrary Truthtrance enforcement.
Other owned preparation families (including native relocation preparation),
-earlier phases/waiting turns, optional/Advanced profiles and comprehensive
+earlier phases/later rounds, optional/Advanced profiles and comprehensive
combination/recovery/privacy/custody acceptance remain due.
+## Same-phase waiting respondents — 24 October 2026
+
+The general current-round FAQ contract now includes a respondent whose unused
+turn remains in the original Movement queue. This extends the provisional owned
+forecast used by shipment promises; it introduces no future-income, cooperation
+or vacated-destination assumption. Keep the current board, owned resources and
+native per-player Hajr history. The detached forecast selects that owner's turn
+and isolates foreign declarations, prices, Ornithopter controls and private
+searches without resolving them or awarding a hypothetical discarded card.
+The live active player, queues, cards and pending decisions remain unchanged.
+
+A matching move by another player neither fulfills Yes nor violates No. Another
+player's end cannot close the respondent's promise. Ordinary action guidance
+appears only on the actual owned turn. The exception is the owner's **already
+paid** Box selection: its current private decision can finish while waiting,
+using the original event and full entitled choice. Once paid, even an ordinary
+nonmovement card may preserve an existing route; do not falsely restrict that
+search to movement cards. Pre-payment known-discard planning is unchanged.
+
+Owner-declared Emperor gift and Spiritual Advisor costs are checked on their
+allowed outcome before opening the deferred response, so a foreign responder
+cannot convert voluntary loss into an opposing release. A pending native move
+defers reconciliation for its own moving player, while a different waiting
+owner still preserves required resources when voluntarily interrupting with Box.
+All four minimal profiles may conservatively decline optional accompaniment
+under a positive promise; strategic refinement remains deferred. Persistent
+waiting/paid guidance has no timer and retains busy handling.
+
+Earlier phases/later rounds, additional preparation families, No-Field/modules,
+full modes and comprehensive acceptance remain unfinished.
+
+### Current waiting-turn evidence
+
+`tests/fixture-truth-movement-waiting.ts` reverses two original unopened deal
+positions, then performs real setup and phase continuations. Its19-action public
+Basic source leaves active Atreides10 at Arrakeen and waiting Emperor0/20 with
+native wallets. Another player's actual matching move/end leaves Yes/No pending;
+the respondent then ships and moves or honors No through its own real ending.
+
+`tests/fixture-truth-movement-waiting-box.ts` uses actual public native purchases
+Box1/Hajr1, stops at Richese's first movement turn and genuinely spends its dealt
+Truth on a current fact. Guild's original distinct Truth then asks the waiting
+Emperor. The44-action source retains cash8, all20 reserves and zero board forces;
+actual payment leaves6, with two ordinary Truth choices. Paid Yes/No selection
+retains the original event and one fee, returns to waiting and later finishes
+the owned turn. Only unopened positions and native scalar lotteries are selected.
+No hand, wallet, force, queue, clock or receipt is installed in these sources.
+
+Separate controlled tests label the modeled pact/gift and past BG losses,
+conserved original Ornithopter acquisition and native Fremen-response/Box
+timing. The last checks both unaffordable and affordable payment: reject the
+former without changing its original frame, and restore the latter's exact
+foreign movement response with one spice left. No foreign flight is discarded
+or reassigned by the waiting forecast. All four profiles can decline optional
+accompaniment instead of repeatedly proposing an illegal acceptance.
+
+The final eighteen-file union passes **125/125**, types/lint and four actual
+guidance/ordinary paid-choice-handler/GameTable programmes. Eight existing-profile
+runs make32 accepted owned proposals, with12 separate neutral acknowledgements;
+there is no strategy tuning. Bounded review repaired active-only policy with
+the paid exception, both deferred-cost preflights and native-owner deferral.
+The production build passes at the initial freeze; a test-only affordable
+counterpart follows. `build-identity.json` proves all application/build/dependency
+and other source files identical while the final tests/types/runtime run again.
+
+Frozen base `f0b2ac82d924ca8568bb7b58b5c290ad75cd04be`, final proof tree
+`0156db54c6165d6b64c612ad15b984c953395f7659e6314894d8317491e64714`,
+2645 files. Private logs, original-state control HTML, build identity and receipts:
+`/home/quintine/.local/share/dune/checkpoints/20261024-movement-waiting/`,
+`frozen-integrated-2/report.json`; the original build is in `frozen-integrated/`.
+Only final checkpoint/reference prose overlays follow proof. Framework aliases/
+CSS stubbing establish server rendering, not browser/visual/deployed acceptance.
+Earlier phases/later rounds, remaining preparation families/modules, complete
+modes and comprehensive assurance remain open. No saved-room/D1/reset/restart/
+deployment operation.
+
## Before-shipment movement promises — 24 October 2026
The same existential ground-movement question now includes the active player's
@@ -82,10 +159,10 @@ a bad destination/force split and then use an opponent's pass to release Yes.
Unsupported Ambassador/No-Field/module sources retain their boundary rather
than executing a full speculative continuation. Native cross/return preparation
is not enumerated yet, though actual legal choices preserve the existing promise.
-Other preparation families, earlier phases/waiting turns, optional/Advanced and
+Other preparation families, earlier phases/later rounds, optional/Advanced and
comprehensive acceptance remain unfinished.
-### Current before-shipment evidence
+### Preceding before-shipment evidence
`tests/fixture-truth-movement-shipment.ts` retains actual source prefixes before
their original shipment. Classic19 has Arrakeen10 plus10 reserves: a one-spice
@@ -140,7 +217,8 @@ ordinary owner sees only the next legal action.
After actual payment, the original private search still shows every eligible
card, retains its event and charges once. A live Yes supplies a compliant
-movement-card selection in the persistent promise control. A selection that
+selection in the persistent promise control: a required movement card, or an
+ordinary card when an existing route needs no new movement card. A selection that
would destroy the last route rejects **before** the real shuffle and leaves the
paid decision available; its fee is neither repeated nor refunded. No-only
promises keep the ordinary paid choice and do not offer End movement until that
diff --git a/game/bots.ts b/game/bots.ts
index b55e0199..d74d885c 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1,4 +1,5 @@
import { discoveryBotActions } from './discovery-options';
+import { liveMovementPromises } from './movement-promises';
import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options';
import { quoteEcazOccupyDial } from './ecaz-occupy-battle';
import { recruitsPlayAction } from './recruits';
@@ -2724,7 +2725,8 @@ function policyActions(g: GameView): Action[] {
{ type: 'decision', accept: false },
];
}
- if (d.kind === 'advisor' && liveShipmentPromises(g.shipmentPromises ?? [], me.id, g.turn).length)
+ if (d.kind === 'advisor' && (liveShipmentPromises(g.shipmentPromises ?? [], me.id, g.turn).length ||
+ liveMovementPromises(g.movementPromises ?? [], me.id, g.turn).some(promise => promise.answer)))
// A waiting promise may need these reserves. Declining is the minimal
// legal policy until optional accompaniment strategy is implemented.
return [{ type: 'decision', accept: false }];
diff --git a/game/engine.ts b/game/engine.ts
index d7205353..4e773463 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -16431,8 +16431,20 @@ function recoverableUnsupportedShipment(shipment: PendingShipment) {
}
/** Keep source controls in the real game. A detached witness answers only
* owned movement reachability; unresolved opposing outcomes are never replayed. */
-function groundMovementProbe(source: Game): Game {
+function groundMovementProbe(source: Game, actor: string): Game {
const game = structuredClone(source);
+ if (game.active !== actor) {
+ // Forecast only this owner's unused turn on the current board. Do not
+ // complete a foreign declaration, price, flight or private search, and do
+ // not turn its potential discarded card into an owned Box candidate.
+ game.active = actor;
+ if (game.pendingShipment?.player !== actor) game.pendingShipment = null;
+ if (game.pendingHomeworldShipment?.player !== actor) game.pendingHomeworldShipment = null;
+ if (game.pendingGuildTransport?.player !== actor) game.pendingGuildTransport = null;
+ if (game.karamaShipping?.player !== actor) game.karamaShipping = null;
+ if (game.ornithopter?.player !== actor) game.ornithopter = null;
+ if (game.pendingNullentropy?.player !== actor) game.pendingNullentropy = null;
+ }
game.truthtrance = null; game.response = null; game.decision = null; game.phaseOpening = null;
game.pendingKarama = null; game.pendingTreacheryDiscard = null;
game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null;
@@ -16483,7 +16495,7 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
player.hand = result.ownerHand; context.discard = result.discard;
setMovementDiscardKnowledge(context, recordMovementBoxKnowledge(context.movementDiscardKnowledge,
context.players.map(p => p.id), actor, result.discard));
- return groundMovementProbe(context);
+ return groundMovementProbe(context, actor);
} catch (error) {
if (error instanceof RuleError) return null;
throw error;
@@ -16491,7 +16503,7 @@ function projectMovementBoxRecovery(source: Game, actor: string, box: string, se
}
function groundMovementCompletion(source: Game, actor: string,
promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn)): MovementCompletion | null {
- const game = groundMovementProbe(source);
+ const game = groundMovementProbe(source, actor);
if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null;
return findMovementCompletion(game, actor, promises, {
allowance: movesAllowed,
@@ -16505,7 +16517,9 @@ function groundMovementCompletion(source: Game, actor: string,
(player.faction === 'choam' && card.kind === 'worthless' && card.name === 'Kulon');
if (paid) {
if (paid.player !== owner || nullentropyIntegrity(state)) return;
- for (const card of eligibleNullentropyCards(state.discard).filter(relevant)) {
+ const choices = eligibleNullentropyCards(state.discard)
+ .sort((a, b) => Number(relevant(b)) - Number(relevant(a)));
+ for (const card of choices) {
const projected = projectMovementBoxRecovery(state, owner, paid.box, card.id, true);
if (projected) yield { game: projected, actions: [{ type: 'decision', event: paid.event, card: card.id }] };
}
@@ -16516,18 +16530,18 @@ function groundMovementCompletion(source: Game, actor: string,
const cost = reserveShipmentCost({ faction: payer.faction, halfRate: shipmentHalfRate(source, payer) },
territory(String(action.territory)).type, 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')); }
+ try { return groundMovementProbe(applyActionInner(source, actor, action, 'movementPreparation'), actor); }
catch (error) { if (error instanceof RuleError) return null; throw error; }
});
for (const card of player.hand) {
if (card.effect === 'hajr' && hajrAvailable(state, player) && !transferCardBlock(state, player, card)) {
try { yield { game: groundMovementProbe(applyActionInner(state, owner,
- { type: 'card', card: card.id }, 'movementPreparation')), actions: [{ type: 'card', card: card.id }] }; }
+ { type: 'card', card: card.id }, 'movementPreparation'), owner), actions: [{ type: 'card', card: card.id }] }; }
catch (error) { if (!(error instanceof RuleError)) throw error; }
}
if (player.faction === 'choam' && card.kind === 'worthless' && card.name === 'Kulon') {
const action: Action = { type: 'card', mode: 'choam', card: card.id };
- try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation')), actions: [action] }; }
+ try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation'), owner), actions: [action] }; }
catch (error) { if (!(error instanceof RuleError)) throw error; }
}
if (richeseCardDefinition(card)?.card.effect === 'nullentropyBox') {
@@ -16558,8 +16572,10 @@ function groundMovementCompletion(source: Game, actor: string,
function projectedGroundMovementPolicy(g: Game, player: Player): Action[] | null {
const live = liveMovementPromises(g.movementPromises ?? [], player.id, g.turn);
const paid = g.pendingNullentropy?.player === player.id;
- if (g.truthtrance || !live.length || (paid && !live.some(promise => promise.answer)) ||
- !movementPromiseTargetAvailable(paid ? { ...g, decision: null } : g, player.id)) return null;
+ if (g.truthtrance || !live.length || (!paid && g.active !== player.id) ||
+ (paid && !live.some(promise => promise.answer))) return null;
+ const available = paid ? groundMovementProbe(g, player.id) : g;
+ if (!movementPromiseTargetAvailable(paid ? { ...available, decision: null } : available, player.id)) return null;
const completion = groundMovementCompletion(g, player.id);
return completion ? completion.actions.length ? completion.actions.slice(0, 1) : [{ type: 'endMovement' }] : null;
}
@@ -16598,10 +16614,11 @@ function recordGroundMovementPromises(g: Game, p: Player, move: MovementOrder) {
function reconcileGroundMovementPromises(g: Game, actor?: string) {
// A declared native movement is awaiting a real opponent choice. It is not
// impossible and must not be fulfilled before physical commitment.
- if (g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove) return;
for (const p of g.players) {
const live = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn);
if (!live.some(promise => promise.answer)) continue;
+ if (g.pendingFremenMove?.order.player === p.id || g.pendingIxMove?.player === p.id ||
+ g.pendingChoamMove?.player === p.id) continue;
const busy = !!(g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length);
if (busy && actor !== p.id) continue;
@@ -33444,12 +33461,14 @@ function applyActionInner(
location: location(dest.territory, dest.sector),
amount,
};
- if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length) {
+ if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
+ liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
// Accepting accompaniment is voluntary even though a later responder
// commits its reserve withdrawal. Check that allowed outcome now.
const afterAdvisor = structuredClone(g);
finishResponse(afterAdvisor, false);
reconcileShipmentPromises(afterAdvisor, { actor: p.id, action });
+ reconcileGroundMovementPromises(afterAdvisor, p.id);
}
} else log(g, `${p.name} declined the free shipment.`);
} else if (decision.kind === 'wormPlacement') {
@@ -34387,13 +34406,15 @@ function applyActionInner(
p.spice >= paidForceRevivalCost(recipient, amount, elite),
'Not enough spice for these extra revivals.',
);
- if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length) {
+ if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
+ liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) {
// A later responder cannot erase the owner's voluntary gift/revival
// invoice. Check its allowed outcome before opening the response.
const afterPayment = structuredClone(g);
getPlayer(afterPayment, p.id).spice -= t === 'emperorGift'
? amount : paidForceRevivalCost(recipient, amount, elite);
reconcileShipmentPromises(afterPayment, { actor: p.id, action });
+ reconcileGroundMovementPromises(afterPayment, p.id);
}
g.response = {
kind: t,
diff --git a/game/movement-promises.ts b/game/movement-promises.ts
index 6d36f89a..5a4cf7a1 100644
--- a/game/movement-promises.ts
+++ b/game/movement-promises.ts
@@ -86,7 +86,7 @@ export function finishMovementPromises(
export function movementPromiseTargetAvailable(g: Game, player: string): boolean {
const target = g.players.find(candidate => candidate.id === player);
return g.status === 'playing' && g.advanced === false && g.phase === 5 &&
- g.active === player && !!target &&
+ !!target && (g.active === player || (!target.shipped && target.moved === 0)) &&
!!g.movementRemaining?.includes(player) &&
g.players.length >= 2 && g.players.length <= 6 &&
g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
diff --git a/game/reference.ts b/game/reference.ts
index 02ec97f8..d2a91494 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -2930,10 +2930,11 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'Bind one future qualifying physical ground movement before an active Basic respondent finishes their current turn.',
steps: [
- 'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Ask the active Basic player before or after shipment and finish pending decisions first. Selected native families are supported; optional modules, Advanced, earlier phases and waiting turns remain unfinished.',
+ 'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Ask an active Basic player before or after shipment, or a still-unused waiting respondent in the current Movement queue. Finish pending decisions first. Selected native families are supported; optional modules, Advanced, earlier phases and later rounds remain unfinished.',
'Yes requires one qualifying ground movement from now until that player’s Shipment and Movement turn ends, while possible. Smaller groups do not add together and earlier moves do not count. Shipment, HMS relocation and Ambassador relocation do not fulfill this statement; a concealed No-Field marker itself is not a physical on-board force counter.',
'A nonmatching move leaves Yes pending when an owned later route remains. One matching physical commitment fulfills it before card disposal and later Intrusion, Ambassador or Terror effects. You are promising the move, not survival afterward. No forbids every later matching move, including Hajr and both Ornithopter groups, until the actual turn closes.',
'Ordinary reserve shipment may prepare or reinforce a later ground group using its actual cost, native allocation and pledged payment split. Shipment itself neither fulfills movement Yes nor violates movement No. Joint guidance honors a required shipment before End; an already declared reserve or native Guild transport keeps its exact source, destination and funding while responses finish.',
+ 'Waiting answers use current board/resources without assuming foreign income, movement, cooperation or discarded cards. Another player cannot fulfill or close your promise by moving or ending. Ordinary guidance appears on your actual turn; your already-paid Box choice can finish now. Deferred gifts/accompaniment cannot voluntarily spend the last route before another player responds.',
'Private feasible answers and the next owned step use actual movement legality, typed groups, held Hajr, both Ornithopter modes and original cohorts, current city access, held native Kulon and owned Box recovery. Before paying the two-spice Box fee, only a known card still in discard supplies a route; unknown future draws/gifts are not invented. Native relocation and wider preparation remain unfinished.',
'A paid Box search retains its full private choice and original event. A guided choice preserves Yes; an incompatible selection rejects before shuffle and keeps that paid search available without another fee. No-only promises retain ordinary selection before movement can end. The existing provisional atomic full-hand exchange remains.',
'Voluntary actions cannot erase the last required route. Native counter responses finish before impossibility is decided. An opposing loss releases only incompatible answers, retaining No constraints and still-possible Yes answers in accepted order. This owned-route and release priority is a provisional software composition of the general current-turn rule, not a publisher movement-specific FAQ.',
@@ -2941,11 +2942,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'A pure ledger and finite search reuse authoritative declaration/physical commitment. Active Basic questions now include ordinary reserve shipment, typed allocations, pledged splits and joint shipment predicates. Exact pending reserve/Guild declarations project without invented income/arrival effects; unsupported sources stay guarded. Held Kulon and known-discard paid Box retain prior semantics. Earlier phases/waiting turns, native transport/relocation preparation, No-Field/modules and wider interactions remain unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/movement-shipment-preparation.ts', 'game/movement-discard-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Dedicated public question/private answers and persistent busy-safe guidance include reserve reinforcement before movement, joint obligations, Box payment/selection and held Kulon. Shipment does not fulfill movement, and No cannot bypass a shipment Yes. Browser/visual acceptance remains open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/shipment-promises.tsx', 'components/truthtrance.tsx', 'components/nullentropy-box.tsx', 'components/game-table.tsx', 'tests/truth-movement-shipment-controls.test.tsx', 'tests/truth-movement-preparation-controls.test.tsx'] },
- { area: 'AI', status: 'Partial', detail: 'All four existing profiles make16 accepted answer/reinforce/move/end actions from the genuine ten-force pre-shipment source. Prior40 paid-Box actions remain historical. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-shipment-engine.test.ts', 'tests/truth-movement-preparation-engine.test.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'The pure ledger and shared movement/shipment search now include unused waiting Basic respondents. Forecast current owned resources without foreign outcomes; ordinary policy waits until active, while the original paid Box can select any entitled card preserving the route. Deferred gift/advisor costs retain preflight; native moves defer only their owner. Earlier phases/later rounds, remaining preparation families, No-Field/modules and wider interactions remain unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/movement-shipment-preparation.ts', 'game/movement-discard-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Dedicated public question/private answers and persistent busy-safe guidance include waiting obligations, actual-turn reinforcement and paid search selection while waiting. Both ordinary paid faces remain inspectable; selecting returns to waiting with no premature movement button. Prior active/card controls remain. Browser/visual acceptance stays open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/shipment-promises.tsx', 'components/truthtrance.tsx', 'components/nullentropy-box.tsx', 'components/game-table.tsx', 'tests/truth-movement-waiting-controls.test.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'Eight runs across all four profiles make32 accepted waiting-answer or paid-selection/later-turn proposals, with12 separate neutral acknowledgements. Optional accompaniment may be declined under a positive promise. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-waiting-engine.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, existential single-event meaning, explicit software composition, source selection and unfinished scope are documented.', evidence: ['docs/TRUTHTRANCE_MOVEMENT_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Current nineteen-file165/165, types/lint/build and six actual-control/table-rendering programmes pass. Original sources19/37/24/19 retain classic reinforcement10→11, native Emperor/CHOAM zero-board starts and Guild5/15/5 reserve/cross transport. Controlled second-card/counter edges are separately labelled. Source/funding review findings and expensive search ordering were repaired; twelve new engine cases pass in3.2s. Prior231/231 and218/218 evidence stays historical. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/movement-shipment-preparation.test.ts', 'tests/truth-movement-shipment-engine.test.ts', 'tests/truth-movement-shipment-controls.test.tsx', 'tests/fixture-truth-movement-shipment.ts', 'tests/fixture-truth-movement.ts', 'tests/fixture-truth-movement-choam.ts', 'tests/fixture-hajr-replay.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Current eighteen-file125/125, types/lint, retained passing app build and four original-action/control/table programmes pass. Classic source19 reverses its unopened deal; native source44 buys Box/Hajr and spends the first Truth; the actual later question supplies the second discard. Controlled pact, BG losses, foreign Ornithopter and native Fremen/Box cases disclose staging and prove rejected/affordable costs. Only the regression amended after build; app identity is verified and final tests/types/runtime rerun. Prior165/165,231/231,218/218 stay historical. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/truth-movement-waiting-engine.test.ts', 'tests/truth-movement-waiting-controls.test.tsx', 'tests/fixture-truth-movement-waiting.ts', 'tests/fixture-truth-movement-waiting-box.ts', 'tests/fixture-hajr-replay.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
],
},
{
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 8fbce78c..8e729b35 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -197,7 +197,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory',
'Supply one ground-movement destination, minimum and respondent.');
check(movementPromiseTargetAvailable(g, v.target as string),
- 'Ground-movement promises require a clean active Basic turn, with no optional modules.');
+ 'Ground-movement promises require an active or still-waiting Basic turn this round, with pending decisions finished and no optional modules.');
try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; }
catch (error) { if (error instanceof MovementClaimError) throw new TruthError(error.message); throw error; }
}
diff --git a/tests/fixture-hajr-replay.ts b/tests/fixture-hajr-replay.ts
index 392ce289..7da265cc 100644
--- a/tests/fixture-hajr-replay.ts
+++ b/tests/fixture-hajr-replay.ts
@@ -51,7 +51,7 @@ export function advanceHajrReplay(game: Game, until: (state: Game) => boolean,
/** Genuine original host, setup, paid Box/Hajr lots and paid desert shipment.
* Only unopened Hajr/Spice deck positions and scalar lotteries are selected.
* No hand, wallet, force, phase or completed-effect receipt is installed. */
-export function createHajrReplayFixture(options: { advanced?: boolean; truthForGuild?: boolean } = {}) {
+export function createHajrReplayFixture(options: { advanced?: boolean; truthForGuild?: boolean; truthForRichese?: boolean } = {}) {
const advanced = options.advanced ?? false;
let game = createGame('HAJRREPLAY', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
@@ -64,6 +64,7 @@ export function createHajrReplayFixture(options: { advanced?: boolean; truthForG
const setup = structuredClone(game);
const startingDealSelection: { position: number; from: number; card: string }[] = [];
let guildTruth: string | null = null;
+ let richeseTruth: string | null = null;
if (options.truthForGuild) {
assert.equal(game.status, 'setup');
assert.ok(game.players.every(player => player.hand.length === 0), 'Select only an unopened starting deal');
@@ -74,10 +75,21 @@ export function createHajrReplayFixture(options: { advanced?: boolean; truthForG
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
startingDealSelection.push({ position, from, card: guildTruth });
}
+ if (options.truthForRichese) {
+ assert.equal(game.status, 'setup');
+ assert.ok(game.players.every(player => player.hand.length === 0), 'Select only an unopened starting deal');
+ const position = game.players.findIndex(player => player.id === 'richese');
+ const from = game.deck.findIndex(card => card.effect === 'truthtrance' && card.id !== guildTruth);
+ assert.ok(position >= 0 && from >= position);
+ richeseTruth = game.deck[from].id;
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ startingDealSelection.push({ position, from, card: richeseTruth });
+ }
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
if (guildTruth) assert.ok(hajrReplayPlayer(game, 'guild').hand.some(card => card.id === guildTruth));
+ if (richeseTruth) assert.ok(hajrReplayPlayer(game, 'richese').hand.some(card => card.id === richeseTruth));
for (let count = 0; count < (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
front.push(game.spiceDeck.splice(at, 1)[0]);
@@ -91,14 +103,14 @@ export function createHajrReplayFixture(options: { advanced?: boolean; truthForG
const beforePurchases = structuredClone(game);
reach(state => hajrReplayPlayer(state).hand.some(card => card.id === HAJR_REPLAY_BOX) &&
hajrReplayPlayer(state).hand.some(card => card.id === hajr));
- const acquired = structuredClone(game);
+ const acquired = structuredClone(game), acquiredActionCount = actions.length;
reach(state => state.phase === 5 && state.active === 'emperor' && hajrReplayClean(state));
const beforeShipment = structuredClone(game), shipmentStep: HajrReplayStep = { actor: 'emperor', action: {
type: 'ship', territory: 'imperial_basin', sector: 9, amount: 3, elite: advanced ? 2 : 0, allyPayment: 0,
} };
act(shipmentStep.actor, shipmentStep.action); reach(hajrReplayClean);
- return { game, lobby, setup, beforeStartingDeal, startingDealSelection, guildTruth,
- afterSetup, beforePurchases, acquired, beforeShipment, shipmentStep,
+ return { game, lobby, setup, beforeStartingDeal, startingDealSelection, guildTruth, richeseTruth,
+ afterSetup, beforePurchases, acquired, acquiredActionCount, beforeShipment, shipmentStep,
afterShipment: structuredClone(game), hajr, box: HAJR_REPLAY_BOX, owner: 'emperor',
source: 'imperial_basin:9', actions,
staging: ['Original native setup and paid cache1/normal1 lots acquire Box and the single Hajr. An original paid3-counter desert shipment leaves the two-spice Box fee. Only unopened deck positions and scalar lotteries are selected.'] };
diff --git a/tests/fixture-truth-movement-waiting-box.ts b/tests/fixture-truth-movement-waiting-box.ts
new file mode 100644
index 00000000..598baa76
--- /dev/null
+++ b/tests/fixture-truth-movement-waiting-box.ts
@@ -0,0 +1,70 @@
+import assert from 'node:assert/strict';
+import type { Action, Game } from '../game/engine';
+import {
+ actHajrReplay, advanceHajrReplay, createHajrReplayFixture,
+ hajrReplayClean, hajrReplayPlayer, type HajrReplayStep,
+} from './fixture-hajr-replay';
+import { truthMovementBoxInventory } from './fixture-truth-movement-box';
+
+export const actTruthMovementWaitingBox = actHajrReplay;
+export const truthMovementWaitingBoxClean = hajrReplayClean;
+export const truthMovementWaitingBoxPlayer = hajrReplayPlayer;
+export const truthMovementWaitingBoxInventory = truthMovementBoxInventory;
+
+/** Genuine public purchases, first movement turn and an actual spent fact
+ * question create an ordinary discard before a later waiting-owner promise.
+ * The caller still owns the Guild question/answer, Box payment/choice, Richese
+ * turn completion and Emperor's later shipment and ground movement. */
+export function createTruthMovementWaitingBoxFixture() {
+ const base = createHajrReplayFixture({ truthForGuild: true, truthForRichese: true });
+ const target = base.owner, asker = 'guild', active = 'richese';
+ const truth = base.guildTruth, richeseTruth = base.richeseTruth;
+ assert.ok(truth && richeseTruth); assert.notEqual(truth, richeseTruth);
+ let game = structuredClone(base.acquired);
+ const actions: HajrReplayStep[] = structuredClone(base.actions.slice(0, base.acquiredActionCount));
+ const act = (actor: string, action: Action) => { game = actHajrReplay(game, actor, action, actions); };
+ const reach = (until: (game: Game) => boolean) => { game = advanceHajrReplay(game, until, actions); };
+ reach(state => state.phase === 5 && state.active === active && hajrReplayClean(state));
+ const beforeFact = structuredClone(game), factStart = actions.length;
+ const factQuestion = { kind: 'fact' as const, target,
+ fact: { kind: 'spice' as const, compare: 'gte' as const, value: 0 } };
+ assert.ok(hajrReplayPlayer(game, active).hand.some(card => card.id === richeseTruth));
+ act(active, { type: 'card', card: richeseTruth });
+ for (const player of game.players)
+ if (!game.truthtrance!.passed.includes(player.id)) act(player.id, { type: 'truthPass' });
+ act(active, { type: 'truthAsk', question: factQuestion });
+ act(target, { type: 'truthAnswer', answer: 'yes' });
+ reach(hajrReplayClean);
+ const factActions = structuredClone(actions.slice(factStart));
+ const owner = hajrReplayPlayer(game, target), originalCards = truthMovementBoxInventory(base.setup);
+ assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
+ assert.equal(game.phase, 5); assert.equal(game.active, active);
+ assert.ok(game.movementRemaining?.includes(target)); assert.notEqual(game.active, target);
+ assert.equal(owner.shipped, false); assert.equal(owner.moved, 0);
+ assert.deepEqual(owner.forces, {}); assert.equal(owner.reserves, 20);
+ assert.equal(owner.spice, 8, 'The original Box1/Hajr1 purchases leave the native eight-spice wallet');
+ assert.ok(owner.hand.some(card => card.id === base.box));
+ assert.ok(owner.hand.some(card => card.id === base.hajr));
+ assert.ok(hajrReplayPlayer(game, asker).hand.some(card => card.id === truth));
+ assert.equal(game.discard.filter(card => card.id === richeseTruth).length, 1);
+ assert.equal(game.discard.filter(card => card.id === truth).length, 0);
+ assert.equal(game.movementPromises, undefined); assert.ok(!game.pendingNullentropy && !game.truthtrance);
+ assert.deepEqual(truthMovementBoxInventory(game), originalCards);
+ return {
+ game, source: structuredClone(game), base, lobby: base.lobby, setup: base.setup,
+ beforeStartingDeal: base.beforeStartingDeal, startingDealSelection: base.startingDealSelection,
+ afterSetup: base.afterSetup, beforePurchases: base.beforePurchases, acquired: base.acquired,
+ beforeFact, factQuestion, factActions, actions, sourceActionCount: actions.length,
+ target, owner: target, asker, active, truth, richeseTruth, box: base.box, hajr: base.hajr,
+ originalCards, balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
+ boxFee: 2, shipmentCost: 1, expectedPaidChoices: [richeseTruth, truth],
+ claim: { kind: 'movement' as const, target, territory: 'carthag', minimum: 1 },
+ currentEnd: { type: 'endMovement' } satisfies Action,
+ shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
+ move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action,
+ staging: [
+ 'Original public bare Basic Richese/Emperor/Guild setup selects the two distinct original unopened Truthtrance deals for Richese and Guild, ordinary Spice/Hajr positions and native scalar lotteries only. Emperor actually buys Box1 and Hajr1. The recorded prefix stops at those acquisitions and continues through actual phase controls to Richese\'s first movement turn, before Emperor acts; the original factory\'s later movement/shipment suffix is excluded. Active Richese genuinely plays its original Truth on the current nonnegative-spice fact, Emperor answers Yes and the original neutral discard continuation completes. That physical Truth remains in discard; Guild still holds its distinct original Truth, and Emperor retains bought Box/Hajr, all twenty reserves, zero board forces and its native eight-spice wallet. No hand, wallet, force, queue, active player, phase, promise or pending receipt is installed. The caller\'s later definite Guild answer naturally supplies the second ordinary paid-search candidate. The source stops before that question/answer, any Box fee/choice or Richese turn ending. Neither Truth candidate is a movement card: a valid ordinary paid selection can preserve the future ordinary shipment and ground move.',
+ ],
+ };
+}
+export type TruthMovementWaitingBoxFixture = ReturnType;
diff --git a/tests/fixture-truth-movement-waiting.ts b/tests/fixture-truth-movement-waiting.ts
new file mode 100644
index 00000000..6205f32d
--- /dev/null
+++ b/tests/fixture-truth-movement-waiting.ts
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict';
+import type { Action } from '../game/engine';
+import {
+ actTruthMovement, advanceTruthMovement, createTruthMovementFixture,
+ truthMovementClean, truthMovementInventory, truthMovementPlayer,
+ type TruthMovementStep,
+} from './fixture-truth-movement';
+
+export const actTruthMovementWaiting = actTruthMovement;
+export const truthMovementWaitingClean = truthMovementClean;
+export const truthMovementWaitingInventory = truthMovementInventory;
+export const truthMovementWaitingPlayer = truthMovementPlayer;
+
+/** Original public setup with the two unopened deal positions reversed. The
+ * active Atreides owns Truthtrance; Emperor has not taken its movement turn.
+ * The original factory's later setup/phase/shipment suffix is not reused or
+ * counted: every continuation below is performed from this unopened snapshot. */
+export function createTruthMovementWaitingFixture(options: { hajr?: boolean } = {}) {
+ const base = createTruthMovementFixture(options);
+ const asker = 'atreides', target = 'emperor';
+ let game = structuredClone(base.beforeStartingDeal);
+ assert.equal(game.status, 'setup');
+ assert.ok(game.players.every(player => player.hand.length === 0));
+ assert.equal(game.deck[0].id, base.targetCard);
+ assert.equal(game.deck[1].id, base.truth);
+ [game.deck[0], game.deck[1]] = [game.deck[1], game.deck[0]];
+ const beforeStartingDeal = structuredClone(game);
+ const dealOrder = [
+ ...structuredClone(base.dealOrder),
+ { position: 0, from: 1, card: base.truth },
+ ];
+ const start = base.actions.findIndex(step => step.action.type === 'start');
+ assert.ok(start >= 0);
+ const actions: TruthMovementStep[] = structuredClone(base.actions.slice(0, start + 1));
+ game = advanceTruthMovement(game, state => state.status === 'playing', actions);
+ const afterSetup = structuredClone(game);
+ assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [base.truth]);
+ assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [base.targetCard]);
+ assert.deepEqual(truthMovementInventory(game), base.originalCards);
+ const startingPositions = Object.fromEntries(game.players.map(player => [player.id, {
+ forces: structuredClone(player.forces), reserves: player.reserves, spice: player.spice,
+ }]));
+ const spiceOrder = base.spiceOrder.map(({ position, card }) => {
+ const from = game.spiceDeck.findIndex((candidate, index) => index >= position &&
+ JSON.stringify(candidate) === JSON.stringify(card));
+ assert.ok(from >= position, 'Choose the same original unopened ordinary Spice card');
+ const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, selected);
+ return { position, from, card: structuredClone(selected) };
+ });
+ assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), base.originalSpice);
+ game = advanceTruthMovement(game, state => state.turn === 1 && state.phase === 5 &&
+ state.active === asker && truthMovementClean(state), actions);
+ const current = truthMovementPlayer(game, asker), waiting = truthMovementPlayer(game, target);
+ assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
+ assert.ok(game.movementRemaining?.includes(asker)); assert.ok(game.movementRemaining?.includes(target));
+ assert.equal(current.shipped, false); assert.equal(current.moved, 0);
+ assert.deepEqual(current.forces, { 'arrakeen:10': 10 }); assert.equal(current.reserves, 10);
+ assert.equal(waiting.shipped, false); assert.equal(waiting.moved, 0);
+ assert.deepEqual(waiting.forces, {}); assert.equal(waiting.reserves, 20);
+ assert.equal(current.spice, startingPositions[asker].spice);
+ assert.equal(waiting.spice, startingPositions[target].spice);
+ assert.equal(game.movementPromises, undefined); assert.ok(!game.truthtrance);
+ assert.deepEqual(truthMovementInventory(game), base.originalCards);
+ return {
+ game, source: structuredClone(game), base, lobby: base.lobby, setup: base.setup,
+ beforeStartingDeal, afterSetup, actions, sourceActionCount: actions.length,
+ asker, target, truth: base.truth, hajr: base.hajr, targetCard: base.targetCard,
+ dealOrder, spiceOrder, originalCards: base.originalCards, originalSpice: base.originalSpice,
+ startingPositions, balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
+ claim: { kind: 'movement' as const, target, territory: 'carthag', minimum: 1 },
+ currentMove: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 10 } satisfies Action,
+ currentEnd: { type: 'endMovement' } satisfies Action,
+ shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action,
+ shipmentCost: 1,
+ move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action,
+ staging: [
+ 'Original public Basic Atreides/Emperor create/join/readiness/start precede an unopened two-position deal reversal: Atreides receives original Truthtrance and Emperor original Hajr (or the explicitly requested original projectile). Actual setup and native Storm/Spice/Charity/Bidding/Revival continue from that snapshot, with only the same two unopened ordinary Spice positions and native scalar lotteries selected. The recorded action prefix includes only that real start and these new continuations, excluding the original factory suffix. Atreides retains its native ten Arrakeen forces and ten reserves; waiting Emperor retains zero board forces, twenty reserves and its native wallet. No dealt hand, wallet, force, phase clock, pending receipt, question or promise is installed. This source stops before any question, answer, active-player shipment/move or waiting-player turn. The suggested active move/end and later Emperor city1 shipment/ground1 remain actual caller actions.',
+ ],
+ };
+}
+export type TruthMovementWaitingFixture = ReturnType;
diff --git a/tests/movement-promises.test.ts b/tests/movement-promises.test.ts
index 6bc94671..ca4925f9 100644
--- a/tests/movement-promises.test.ts
+++ b/tests/movement-promises.test.ts
@@ -93,7 +93,7 @@ function movementBoundary(): Game {
g.movementRemaining = ['target', 'asker']; g.players[0].shipped = true;
return g;
}
-void test('the Basic profile permits a clean active post-shipment respondent with Truthtrance or ordinary flight open', () => {
+void test('the Basic profile permits an active respondent or a still-unused waiting turn with Truthtrance or ordinary flight open', () => {
const g = movementBoundary(); assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
g.truthtrance = { stage: 'ask', queue: [], passed: [], question: null };
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
@@ -102,6 +102,10 @@ void test('the Basic profile permits a clean active post-shipment respondent wit
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
g.players[0].shipped = false; g.players[0].moved = 1;
assert.equal(movementPromiseTargetAvailable(g, 'target'), true);
+ assert.equal(movementPromiseTargetAvailable(g, 'asker'), true);
+ g.players[1].shipped = true;
+ assert.equal(movementPromiseTargetAvailable(g, 'asker'), false);
+ g.players[1].shipped = false; g.players[1].moved = 1;
assert.equal(movementPromiseTargetAvailable(g, 'asker'), false);
});
void test('movement question profile permits an unspent shipment but rejects an exhausted turn and active opposing response', () => {
diff --git a/tests/truth-movement-waiting-controls.test.tsx b/tests/truth-movement-waiting-controls.test.tsx
new file mode 100644
index 00000000..6847d88b
--- /dev/null
+++ b/tests/truth-movement-waiting-controls.test.tsx
@@ -0,0 +1,58 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement, type ReactElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { MovementPromises } from '../components/movement-promises';
+import { NullentropySearch } from '../components/nullentropy-box';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { createTruthMovementWaitingFixture, actTruthMovementWaiting as act } from './fixture-truth-movement-waiting';
+import { createTruthMovementWaitingBoxFixture } from './fixture-truth-movement-waiting-box';
+
+function buttons(node: unknown): { onClick: () => void }[] {
+ if (Array.isArray(node)) return node.flatMap(buttons);
+ if (!node || typeof node !== 'object' || !('props' in node)) return [];
+ const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props;
+ return [...(props.onClick ? [{ onClick: props.onClick }] : []), ...buttons(props.children)];
+}
+function pass(game: Game) {
+ if (game.pendingTreacheryDiscard?.reaction) {
+ const event = game.pendingTreacheryDiscard.batch.event;
+ for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
+ }
+ return game;
+}
+function bind(f: { game: Game; asker: string; target: string; truth: string; claim: Record }) {
+ let game = act(f.game, f.asker, { type: 'card', card: f.truth });
+ for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
+ game = act(game, f.asker, { type: 'truthAsk', question: f.claim });
+ return pass(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }));
+}
+void test('persistent waiting guidance has no premature movement button and becomes executable on the real turn', () => {
+ const f = createTruthMovementWaitingFixture({ hajr: false }); let game = bind(f);
+ const actions: Action[] = [], submit = (action: Action) => { actions.push(action); };
+ const props = { game: viewGame(game, f.target), act: submit, busy: false };
+ assert.match(renderToStaticMarkup(createElement(MovementPromises, props)), /Your Movement turn is still ahead/);
+ assert.equal(buttons(MovementPromises(props)).length, 0);
+ game = act(game, f.asker, f.currentMove); game = act(game, f.asker, f.currentEnd);
+ const own = { ...props, game: viewGame(game, f.target) };
+ assert.doesNotMatch(renderToStaticMarkup(createElement(MovementPromises, own)), /turn is still ahead/);
+ buttons(MovementPromises({ ...own, busy: true }))[0].onClick(); assert.equal(actions.length, 0);
+ buttons(MovementPromises(own))[0].onClick(); assert.equal(actions[0].type, 'ship');
+ game = act(game, f.target, actions[0]); assert.equal(game.movementPromises![0].fulfilled, undefined);
+});
+void test('a waiting paid owner gets only its original paid choice; after selection the future action button disappears', () => {
+ const f = createTruthMovementWaitingBoxFixture(); let game = bind(f);
+ game = act(game, f.target, { type: 'card', card: f.box });
+ const actions: Action[] = [], props = { game: viewGame(game, f.target), act(action: Action) { actions.push(action); }, busy: false };
+ const html = renderToStaticMarkup(createElement(MovementPromises, props));
+ assert.match(html, /already-paid Box search can finish now/); assert.match(html, /Choose Truthtrance in your paid Box search/);
+ const search = renderToStaticMarkup(createElement(NullentropySearch, props));
+ assert.equal((search.match(/Take Truthtrance and finish search/g) ?? []).length, 2);
+ buttons(MovementPromises({ ...props, busy: true }))[0].onClick(); assert.equal(actions.length, 0);
+ buttons(MovementPromises(props))[0].onClick();
+ assert.equal(actions[0].type, 'decision'); assert.equal(actions[0].event, game.pendingNullentropy!.event);
+ game = pass(act(game, f.target, actions[0])); assert.equal(game.active, f.active);
+ const waiting = { ...props, game: viewGame(game, f.target) };
+ assert.equal(buttons(MovementPromises(waiting)).length, 0);
+ assert.match(renderToStaticMarkup(createElement(MovementPromises, waiting)), /turn is still ahead/);
+});
diff --git a/tests/truth-movement-waiting-engine.test.ts b/tests/truth-movement-waiting-engine.test.ts
new file mode 100644
index 00000000..b23e3e27
--- /dev/null
+++ b/tests/truth-movement-waiting-engine.test.ts
@@ -0,0 +1,233 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, normalizeAutomaticGame, createGame, joinGame, newPlayer, type Game } from '../game/engine';
+import { baseDeck, treacheryDeck } from '../game/cards';
+import { richeseCards } from '../game/richese-cards';
+import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { createTruthMovementWaitingFixture as fixture, actTruthMovementWaiting as act,
+ truthMovementWaitingInventory as inventory } from './fixture-truth-movement-waiting';
+import { createTruthMovementWaitingBoxFixture as boxFixture } from './fixture-truth-movement-waiting-box';
+
+function ask(game: Game, asker: string, card: string, question: Record) {
+ game = act(game, asker, { type: 'card', card });
+ for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
+ return act(game, asker, { type: 'truthAsk', question });
+}
+void test('a genuinely waiting Basic respondent can answer a same-round future ground-movement question', () => {
+ const f = fixture({ hajr: false }); const game = ask(f.game, f.asker, f.truth, f.claim);
+ assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
+});
+void test('controlled past BG losses retain an owned future route when accompaniment would consume its last reserve', () => {
+ for (const reserves of [1, 2]) {
+ // Structural timing/physical-loss fixture; separate public sources prove
+ // natural setup. Native faction/leader identities and all twenty counters
+ // are retained, with modeled past losses and bank payments explicit here.
+ const source = createGame('WAITINGMOVEMENTADVISOR', newPlayer('current', 'Current', 'atreides'), false);
+ joinGame(source, newPlayer('waiting', 'Waiting BG', 'beneGesserit'));
+ Object.assign(source, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'current',
+ order: ['current', 'waiting'], movementRemaining: ['current', 'waiting'], deck: baseDeck() });
+ const current = source.players[0], waiting = source.players[1];
+ current.forces = { 'arrakeen:10': 10 }; current.reserves = 10; current.spice = 10;
+ waiting.forces = { 'polar_sink:0': 1 }; waiting.reserves = reserves; waiting.tanks = 19 - reserves; waiting.spice = 1;
+ const index = source.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
+ const card = source.deck.splice(index, 1)[0]; current.hand.push(card);
+ let game = ask(source, current.id, card.id, { kind: 'movement', target: waiting.id, territory: 'carthag', minimum: 1 });
+ game = act(game, waiting.id, { type: 'truthAnswer', answer: 'yes' });
+ game = act(game, current.id, { type: 'ship', territory: 'carthag', sector: 11, amount: 1 });
+ assert.equal(game.decision?.kind, 'advisor');
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(game, waiting.id); view.players.find(p => p.id === waiting.id)!.bot = difficulty;
+ assert.deepEqual(botActions(view), [{ type: 'decision', accept: false }]);
+ }
+ if (reserves === 1) {
+ const before = structuredClone(game);
+ assert.throws(() => act(game, waiting.id, { type: 'decision', accept: true })); assert.deepEqual(game, before);
+ game = act(game, waiting.id, { type: 'decision', accept: false });
+ } else game = act(game, waiting.id, { type: 'decision', accept: true });
+ assert.equal(game.movementPromises![0].released, undefined);
+ game = act(game, current.id, { type: 'endMovement' });
+ game = act(game, waiting.id, viewGame(game, waiting.id).movementPromisePolicy![0]);
+ game = act(game, waiting.id, viewGame(game, waiting.id).movementPromisePolicy![0]);
+ assert.equal(game.movementPromises![0].fulfilled, true);
+ }
+});
+function bind(answer = 'yes') {
+ const f = fixture({ hajr: false });
+ const game = act(ask(f.game, f.asker, f.truth, f.claim), f.target, { type: 'truthAnswer', answer });
+ return { f, game };
+}
+function pass(game: Game) {
+ if (game.pendingTreacheryDiscard?.reaction) {
+ const event = game.pendingTreacheryDiscard.batch.event;
+ for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
+ }
+ while (game.response) {
+ const player = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(player);
+ game = act(game, player.id, { type: 'passResponse' });
+ }
+ return game;
+}
+function paidWaitingBox() {
+ const f = boxFixture(); let game = ask(f.game, f.asker, f.truth, f.claim);
+ game = pass(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }));
+ game = act(game, f.target, { type: 'card', card: f.box });
+ return { f, game };
+}
+void test('a genuinely waiting owner can finish an ordinary paid Box choice while future movement controls stay unavailable', () => {
+ const { f, game: paid } = paidWaitingBox(), before = structuredClone(paid);
+ const search = viewGame(paid, f.target).nullentropy!.search!;
+ assert.deepEqual(search.cards.map(card => card.id).sort(), [...f.expectedPaidChoices].sort());
+ const next = viewGame(paid, f.target).movementPromisePolicy![0];
+ assert.equal(next.type, 'decision'); assert.equal(next.event, search.event);
+ assert.ok(f.expectedPaidChoices.includes(String(next.card))); assert.deepEqual(paid, before);
+ let game = pass(act(paid, f.target, next));
+ assert.equal(game.active, f.active); assert.equal(game.pendingNullentropy, null);
+ assert.equal(game.players.find(p => p.id === f.target)!.spice, f.balances[f.target] - 2);
+ assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
+ game = act(game, f.active, f.currentEnd); assert.equal(game.active, f.target);
+ game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]));
+ game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]));
+ assert.equal(game.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
+});
+void test('all four minimal profiles select the original paid waiting decision, then wait until their real movement turn', () => {
+ const { f, game: paid } = paidWaitingBox();
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(paid, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
+ const choice = botActions(view)[0]; assert.equal(choice.type, 'decision'); assert.equal(choice.event, paid.pendingNullentropy!.event);
+ let game = pass(act(paid, f.target, choice)); assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
+ game = act(game, f.active, f.currentEnd);
+ let count = 0;
+ while (game.phase === 5 && game.active === f.target && count++ < 14) {
+ const own = viewGame(game, f.target); own.players.find(p => p.id === f.target)!.bot = difficulty;
+ const action = botActions(own)[0]; assert.ok(action); game = pass(act(game, f.target, action));
+ }
+ assert.ok(count < 14); assert.equal(game.movementPromises![0].fulfilled, true);
+ assert.equal(game.players.find(p => p.id === f.target)!.spice, 5); assert.deepEqual(inventory(game), f.originalCards);
+ }
+});
+void test('a controlled original Ornithopter acquisition leaves the foreign flight intact during a waiting-owner forecast', () => {
+ const f = boxFixture(), source = structuredClone(f.game);
+ const index = source.richeseCache!.findIndex(card => card.effect === 'ornithopter'); assert.ok(index >= 0);
+ const card = source.richeseCache!.splice(index, 1)[0]; source.players.find(p => p.id === f.active)!.hand.push(card);
+ let game = pass(act(source, f.active, { type: 'ship', territory: 'tueks_sietch', sector: 5, amount: 2 }));
+ game = pass(act(game, f.active, { type: 'move', from: 'tueks_sietch:5', territory: 'south_mesa', sector: 5,
+ amount: 1, movementCard: card.id, ornithopter: 'twoGroups' }));
+ const flight = structuredClone(game.ornithopter); assert.equal(flight?.player, f.active); assert.equal(flight.completed, 1);
+ game = ask(game, f.asker, f.truth, f.claim); assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
+ const before = structuredClone(game); viewGame(game, f.target); assert.deepEqual(game, before);
+ game = pass(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }));
+ assert.deepEqual(game.ornithopter, flight); assert.equal(game.discard.filter(c => c.id === card.id).length, 0);
+ assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
+ game = pass(act(game, f.active, f.currentEnd)); assert.equal(game.active, f.target);
+ assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
+ game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]));
+ game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]));
+ assert.equal(game.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
+});
+void test('a controlled native Fremen response cannot hide a waiting Box owner spending its last shipment money', () => {
+ for (const spice of [2, 3]) {
+ const source = createGame('WAITINGBOXFOREIGNMOVE', newPlayer('f', 'Current Fremen', 'fremen'), false, ['choam']);
+ joinGame(source, newPlayer('e', 'Waiting Emperor', 'emperor')); joinGame(source, newPlayer('r', 'Richese counter', 'richese'));
+ Object.assign(source, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'f', order: ['f', 'e', 'r'],
+ movementRemaining: ['f', 'e', 'r'], deck: treacheryDeck(['choam']), richeseCache: richeseCards() });
+ const [fremen, emperor, richese] = source.players;
+ fremen.forces = { 'red_chasm:7': 4 }; fremen.reserves = 16; fremen.shipped = true;
+ emperor.spice = spice;
+ const take = (effect: string, owner: typeof emperor) => {
+ const index = source.deck.findIndex(card => card.effect === effect); assert.ok(index >= 0);
+ const card = source.deck.splice(index, 1)[0]; owner.hand.push(card); return card.id;
+ };
+ const truth = take('truthtrance', fremen); take('karama', richese);
+ const boxIndex = source.richeseCache!.findIndex(card => card.effect === 'nullentropyBox'); assert.ok(boxIndex >= 0);
+ const box = source.richeseCache!.splice(boxIndex, 1)[0]; emperor.hand.push(box);
+ const old = source.deck.findIndex(card => card.kind === 'shield'); assert.ok(old >= 0);
+ source.discard.push(source.deck.splice(old, 1)[0]);
+ // Modeled past payment/discard and conserved cache acquisition. The native
+ // movement declaration/response and attempted paid Box are actual actions.
+ let game = ask(source, fremen.id, truth, { kind: 'movement', target: emperor.id, territory: 'carthag', minimum: 1 });
+ game = act(game, emperor.id, { type: 'truthAnswer', answer: 'yes' });
+ const destination = TERRITORIES.flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
+ to.sector !== 18 && gameDistance(game, 'red_chasm:7', `${to.territory}:${to.sector}`, key => splitLocation(key).sector === 18) === 2);
+ assert.ok(destination);
+ game = act(game, fremen.id, { type: 'move', from: 'red_chasm:7', amount: 4, ...destination });
+ assert.equal(game.response?.kind, 'fremenMovement'); assert.ok(game.pendingFremenMove);
+ const before = structuredClone(game);
+ if (spice === 2) {
+ assert.throws(() => act(game, emperor.id, { type: 'card', card: box.id })); assert.deepEqual(game, before);
+ } else {
+ const paid = act(game, emperor.id, { type: 'card', card: box.id });
+ const choice = viewGame(paid, emperor.id).movementPromisePolicy![0];
+ assert.equal(choice.type, 'decision'); assert.equal(choice.event, paid.pendingNullentropy!.event);
+ const restored = act(paid, emperor.id, choice);
+ assert.equal(restored.active, fremen.id); assert.equal(restored.response?.kind, 'fremenMovement');
+ assert.deepEqual(restored.pendingFremenMove, before.pendingFremenMove);
+ assert.equal(restored.players.find(p => p.id === emperor.id)!.spice, 1);
+ assert.equal(restored.movementPromises![0].released, undefined);
+ assert.equal(viewGame(restored, emperor.id).movementPromisePolicy, null);
+ }
+ }
+});
+void test('a genuine waiting Yes survives another player matching and ending, then executes only on its actual turn', () => {
+ const { f, game: bound } = bind(); let game = bound;
+ assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
+ const before = structuredClone(game); assert.throws(() => act(game, f.target, f.shipment)); assert.deepEqual(game, before);
+ game = act(game, f.asker, f.currentMove); assert.equal(game.movementPromises![0].fulfilled, undefined);
+ assert.equal(game.movementPromises![0].released, undefined); assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
+ game = act(game, f.asker, f.currentEnd); assert.equal(game.active, f.target);
+ assert.equal(game.movementPromises![0].fulfilled, undefined);
+ game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
+ assert.equal(game.movementPromises![0].fulfilled, undefined);
+ game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
+ assert.equal(game.movementPromises![0].fulfilled, true);
+ game = act(game, f.target, { type: 'endMovement' }); assert.deepEqual(inventory(game), f.originalCards);
+});
+void test('a genuine waiting No ignores another player matching and remains binding until its own actual ending', () => {
+ const { f, game: bound } = bind('no'); let game = act(bound, f.asker, f.currentMove);
+ assert.equal(game.movementPromises![0].fulfilled, undefined);
+ game = act(game, f.asker, f.currentEnd); assert.equal(game.movementPromises![0].fulfilled, undefined);
+ game = act(game, f.target, f.shipment);
+ assert.throws(() => act(game, f.target, f.move));
+ game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true);
+});
+void test('waiting-owner bribes retain the real later shipment fee while actual opposing loss can release Yes', () => {
+ const { f, game } = bind(), before = structuredClone(game);
+ assert.equal(viewGame(game, f.target).bribeOptions.targets.find(p => p.id === f.asker)!.maximum, f.balances[f.target] - 1);
+ assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: f.balances[f.target] }));
+ assert.deepEqual(game, before);
+ const funded = act(game, f.target, { type: 'bribe', target: f.asker, amount: f.balances[f.target] - 1 });
+ assert.equal(funded.movementPromises![0].released, undefined);
+ // Controlled conserved opposing-loss edge, not a claimed natural card effect.
+ const harmed = structuredClone(funded); harmed.players.find(p => p.id === f.target)!.spice--;
+ harmed.players.find(p => p.id === f.asker)!.spice++;
+ assert.equal(normalizeAutomaticGame(harmed).movementPromises![0].released, true);
+});
+void test('a controlled mutual alliance cannot open an Emperor gift that spends the waiting movement route', () => {
+ const { f, game: bound } = bind(), game = structuredClone(bound);
+ const funded = act(bound, f.target, { type: 'bribe', target: f.asker, amount: f.balances[f.target] - 1 });
+ for (const source of [game, funded]) {
+ source.players.find(p => p.id === f.target)!.ally = f.asker;
+ source.players.find(p => p.id === f.asker)!.ally = f.target;
+ }
+ const before = structuredClone(funded);
+ assert.throws(() => act(funded, f.target, { type: 'emperorGift', amount: 1 })); assert.deepEqual(funded, before);
+ const affordable = act(game, f.target, { type: 'emperorGift', amount: 1 });
+ assert.equal(affordable.movementPromises![0].released, undefined);
+});
+void test('all four minimal profiles answer while waiting and finish their actual later turn without premature witness actions', () => {
+ const f = fixture({ hajr: false }), asked = ask(f.game, f.asker, f.truth, f.claim);
+ for (const difficulty of DIFFICULTIES) {
+ let game = asked; const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
+ const answer = botActions(view)[0]; assert.equal(answer.type, 'truthAnswer');
+ game = act(game, f.target, answer); assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
+ game = act(game, f.asker, f.currentMove); game = act(game, f.asker, f.currentEnd);
+ let count = 0;
+ while (game.phase === 5 && game.active === f.target && count++ < 10) {
+ const own = viewGame(game, f.target); own.players.find(p => p.id === f.target)!.bot = difficulty;
+ const next = botActions(own)[0]; assert.ok(next); game = act(game, f.target, next);
+ }
+ assert.ok(count < 10); assert.equal(game.movementPromises![0].fulfilled, true);
+ assert.deepEqual(inventory(game), f.originalCards);
+ }
+});