From 2ae2b984c05c918c7df34be7c6234524c52f1c65 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Sun, 25 Oct 2026 18:38:02 +1100
Subject: [PATCH] Support classic Advanced movement Truthtrance promises
---
components/movement-promises.tsx | 1 +
components/truthtrance.tsx | 3 +-
docs/CURRENT_STATUS.md | 31 ++-
docs/IMPLEMENTATION_STATUS.md | 35 +++
docs/RULE_DECISIONS.md | 12 +
docs/TRUTHTRANCE_MOVEMENT_PROMISES.md | 58 ++++-
game/engine.ts | 28 ++-
game/movement-promise-search.ts | 12 +-
game/movement-promises.ts | 6 +-
game/reference.ts | 13 +-
game/truthtrance.ts | 2 +-
tests/fixture-truth-movement-advanced.ts | 192 ++++++++++++++++
tests/movement-promises.test.ts | 16 +-
.../truth-movement-advanced-controls.test.tsx | 34 +++
tests/truth-movement-advanced-engine.test.ts | 214 ++++++++++++++++++
15 files changed, 634 insertions(+), 23 deletions(-)
create mode 100644 tests/fixture-truth-movement-advanced.ts
create mode 100644 tests/truth-movement-advanced-controls.test.tsx
create mode 100644 tests/truth-movement-advanced-engine.test.ts
diff --git a/components/movement-promises.tsx b/components/movement-promises.tsx
index e6b47659..fbdcf0ed 100644
--- a/components/movement-promises.tsx
+++ b/components/movement-promises.tsx
@@ -32,6 +32,7 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac
: next?.type === 'card' && card?.effect === 'nullentropyBox' ? 'Pay 2 spice and search with Nullentropy Box'
: next?.type === 'card' && card?.effect === 'ghola' ? `Play Ghola to return ${amount} ${amount === 1 ? 'force' : 'forces'} to reserves for the required movement`
: next?.type === 'card' && next.mode === 'choam' ? 'Play Kulon to extend the required movement'
+ : next?.type === 'prepareAdvisors' ? `Prepare advisors in ${destinationLabel} for the required movement`
: next?.type === 'card' ? 'Play Hajr to prepare the required movement'
: next?.type === 'ship' ? `Ship ${amount} forces to ${destinationLabel}, sector ${sector}, before the required movement`
: next?.type === 'endMovement' ? 'Finish movement while honoring No'
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index 0a07b42c..d9bdb1d0 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -350,7 +350,8 @@ 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.phase < 5 && Movement comes later this round. Keep the forces, spice and cards needed by your answer; available Charity or Revival may prepare the route. Current auction commitments reserve their actual funding. Current preparation appears when it is available.
}
- {!g.truthMovementTargets.includes(target) && Choose a supported Basic respondent this round and finish pending decisions. Optional-module and Advanced movement promises remain unfinished.
}
+ {g.advanced && Classic Advanced movement counts physical forces, including each elite once. Hajr remains available only immediately after a completed movement and its required arrival decisions.
}
+ {!g.truthMovementTargets.includes(target) && Choose a supported Basic respondent this round or a classic Advanced respondent during Movement, and finish pending decisions. Advanced expansion and optional-module 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 9cb6009b..0ad2ac02 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -1,6 +1,6 @@
# Current development status
-Updated 24 October 2026. **The full twelve-faction goal is unfinished. Basic
+Updated 25 October 2026. **The full twelve-faction goal is unfinished. Basic
is playable. Advanced, expansion-faction and supported optional-module starts
are labelled previews; complete rules/publication acceptance remains gated.**
@@ -16,6 +16,30 @@ open mode gates.
## Current checkpoint and work
+**Classic Advanced movement promises — 25 October. Prototyped / Partial.**
+Classic Advanced phase5 now accepts structured movement Truthtrance for active
+or unused waiting seats. Shared typed legality counts each elite once; owned
+advisor preparation/arrival flips can supply city range, and Hajr retains its
+immediate timing. Actual matching/Fremen/conversion counters finish before
+transfer or impossibility; persistent guidance uses the same legal witness.
+Basic/native early-phase entry remains. Earlier Advanced, expansion/module
+preparations and arbitrary commitments remain unfinished.
+[Meaning, authority and boundaries](TRUTHTRANCE_MOVEMENT_PROMISES.md#classic-advanced-movement--25-october-2026).
+
+Disjoint native-fixture work, bounded Advanced review and root integration pass
+**26 files238/238**, types/lint/build and four actual-control/GameTable SSR
+journeys, including a real guidance callback, Hajr continuation and typed Fremen
+allowance/prevention. Genuine23/27-action public Advanced sources earn original
+Truthtrance/Hajr, native forces and actual shipment; Fremen earns its original
+Karama through a paid1 auction and places/ships actual Fedaykin. No played state
+is installed in those sources. Separate BG cases label controlled acquisitions/
+stance/reserve placements and exercise original preparation, matching, city-range
+arrival flip and Hajr. Four existing policies finish legally without tuning.
+Fixture feedback corrected an invalid zero-shipment action and an immediately
+settling controlled advisor stance. Final proof is source-bound; SSR/callbacks
+are not hydration/browser/mobile/deployed acceptance. Full modes and final
+assurance remain open; no saved-room/D1/server/restart/deployment operation.
+
**Planetologist resident matching — 24 October. Prototyped / Partial.**
Resident gathers now queue one normal-format matching response per changing
incoming origin. Both physical groups wait through partial allowance; any
@@ -43,9 +67,8 @@ snapshots explicit; the final runtime/union/types/lint/build pass. Wider empty
sequencing, modules, complete modes and final browser/deployed assurance remain
open. No AI refinement or saved-room/D1/server/reset/restart/deployment operation.
-**Requested stopping checkpoint:** the user requested stopping after the next
-Git push. This verified gathering checkpoint is that push; the full objective
-is unchanged and further goal work waits for the user to resume it.
+**Requested stopping checkpoint:** this gathering checkpoint was pushed before
+pausing as requested. The user subsequently resumed; the full objective remains.
**Guild/Junction incoming matching — 24 October. Prototyped / Partial.**
Fresh native Guild and absent-Guild Secret Ally cross-transports plus Junction
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index b50269ab..6bc8ef7c 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,40 @@
# Dune implementation status
+## 25 October 2026 — Classic Advanced structured ground-movement Truthtrance
+
+Classic Advanced without modules now accepts structured movement questions in
+phase5 for active or unused waiting seats. Retain Basic/native early admission,
+physical elite counts, original route validation and immediate Hajr timing.
+Owned advisor preparation and eligible arrival fighter flips can supply later
+city range; detached stance settlement preserves continuously-alone holds and
+removes empty groups without creating matching/automatic receipts. Real native
+Fremen/matching/conversion counters retain priority and physical fulfillment;
+mandatory response passes cannot deadlock opposing impossibility release.
+Human guidance and all four minimal policies share the private witness.
+
+Disjoint fixture work, bounded Advanced review and root integration pass
+**26 files238/238**, types/lint/build and four actual-control/GameTable SSR
+journeys, including a real persistent-guidance callback. Genuine23-action
+Atreides and27-action Fremen public Advanced sources earn original base33 deal,
+Truthtrance/Hajr, original forces, actual paid/free Polar shipment and native
+Fedaykin setup. Emperor actually buys its original Karama for1. No played state
+is installed. Separate BG cases explicitly control original-card acquisition,
+stance and two reserve placements while exercising actual production counters,
+preparation and later moves. Fixture feedback corrects an invalid zero-shipment
+payload and a controlled advisor stance that would immediately settle.
+
+Private proof and HTML:
+`/home/quintine/.local/share/dune/checkpoints/20261025-truth-movement-advanced/`.
+`frozen-integrated/report.json` binds tree
+`2bbb98a518df218683789a6c183d4985c750403f42cb7e82ac5a23736c8681bc`,
+2773files on base `0d8481e8216c20cfe100d903fe4c7d41f9136dc7`.
+Final four prose/reference overlays are reviewed separately. This reuses the
+existing general FAQ composition, not a new movement-specific publisher ruling.
+Earlier Advanced, expansions/modules, broader commitments and full-mode/final
+rules/recovery/privacy/custody/browser/deployed acceptance remain open. No AI
+refinement or saved-room/D1/server/reset/restart/deployment operation.
+[Canonical contract](TRUTHTRANCE_MOVEMENT_PROMISES.md#classic-advanced-movement--25-october-2026).
+
## 24 October 2026 — One matching counter per changing resident-gather origin
Supplied physical28's resident matching and physical17's one-group counter now
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 63a64584..99d1c8a2 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,17 @@
# Rule decision index
+## Classic Advanced ground-movement promises — 25 October 2026
+
+Extend the existing general current-turn Truthtrance composition to clean
+classic Advanced Movement. Count physical typed counters once, retain immediate
+Hajr timing and quote native routes. Owned advisor preparation/arrival flips
+may supply an allowed reachability witness; actual independent counters still
+resolve before commitment/impossibility. Normal stance settlement preserves
+continuously-alone holds. A mandatory final response pass is not a voluntary
+way to destroy or evade a promise. No new publisher timing ruling is claimed;
+earlier Advanced, expansions/modules and arbitrary commitments remain open.
+[Meaning, sources and native/controlled evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md#classic-advanced-movement--25-october-2026).
+
## Planetologist resident matching groups — 24 October 2026
Compose supplied physical28's resident-matching rule with physical17's counter
diff --git a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md
index d7d9e2ed..8f533cf4 100644
--- a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md
+++ b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md
@@ -1,8 +1,60 @@
# Truthtrance ground-movement promises
-The [Ghola preparation follow-up](#owned-ghola-preparation--24-october-2026)
-supersedes preceding timing boundaries. Previous checkpoint counts remain dated
-evidence, not complete rules or final acceptance.
+## Classic Advanced Movement — 25 October 2026
+
+**Prototyped / Partial.** Classic Advanced tables without expansions or optional
+modules now accept the same structured movement question during phase5, for an
+active or still-unused waiting respondent. Basic's existing native-family and
+earlier-phase entry stays available. Earlier Advanced phases, Advanced expansion
+families, modules and arbitrary promises remain unfinished.
+
+The single-event meaning and general current-turn FAQ authority below still
+apply; this is a software composition, not a new movement-specific publisher
+ruling. Count physical counters, including each Fedaykin/Sardaukar once, through
+the shared typed movement validator. A reserve shipment can prepare a larger
+group but cannot fulfill movement. Hajr requires the actual immediate
+post-movement opportunity; search cannot play it before the first move or after
+an intervening voluntary action has closed that opportunity.
+
+Private reachability includes the owned native pre-first-shipment advisor
+preparation and an eligible incoming advisor's requested fighter flip. Project
+the allowed owned advantage and normal stance settlement without creating
+matching events or new automatic-flip receipts. This retains continuously-alone
+holds and removes empty advisor groups. The real action still resolves its
+original independent Karama counter. Keep promises pending across original
+Fremen/matching/conversion decisions; fulfill only on a physical matching move.
+A final mandatory response pass cannot voluntarily evade a promise or prevent
+release when an opponent's completed counter makes it impossible. Search memo
+keys retain current stances, prevention markers and the immediate Hajr window.
+
+The [native fixture](../tests/fixture-truth-movement-advanced.ts) selects original
+unopened lotteries/deal/Spice positions, uses the real lobby Advanced selector
+and preview start, and earns original Truthtrance/Hajr through actual dealing.
+The Atreides branch retains its native ten Arrakeen counters after a paid Polar
+shipment. The Fremen branch actually places two Fedaykin at Tabr, buys Emperor's
+original Karama for1 and ships the last reserve Fedaykin to Polar. No played
+clock, force, wallet, hand or promise is installed. Separate BG regressions
+explicitly label controlled original-card acquisition/stance/reserve placement;
+native preparation, paid shipment, original counter and later movement still
+use production controls. This is not native provenance for those overlays.
+
+Evidence: [rule/control tests](../tests/truth-movement-advanced-engine.test.ts),
+[actual callback test](../tests/truth-movement-advanced-controls.test.tsx).
+Frozen **26 files238/238**, types/lint/build and four actual-control/GameTable
+SSR journeys pass, including a real persistent-guidance callback. Native source
+prefixes take23/27 actions. Private proof and HTML:
+`/home/quintine/.local/share/dune/checkpoints/20261025-truth-movement-advanced/`;
+`frozen-integrated/report.json` binds tree
+`2bbb98a518df218683789a6c183d4985c750403f42cb7e82ac5a23736c8681bc`,
+2773files on base `0d8481e8216c20cfe100d903fe4c7d41f9136dc7`.
+Final prose/reference overlays are separately reviewed.
+All four existing profiles use the same legal guidance; no strategic tuning or
+calibration is added. Browser/hydration/mobile/deployed and comprehensive final
+rules/recovery/privacy/custody acceptance remain open. No saved-room, database,
+server restart or deployment operation is part of this checkpoint.
+
+The preceding Basic records below retain their dated scope; the Advanced entry
+above supersedes only their blanket Advanced exclusion.
## Basic prototype — 24 October 2026
diff --git a/game/engine.ts b/game/engine.ts
index 79fc3748..b305dc5b 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -16635,6 +16635,14 @@ function groundMovementEvent(move: MovementOrder) {
function projectGroundMovement(source: Game, move: MovementOrder): Game {
const game = structuredClone(source), player = getPlayer(game, move.player);
commitGroundMovementPieces(game, move);
+ // Witness the allowed owned arrival without opening another real response.
+ // The live declaration retains its matching/conversion counters. Settlement
+ // also removes emptied advisor groups and preserves an existing alone hold.
+ if (move.wantsFighters) {
+ if (player.advisors) delete player.advisors[move.to];
+ game.response = null;
+ }
+ settleAdvisors(game);
const flight = game.ornithopter;
if (flight?.player === player.id) {
flight.completed++;
@@ -16725,6 +16733,18 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises:
}
return;
}
+ if (state.advanced && player.faction === 'beneGesserit') {
+ for (const territory of quoteAdvisorPreparationWindow(state, owner).territories) {
+ const action: Action = { type: 'prepareAdvisors', territory };
+ try {
+ const prepared = applyActionInner(state, owner, action, 'movementPreparation');
+ // Assume this owned advantage is allowed in the witness; the live
+ // action still opens its real independently counterable response.
+ if (prepared.response?.advisorPreparationSource) finishResponse(prepared, false);
+ yield { game: groundMovementProbe(prepared, owner), actions: [action] };
+ } catch (error) { if (!(error instanceof RuleError)) throw error; }
+ }
+ }
yield* movementShipmentPreparations(state, owner, (source, actor, action) => {
const payer = getPlayer(source, actor), share = Number(action.allyPayment);
const cost = reserveShipmentCost({ faction: payer.faction, halfRate: shipmentHalfRate(source, payer) },
@@ -16860,9 +16880,13 @@ function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Actio
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;
+ 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;
const early = g.phase < 5 || sourcePhase < 5;
- const voluntary = actor === p.id && (!early || !!action &&
+ const voluntary = actor === p.id && action?.type !== 'passResponse' && (!early || !!action &&
(['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', '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)));
diff --git a/game/movement-promise-search.ts b/game/movement-promise-search.ts
index 5c5bdb43..e770e2ee 100644
--- a/game/movement-promise-search.ts
+++ b/game/movement-promise-search.ts
@@ -50,8 +50,10 @@ export function findMovementCompletion(source: Game, actor: string,
// validates every owned card and never inspects unknown Box faces here.
const recovery = player.hand.some(card => card.effect === 'ghola' || card.effect === 'nullentropyBox') ? player.tanks : 0;
if (live.some(promise => promise.answer && promise.minimum > physical + (player.shipped ? 0 : player.reserves + recovery))) return null;
- const key = JSON.stringify([player.forces, player.elites, player.moved, player.shipped, player.reserves, player.tanks, player.hand.map(card => card.id),
- player.hajrPlays, player.ornithopterExtraMoves, player.spice, game.choamMovement,
+ const key = JSON.stringify([player.forces, player.elites, player.advisors, player.moved, player.shipped, player.reserves, player.tanks, player.hand.map(card => card.id),
+ player.hajrPlays, player.hajrWindow, player.ornithopterExtraMoves, player.spice, game.choamMovement,
+ player.fremenMovementBlocked, player.advisorFlipMovementBlocked, game.advisorMatchingDenied,
+ game.advisorPreparation,
game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises,
game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy,
game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null,
@@ -69,6 +71,8 @@ export function findMovementCompletion(source: Game, actor: string,
? [{ ornithopterEvent: game.ornithopter.event }]
: [{}, ...(card ? [{ movementCard: card.id, ornithopter: 'range3' },
{ movementCard: card.id, ornithopter: 'twoGroups' }] : [])];
+ const stances: Partial[] = game.advanced && player.faction === 'beneGesserit'
+ ? [{}, { fighters: true }] : [{}];
// An exhausted ordinary move cannot leave Yes pending without an owned
// card that could grant another move. Treat held Box/Hajr/Ornithopter
// conservatively; their actual availability remains the adapter's job.
@@ -85,8 +89,8 @@ export function findMovementCompletion(source: Game, actor: string,
const event = { territory: destination.id, amount };
if (!movementEventAllowed(live, event)) continue;
if (!mayMoveAgain && live.some(promise => promise.answer && !movementMatches(promise, event))) continue;
- for (const group of groups(player, keys, amount)) for (const mode of modes) {
- const action = { ...group, ...mode, territory: destination.id, sector };
+ for (const group of groups(player, keys, amount)) for (const mode of modes) for (const stance of stances) {
+ const action = { ...group, ...mode, ...stance, territory: destination.id, sector };
const next = adapter.project(game, actor, action);
if (!next) continue;
const settled = settleMovementEvent(pending, actor, game.turn, next.event);
diff --git a/game/movement-promises.ts b/game/movement-promises.ts
index 400559c5..05b83861 100644
--- a/game/movement-promises.ts
+++ b/game/movement-promises.ts
@@ -84,7 +84,11 @@ export function finishMovementPromises(
/** Prototype profile/timing only. Actual route feasibility stays authoritative
* in the engine. Truthtrance and an ordinary active Ornithopter may remain. */
export function movementPromiseModeSupported(g: Game): boolean {
- return g.advanced === false &&
+ // Basic retains its native-family/early-phase prototype. Advanced starts
+ // with classic factions during Movement; earlier forecasts and expansion
+ // preparations are separate scope, not implied by this admission.
+ return (g.advanced === false ||
+ g.advanced === true && g.phase === 5 && g.expansions.length === 0) &&
g.players.length >= 2 && g.players.length <= 6 &&
g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
new Set(g.expansions).size === g.expansions.length &&
diff --git a/game/reference.ts b/game/reference.ts
index 62f1224a..3ea89956 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3003,9 +3003,9 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
- summary: 'Bind one future qualifying physical ground movement before a Basic respondent finishes their current-round turn.',
+ summary: 'Bind one future qualifying physical ground movement before a supported Basic or classic Advanced respondent finishes their current-round turn.',
steps: [
- 'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Basic with declared native families supports clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Optional modules, Advanced, later rounds and wider owned preparations remain unfinished.',
+ 'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Basic with declared native families supports clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Classic Advanced without modules also supports its current Movement phase. Advanced earlier phases/expansions, optional modules, later rounds and wider owned preparations 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.',
@@ -3015,15 +3015,16 @@ export const RULE_TOPICS: RuleTopic[] = [
'Ghola returns up to five eligible forces to reserves using the physical card and current typed allowance, without using normal revival slots. It does not fulfill ground movement: an unused affordable shipment and later matching move remain necessary. A known spent Ghola can be recovered and replayed through Box. Possible Tleilaxu income remains conditional and supplies no forecast cash.',
'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.',
+ 'Classic Advanced counts each physical elite once and retains Hajr’s immediate post-movement timing. Owned native advisor preparation and an eligible arrival fighter flip may prepare city range; the original Karama response still decides the real advantage. Matching/Fremen/conversion counters keep Yes pending until transfer or completed impossibility. A mandatory response pass cannot evade the obligation or deadlock release.',
'Persistent answer and next-step controls do not time out. All four profiles use the same private legal guidance. Broader Truthtrance questions, complete modes, full combinations and final acceptance remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Basic declared native families now join classic phases0–4. Exact Richese own/donor invoices, full submitted-ally Silent locks and already-paid boundaries prepare the owned turn without speculative bought cards/income. Fixed non-CHOAM preopening Charity and shared ordinary force-revival accounting preserve real native counter decisions. Mandatory Ix phase5 opening retains the future turn/reset. Held Ghola and known/paid Box recovery now add exact force returns and later shipment; normal revival allowances stay separate and native income is not precredited. CHOAM own unresolved opening, later rounds, wider preparations/modules remain unfinished.', evidence: ['game/ghola-force-preparation.ts', 'game/early-auction-funding.ts', '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: 'Physical Ghola force-return guidance now precedes actual reserve shipment and ground movement, with income/disposal controls retaining priority. Persistent busy-safe guidance includes current Charity, Revival, Ready, ordinary bid-pass/payment and event-bearing native Once Around pass/Silent zero. Foreign pending funding cannot force a speculative positive bid or expose future movement. Actual native pass/zero handlers preserve unread obligations. Existing waiting/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', 'tests/truth-movement-early-controls.test.tsx', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/truth-movement-ghola-controls.test.tsx'] },
- { area: 'AI', status: 'Partial', detail: 'Eight existing-profile held-Ghola/known-Box runs accept40 proposals plus24 separate native continuations. Physical return, exact paid recovery, unused shipment and qualifying ground move use the same private legal guidance. Earlier native funding paths remain. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-ghola-engine.test.ts', 'tests/truth-movement-native-early-engine.test.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Classic Advanced phase5 now reuses the typed route witness, immediate Hajr timing and owned advisor preparation/arrival flips with real counters. Basic declared native families join classic phases0–4. Exact Richese own/donor invoices, full submitted-ally Silent locks and already-paid boundaries prepare the owned turn without speculative bought cards/income. Fixed non-CHOAM preopening Charity and shared ordinary force-revival accounting preserve real native counter decisions. Mandatory Ix phase5 opening retains the future turn/reset. Held Ghola and known/paid Box recovery now add exact force returns and later shipment; normal revival allowances stay separate and native income is not precredited. CHOAM own unresolved opening, later rounds, wider preparations/modules remain unfinished.', evidence: ['game/ghola-force-preparation.ts', 'game/early-auction-funding.ts', '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: 'Classic Advanced questions and persistent guidance now connect an actual legal movement callback; native advisor preparation has a named action. Physical Ghola force-return guidance now precedes actual reserve shipment and ground movement, with income/disposal controls retaining priority. Persistent busy-safe guidance includes current Charity, Revival, Ready, ordinary bid-pass/payment and event-bearing native Once Around pass/Silent zero. Foreign pending funding cannot force a speculative positive bid or expose future movement. Actual native pass/zero handlers preserve unread obligations. Existing waiting/card controls remain; browser/visual acceptance stays open.', evidence: ['tests/truth-movement-advanced-controls.test.tsx', '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', 'tests/truth-movement-early-controls.test.tsx', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/truth-movement-ghola-controls.test.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All four existing profiles finish a genuine classic Advanced movement question legally without strategy changes. Eight earlier existing-profile held-Ghola/known-Box runs accept40 proposals plus24 separate native continuations. Physical return, exact paid recovery, unused shipment and qualifying ground move use the same private legal guidance. Earlier native funding paths remain. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-ghola-engine.test.ts', 'tests/truth-movement-native-early-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: 'Current22-file182/182, types/lint/build and four actual-control/GameTable SSR programmes pass. Original held/Box prefixes42/104/75/151 use native setup, actual cache purchase, paid desert shipments, Storm casualties and settled bribes without Advanced city income. Eight minimal-profile runs accept40 proposals plus24 separate native continuations. Controlled acquisitions/losses/knowledge and counter-enabled income disclose staging. Ghola discard relevance and a fixture field collision are repaired before final frozen proof. Earlier counts remain historical. Full modes, wider preparations, browser/deployed and comprehensive assurance remain open.', 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', 'tests/truth-movement-early-engine.test.ts', 'tests/truth-movement-early-controls.test.tsx', 'tests/fixture-truth-movement-early.ts', 'tests/early-auction-funding.test.ts', 'tests/truth-movement-native-early-engine.test.ts', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/fixture-truth-movement-native-early.ts', 'tests/fixture-truth-movement-ghola.ts', 'tests/truth-movement-ghola-engine.test.ts', 'tests/truth-movement-ghola-controls.test.tsx', 'tests/movement-discard-knowledge.test.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
+ { area: 'Verification', status: 'Partial', detail: '25 October classic Advanced:26 files238/238, types/lint/build and four actual-control/GameTable SSR journeys, including a real guidance callback, pass. Genuine23/27-action Atreides/Fremen sources earn original setup/cards, paid/free shipment and actual Karama auction. BG acquisition/stance/reserve overlays are explicitly controlled. Hajr, typed counter outcomes, preparation/matching and city-range arrival flips pass. Earlier22-file182/182 Ghola evidence follows. Original held/Box prefixes42/104/75/151 use native setup, actual cache purchase, paid desert shipments, Storm casualties and settled bribes without Advanced city income. Eight minimal-profile runs accept40 proposals plus24 separate native continuations. Controlled acquisitions/losses/knowledge and counter-enabled income disclose staging. Ghola discard relevance and a fixture field collision are repaired before final frozen proof. Earlier counts remain historical. Full modes, wider preparations, browser/deployed and comprehensive assurance remain open.', evidence: ['tests/truth-movement-advanced-engine.test.ts', 'tests/truth-movement-advanced-controls.test.tsx', 'tests/fixture-truth-movement-advanced.ts', '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', 'tests/truth-movement-early-engine.test.ts', 'tests/truth-movement-early-controls.test.tsx', 'tests/fixture-truth-movement-early.ts', 'tests/early-auction-funding.test.ts', 'tests/truth-movement-native-early-engine.test.ts', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/fixture-truth-movement-native-early.ts', 'tests/fixture-truth-movement-ghola.ts', 'tests/truth-movement-ghola-engine.test.ts', 'tests/truth-movement-ghola-controls.test.tsx', 'tests/movement-discard-knowledge.test.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] },
],
},
{
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 1d311baa..f110d79e 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -209,7 +209,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 need a supported Basic opportunity this round with pending decisions finished. Optional modules and Advanced remain unfinished.');
+ 'Ground-movement promises need a supported Basic opportunity this round or a classic Advanced Movement turn, with pending decisions finished. Optional modules and Advanced expansion preparations remain unfinished.');
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-truth-movement-advanced.ts b/tests/fixture-truth-movement-advanced.ts
new file mode 100644
index 00000000..38fddfbd
--- /dev/null
+++ b/tests/fixture-truth-movement-advanced.ts
@@ -0,0 +1,192 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { distance, location, TERRITORIES } from '../game/board';
+import { botGroundMoveAllowed } from '../game/bot-mobility';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+import {
+ actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer,
+ type TruthMovementStep,
+} from './fixture-truth-movement';
+
+export { actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer };
+export type TruthMovementAdvancedStep = TruthMovementStep;
+export type TruthMovementAdvancedTransition = { before: Game; steps: TruthMovementStep[]; after: Game };
+export const actTruthMovementAdvanced = actTruthMovement;
+export const truthMovementAdvancedClean = truthMovementClean;
+export const truthMovementAdvancedInventory = truthMovementInventory;
+export const truthMovementAdvancedPlayer = (game: Game, actor = game.players[0].id) => truthMovementPlayer(game, actor);
+
+/** Native original setup and phase controls only. Truthtrance and the shipped
+ * respondent's movement remain caller-owned; no promise is silently closed. */
+export function nextTruthMovementAdvancedNativeStep(game: Game): TruthMovementStep | null {
+ if (game.truthtrance) return null;
+ const target = game.players[0].id;
+ if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard &&
+ game.status === 'setup' && game.setupStage === 'forces' &&
+ truthMovementPlayer(game, target).faction === 'fremen' && truthMovementPlayer(game, target).reserves === 20)
+ return { actor: target, action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 },
+ elitePlacements: { 'sietch_tabr:14': 2 } } };
+ if (game.phase === 5 && game.active === target && truthMovementPlayer(game, target).shipped && truthMovementClean(game)) return null;
+ return nextStrongholdFactionsNativeStep(game);
+}
+export function advanceTruthMovementAdvanced(game: Game, until: (state: Game) => boolean,
+ actions: TruthMovementStep[] = []): Game {
+ for (let count = 0; count < 1200; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original Advanced movement source ended before its requested control');
+ const next = nextTruthMovementAdvancedNativeStep(game);
+ assert.ok(next, `Supply the original Truthtrance or ground movement at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`);
+ game = actTruthMovement(game, next.actor, next.action, actions);
+ }
+ throw Error('The original Advanced Truthtrance movement source did not reach its finite boundary');
+}
+export { advanceTruthMovementAdvanced as advanceTruthMovement };
+
+/** Public rules selection and original Advanced preview start. Original Hajr
+ * and Truthtrance are actually dealt; all later clocks/counters/payment arise
+ * from native controls. The optional Fremen route earns a real auction Karama. */
+export function createTruthMovementAdvancedFixture(options: {
+ hajr?: boolean; targetFaction?: 'atreides' | 'fremen';
+} = {}) {
+ const targetFaction = options.targetFaction ?? 'atreides', target = targetFaction, asker = 'emperor';
+ const withHajr = options.hajr ?? true;
+ let game = createGame('TRUTHMOVEMENTADVANCED', newPlayer(target, targetFaction === 'atreides' ? 'Atreides' : 'Fremen', targetFaction), false);
+ joinGame(game, newPlayer(asker, 'Emperor', 'emperor'));
+ const initial = structuredClone(game), actions: TruthMovementStep[] = [];
+ const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceTruthMovementAdvanced(game, until, actions); };
+ const transition = (run: () => void): TruthMovementAdvancedTransition => {
+ const before = structuredClone(game), start = actions.length; run();
+ return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
+ };
+ const rules = transition(() => { act(game.host, { type: 'rules', advanced: true }); });
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
+ const setup = structuredClone(game);
+ assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.advancedPreview, true);
+ assert.deepEqual(game.expansions, []);
+ assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
+ !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
+ assert.ok(game.players.every(player => !player.hand.length));
+ const originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 33);
+ const targetCard = game.deck.find(card => withHajr ? card.effect === 'hajr' : card.kind === 'projectile');
+ const truthtrance = game.deck.find(card => card.effect === 'truthtrance');
+ const karama = targetFaction === 'fremen' ? game.deck.find(card => card.effect === 'karama') : null;
+ assert.ok(targetCard && truthtrance); assert.ok(targetFaction !== 'fremen' || karama);
+ const selected = [targetCard, truthtrance, ...(karama ? [karama] : [])];
+ const deal = selected.slice(0, 2).map(card => card.id);
+ const dealOrder = selected.map((card, position) => {
+ const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ return { position, from, card: card.id };
+ });
+ const beforeStartingDeal = structuredClone(game);
+ let fremenSetup: TruthMovementAdvancedTransition | null = null;
+ for (let count = 0; game.status === 'setup' && count < 100; count++) {
+ const next = nextTruthMovementAdvancedNativeStep(game); assert.ok(next);
+ const before = next.action.type === 'fremenSetup' ? structuredClone(game) : null;
+ act(next.actor, next.action);
+ if (before) fremenSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
+ }
+ assert.equal(game.status, 'playing');
+ const afterSetup = structuredClone(game);
+ assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
+ assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]);
+ if (targetFaction === 'atreides') assert.deepEqual(truthMovementPlayer(game, target).forces, { 'arrakeen:10': 10 });
+ else {
+ assert.ok(fremenSetup); assert.deepEqual(truthMovementPlayer(game, target).forces, { 'sietch_tabr:14': 10 });
+ assert.equal(truthMovementPlayer(game, target).elites?.forces['sietch_tabr:14'], 2);
+ assert.equal(truthMovementPlayer(game, target).elites?.reserves, 1);
+ }
+ const startingPositions = Object.fromEntries(game.players.map(player => [player.id, {
+ forces: structuredClone(player.forces), reserves: player.reserves, spice: player.spice,
+ elites: structuredClone(player.elites),
+ }]));
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ const spiceOrder = [0, 1].map(position => {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
+ assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
+ game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
+ });
+ assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
+ let karamaAcquisition: TruthMovementAdvancedTransition | null = null;
+ if (karama) {
+ reach(state => state.phase === 3 && truthMovementClean(state) && state.auction?.active === asker &&
+ state.auction.cards[state.auction.index]?.id === karama.id);
+ karamaAcquisition = transition(() => {
+ act(asker, { type: 'bid', amount: 1, allyPayment: 0 });
+ reach(state => truthMovementClean(state) && truthMovementPlayer(state, asker).hand.some(card => card.id === karama.id));
+ });
+ }
+ reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthMovementClean(state));
+ const beforeShipment = structuredClone(game);
+ assert.equal(truthMovementPlayer(game, target).shipped, false);
+ const shipmentStep: TruthMovementStep = { actor: target, action: {
+ type: 'ship', amount: 1, territory: 'polar_sink', sector: 0,
+ elite: targetFaction === 'fremen' ? 1 : 0, allyPayment: 0,
+ } };
+ const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(truthMovementClean); });
+ const player = truthMovementPlayer(game, target), shipmentCost = truthMovementPlayer(beforeShipment, target).spice - player.spice;
+ assert.equal(game.phase, 5); assert.equal(game.turn, 1); assert.equal(game.active, target);
+ assert.equal(player.shipped, true); assert.equal(player.moved, 0); assert.equal(player.forces['polar_sink:0'], 1);
+ assert.equal(player.reserves, 9); assert.equal(shipmentCost, targetFaction === 'fremen' ? 0 : 2);
+ if (targetFaction === 'atreides') assert.equal(player.forces['arrakeen:10'], 10);
+ else {
+ assert.equal(player.elites?.forces['polar_sink:0'], 1); assert.equal(player.elites?.forces['sietch_tabr:14'], 2);
+ assert.equal(player.elites?.reserves, 0); assert.ok(truthMovementPlayer(game, asker).hand.some(card => card.id === karama!.id));
+ }
+ assert.ok(player.hand.some(card => card.id === targetCard.id));
+ assert.ok(truthMovementPlayer(game, asker).hand.some(card => card.id === truthtrance.id));
+ assert.deepEqual(truthMovementInventory(game), originalCards);
+ const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } };
+ const matchingMove: Action = targetFaction === 'atreides'
+ ? { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 6 }
+ : { type: 'move', from: 'polar_sink:0', territory: 'imperial_basin', sector: 9, amount: 1, elite: 1 };
+ const questionAction: Action = { type: 'truthAsk', question: { kind: 'movement', target,
+ territory: targetFaction === 'atreides' ? 'carthag' : 'imperial_basin', minimum: targetFaction === 'atreides' ? 6 : 1 } };
+ let twoStepMove: Action | null = null, twoStepDestination: string | null = null;
+ if (targetFaction === 'fremen') {
+ const view = viewGame(game, target), own = view.players.find(candidate => candidate.id === target)!;
+ const destination = TERRITORIES.flatMap(territory => territory.sectors.map(sector => ({ territory: territory.id, sector })))
+ .find(to => to.sector !== game.storm && distance('polar_sink:0', location(to.territory, to.sector)) === 2 &&
+ botGroundMoveAllowed(view, own, 'polar_sink:0', location(to.territory, to.sector), 1));
+ assert.ok(destination, 'The original Fedaykin group has a legal native two-step movement');
+ twoStepDestination = destination.territory;
+ twoStepMove = { type: 'move', from: 'polar_sink:0', ...destination, amount: 1, elite: 1 };
+ }
+ return { game, source: structuredClone(game), initial, rules, lobby, setup, beforeStartingDeal, afterSetup,
+ fremenSetup, beforeShipment, shipment: { ...shipment, step: shipmentStep }, shipmentStep, shipmentCost,
+ targetFaction, asker, target, truth: truthtrance.id, truthtrance: truthtrance.id,
+ hajr: withHajr ? targetCard.id : null, targetCard: targetCard.id, karama: karama?.id ?? null, karamaAcquisition,
+ truthStep, declaration: truthStep.action, questionAction, matchingMove, twoStepMove, twoStepDestination,
+ nonmatchingMove: targetFaction === 'atreides'
+ ? { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 4 } satisfies Action : null,
+ hajrAction: withHajr ? { type: 'card', card: targetCard.id } satisfies Action : null,
+ typedGroup: targetFaction === 'fremen' ? { source: 'polar_sink:0', normal: 0, elite: 1 } : null,
+ deal, dealOrder, spiceOrder, originalCards, originalInventory: originalCards, originalSpice, startingPositions,
+ actions, steps: actions, sourceActionCount: actions.length,
+ balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])),
+ staging: [
+ 'Actual public two-seat classic lobby starts Basic, uses host rules advanced:true, readies both seats and submits exact start advancedPreview:true. No played Basic state is converted and no development initializer is used.',
+ 'Select only original scalar lotteries, unopened starting-deal positions and two original unopened ordinary Spice positions. The target really receives Hajr (or the explicitly requested ordinary projectile) and Emperor really receives Truthtrance from the native base33 deal.',
+ targetFaction === 'atreides'
+ ? 'Actual Atreides private Traitor setup retains its original ten Arrakeen counters and ten reserves. Native safe Storm, both Advanced Spice blows, Charity, Bidding and Revival precede an actual two-spice one-force Polar Sink shipment; the original ten Arrakeen forces and held Hajr remain.'
+ : 'Actual Fremen setup places ten Tabr14 counters including two original Fedaykin, retaining one original reserve Fedaykin. Emperor actually wins the original next Karama auction lot with a one-spice invoice through native bidding/payment/income. Original Storm/forecast responses, both Spice blows, Charity and Revival precede a real free Polar Sink shipment of the last reserve Fedaykin.',
+ 'Stop at clean first Shipment and Movement with the target active, actual shipment used and ordinary ground movement unused. Source, beforeShipment, original paid/dealt cards and typed counters are real chronology snapshots. No hand, force, wallet, phase, allowance, movement promise, pending response or receipt is installed.',
+ 'Truthtrance declaration/question, Hajr play and returned original ground movements remain caller-owned. This source establishes native Advanced provenance only; it does not certify the expanded forecast, every faction/module or later-round movement semantics.',
+ ] };
+}
+export type TruthMovementAdvancedFixture = ReturnType;
+export function createFremenTruthMovementAdvancedFixture(options: { hajr?: boolean } = {}) {
+ return createTruthMovementAdvancedFixture({ ...options, targetFaction: 'fremen' });
+}
+
+/** Submit a real declaration, priority passes and one structured question. */
+export function askTruthMovementAdvanced(game: Game, asker: string, card: string,
+ target: string, minimum = 6, territory = 'carthag') {
+ game = actTruthMovement(game, asker, { type: 'card', card });
+ for (const player of game.players) if (!game.truthtrance!.passed.includes(player.id))
+ game = actTruthMovement(game, player.id, { type: 'truthPass' });
+ return actTruthMovement(game, asker,
+ { type: 'truthAsk', question: { kind: 'movement', target, territory, minimum } });
+}
diff --git a/tests/movement-promises.test.ts b/tests/movement-promises.test.ts
index 8c477539..19b6fdd2 100644
--- a/tests/movement-promises.test.ts
+++ b/tests/movement-promises.test.ts
@@ -116,7 +116,7 @@ void test('movement question profile permits an unspent shipment but rejects an
const response = movementBoundary(); response.response = { kind: 'fremenMovement', owner: 'target', location: 'carthag:11', amount: 1, passed: [] };
assert.equal(movementPromiseTargetAvailable(response, 'target'), false);
const advanced = movementBoundary(); advanced.advanced = true;
- assert.equal(movementPromiseTargetAvailable(advanced, 'target'), false);
+ assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true);
});
void test('Basic native selected families qualify, while optional modules and undeclared native families do not', () => {
const native = movementBoundary(); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
@@ -143,3 +143,17 @@ void test('earlier current-round movement question profiles include declared Bas
assert.equal(movementPromiseTargetAvailable(g, 'target'), false);
}
});
+
+void test('classic Advanced Movement admits active and unused waiting seats while retaining module and native-family boundaries', () => {
+ const advanced = movementBoundary(); advanced.advanced = true;
+ assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true);
+ assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), true);
+ for (const field of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
+ 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery'] as const) {
+ assert.equal(movementPromiseTargetAvailable({ ...advanced, [field]: true } as unknown as Game, 'target'), false, field);
+ }
+ const native = structuredClone(advanced); native.players[0].faction = 'ixians'; native.expansions = ['ix'];
+ assert.equal(movementPromiseTargetAvailable(native, 'target'), false);
+ advanced.players[1].shipped = true;
+ assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), false);
+});
diff --git a/tests/truth-movement-advanced-controls.test.tsx b/tests/truth-movement-advanced-controls.test.tsx
new file mode 100644
index 00000000..3e515c6b
--- /dev/null
+++ b/tests/truth-movement-advanced-controls.test.tsx
@@ -0,0 +1,34 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement, type ReactElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { Truthtrance } from '../components/truthtrance';
+import { MovementPromises } from '../components/movement-promises';
+import { viewGame, type Action } from '../game/engine';
+import { createTruthMovementAdvancedFixture, askTruthMovementAdvanced,
+ actTruthMovement as act } from './fixture-truth-movement-advanced';
+
+function callbacks(node: unknown): (() => void)[] {
+ if (Array.isArray(node)) return node.flatMap(callbacks);
+ if (!node || typeof node !== 'object' || !('props' in node)) return [];
+ const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props;
+ return [...(props.onClick ? [props.onClick] : []), ...callbacks(props.children)];
+}
+
+void test('genuine Advanced movement answers and persistent guidance connect to a real physical move', () => {
+ const f = createTruthMovementAdvancedFixture();
+ let g = askTruthMovementAdvanced(f.game, f.asker, f.truthtrance, f.target);
+ const answers = renderToStaticMarkup(createElement(Truthtrance,
+ { game: viewGame(g, f.target), busy: false, act() {} }));
+ assert.match(answers, />Answer Yes<\/button>/); assert.match(answers, />Answer No<\/button>/);
+ g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
+ const view = viewGame(g, f.target), actions: Action[] = [];
+ const props = { game: view, busy: false, act: (action: Action) => { actions.push(action); } };
+ const html = renderToStaticMarkup(createElement(MovementPromises, props));
+ assert.match(html, /Your ground-movement promises/); assert.match(html, /Move 6 forces to Carthag/);
+ callbacks(MovementPromises(props))[0]();
+ assert.deepEqual(actions, view.movementPromisePolicy);
+ g = act(g, f.target, actions[0]); assert.equal(g.movementPromises![0].fulfilled, true);
+ callbacks(MovementPromises({ ...props, busy: true }))[0](); assert.equal(actions.length, 1);
+ assert.equal(MovementPromises({ ...props, game: viewGame(g, f.asker) }), null);
+});
diff --git a/tests/truth-movement-advanced-engine.test.ts b/tests/truth-movement-advanced-engine.test.ts
new file mode 100644
index 00000000..29b2a709
--- /dev/null
+++ b/tests/truth-movement-advanced-engine.test.ts
@@ -0,0 +1,214 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
+import { viewGame, type Game } from '../game/engine';
+import { createTruthMovementAdvancedFixture, askTruthMovementAdvanced as askAdvancedMovement } from './fixture-truth-movement-advanced';
+import { actTruthMovement as act, advanceTruthMovement as advance,
+ truthMovementClean as clean, truthMovementPlayer as player,
+ truthMovementInventory as inventory } from './fixture-truth-movement';
+import { createAdvisorMatchingMoveFixture } from './fixture-advisor-matching-move';
+
+void test('genuine Advanced Yes binds physical movement and preserves immediate Hajr timing', () => {
+ const f = createTruthMovementAdvancedFixture();
+ assert.throws(() => act(f.game, f.target, { type: 'card', card: f.hajr! }),
+ 'Advanced Hajr cannot prepare the initial move');
+ let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
+ assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['yes', 'no']);
+ g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
+ assert.throws(() => act(g, f.target, { type: 'endMovement' }));
+ g = act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 4,
+ territory: 'imperial_basin', sector: 9 });
+ g = advance(g, clean);
+ assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
+ const next = viewGame(g, f.target).movementPromisePolicy?.[0];
+ assert.deepEqual(next, { type: 'card', card: f.hajr });
+ g = advance(act(g, f.target, next!), clean);
+ g = act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
+ assert.equal(g.movementPromises![0].fulfilled, true);
+ g = act(advance(g, clean), f.target, { type: 'endMovement' });
+ assert.equal(player(g, f.target).forces['carthag:11'], 6);
+ assert.deepEqual(inventory(g), f.originalCards);
+});
+
+void test('Advanced No remains binding through a genuine later Hajr movement', () => {
+ const f = createTruthMovementAdvancedFixture();
+ let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
+ g = act(g, f.target, { type: 'truthAnswer', answer: 'no' });
+ g = advance(act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 4,
+ territory: 'imperial_basin', sector: 9 }), clean);
+ g = advance(act(g, f.target, { type: 'card', card: f.hajr! }), clean);
+ const before = structuredClone(g);
+ assert.throws(() => act(g, f.target, { type: 'move', from: 'arrakeen:10', amount: 6,
+ territory: 'carthag', sector: 11 }));
+ assert.deepEqual(g, before);
+ g = act(g, f.target, { type: 'endMovement' });
+ assert.equal(g.movementPromises![0].fulfilled, true);
+ assert.deepEqual(inventory(g), f.originalCards);
+});
+
+void test('an Advanced unused shipment prepares a larger ground group without itself fulfilling Yes', () => {
+ const f = createTruthMovementAdvancedFixture();
+ let g = askAdvancedMovement(f.beforeShipment, f.asker, f.truthtrance, f.target, 11);
+ assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
+ g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
+ const shipment = viewGame(g, f.target).movementPromisePolicy?.[0];
+ assert.equal(shipment?.type, 'ship');
+ g = advance(act(g, f.target, shipment!), clean);
+ assert.ok(!g.movementPromises![0].fulfilled);
+ const movement = viewGame(g, f.target).movementPromisePolicy?.[0];
+ assert.equal(movement?.type, 'move');
+ g = act(g, f.target, movement!);
+ assert.equal(g.movementPromises![0].fulfilled, true);
+});
+
+function fremenRoute(g: Game, actor: string) {
+ const owner = player(g, actor);
+ const from = Object.keys(owner.forces).find(key => owner.forces[key] >= 3 && (owner.elites?.forces[key] ?? 0) >= 2);
+ assert.ok(from);
+ const destination = TERRITORIES.filter(t => t.type === 'sand').flatMap(t =>
+ t.sectors.filter(sector => sector !== g.storm).map(sector => ({ territory: t.id, sector })))
+ .find(to => gameDistance(g, from, `${to.territory}:${to.sector}`,
+ point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm) === 2);
+ assert.ok(destination);
+ return { type: 'move', forces: { [from]: 3 }, eliteForces: { [from]: 2 }, ...destination };
+}
+
+for (const cancel of [false, true]) void test(`native Advanced typed Fremen movement ${cancel ? 'prevention releases an impossible Yes' : 'waits for allowance before fulfillment'}`, () => {
+ const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
+ const move = fremenRoute(f.game, f.target);
+ let g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target, 3, move.territory);
+ g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' });
+ const before = structuredClone(player(g, f.target));
+ g = act(g, f.target, move);
+ assert.equal(g.response?.kind, 'fremenMovement');
+ assert.equal(g.movementPromises![0].fulfilled, undefined);
+ assert.equal(g.movementPromises![0].released, undefined);
+ assert.deepEqual(player(g, f.target).forces, before.forces);
+ if (cancel) {
+ assert.ok(f.karama);
+ g = act(g, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
+ assert.equal(g.movementPromises![0].released, true);
+ assert.deepEqual(player(g, f.target).forces, before.forces);
+ assert.equal(player(g, f.target).moved, 0);
+ } else {
+ g = advance(g, clean);
+ assert.equal(g.movementPromises![0].fulfilled, true);
+ assert.equal(player(g, f.target).elites!.forces[`${move.territory}:${move.sector}`], 2);
+ assert.equal(player(g, f.target).forces[`${move.territory}:${move.sector}`], 3);
+ }
+ assert.deepEqual(inventory(g), f.originalCards);
+});
+
+void test('elite combat strength cannot substitute for eleven physical forces in one movement', () => {
+ const f = createTruthMovementAdvancedFixture({ targetFaction: 'fremen', hajr: false });
+ const move = fremenRoute(f.game, f.target);
+ const g = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target, 11, move.territory);
+ assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['no']);
+});
+
+for (const cancel of [false, true]) void test(`controlled original Truthtrance plus native BG matching ${cancel ? 'prevention retains a legal replacement' : 'allowance fulfills only at transfer'}`, () => {
+ const f = createAdvisorMatchingMoveFixture();
+ // Only this card acquisition is controlled; setup, original advisors/fighter,
+ // paid shipment, Karama and matching declaration come from the native source.
+ const source = structuredClone(f.game), at = source.deck.findIndex(c => c.effect === 'truthtrance');
+ assert.ok(at >= 0); const truth = source.deck.splice(at, 1)[0];
+ player(source, f.emperor).hand.push(truth);
+ let g = askAdvancedMovement(source, f.emperor, truth.id, f.bg, 1, f.destination);
+ g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
+ const forces = structuredClone(player(g, f.bg).forces);
+ g = act(g, f.bg, f.action);
+ assert.ok(g.response?.advisorMatchingSource);
+ assert.deepEqual(player(g, f.bg).forces, forces);
+ assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
+ if (cancel) {
+ g = act(g, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
+ assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
+ g = act(g, f.bg, f.ordinaryAction);
+ } else g = advance(g, clean);
+ assert.equal(g.movementPromises![0].fulfilled, true);
+ assert.deepEqual(inventory(g), f.originalInventory);
+});
+
+function controlledTruth(game: Game, recipient: string) {
+ const at = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(at >= 0);
+ const card = game.deck.splice(at, 1)[0]; player(game, recipient).hand.push(card); return card.id;
+}
+const routeDistance = (g: Game, from: string, to: string) => gameDistance(g, from, to,
+ point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm);
+
+for (const cancel of [false, true]) void test(`native pre-first-shipment advisor preparation ${cancel ? 'prevention releases the blocked route' : 'prepares the promised city-range move'}`, () => {
+ const f = createAdvisorMatchingMoveFixture();
+ let g = act(f.beforeShipment, f.bg, { type: 'bribe', target: f.emperor,
+ amount: player(f.beforeShipment, f.bg).spice });
+ assert.equal(g.advisorPreparation?.firstShipment, null, 'A real bribe does not close preparation');
+ const truth = controlledTruth(g, f.emperor);
+ const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
+ .find(to => to.sector !== g.storm && routeDistance(g, 'arrakeen:10', `${to.territory}:${to.sector}`) === 3);
+ assert.ok(to);
+ g = askAdvancedMovement(g, f.emperor, truth, f.bg, 1, to.territory);
+ assert.ok(viewGame(g, f.bg).truthMovementAnswers!.includes('yes'));
+ g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
+ const preparation = viewGame(g, f.bg).movementPromisePolicy?.[0];
+ assert.deepEqual(preparation, { type: 'prepareAdvisors', territory: 'arrakeen' });
+ g = act(g, f.bg, preparation!);
+ assert.ok(g.response?.advisorPreparationSource);
+ assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
+ if (cancel) {
+ g = act(g, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
+ assert.equal(g.movementPromises![0].released, true);
+ } else {
+ g = advance(g, clean);
+ const movement = viewGame(g, f.bg).movementPromisePolicy?.[0]; assert.equal(movement?.type, 'move');
+ g = act(g, f.bg, movement!); assert.equal(g.movementPromises![0].fulfilled, true);
+ }
+ assert.deepEqual(inventory(g), f.originalInventory);
+});
+
+void test('controlled advisor arrival flip supplies city range for the subsequent original Hajr', () => {
+ const f = createAdvisorMatchingMoveFixture();
+ let g = structuredClone(f.game);
+ const bg = player(g, f.bg), emperor = player(g, f.emperor);
+ // Controlled stance, two real reserve counters and original card custody
+ // isolate this two-move search boundary; not an unstaged native source.
+ bg.advisors!.imperial_basin = {};
+ emperor.reserves -= 2; emperor.forces['carthag:11'] = 1; emperor.forces['imperial_basin:9'] = 1;
+ const at = g.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0);
+ const hajr = g.deck.splice(at, 1)[0]; bg.hand.push(hajr);
+ const truth = controlledTruth(g, f.emperor);
+ const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
+ .find(to => to.sector !== g.storm && routeDistance(g, 'carthag:11', `${to.territory}:${to.sector}`) === 3 &&
+ routeDistance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) > 2 &&
+ routeDistance(g, 'arrakeen:10', `${to.territory}:${to.sector}`) > 1);
+ assert.ok(to);
+ g = askAdvancedMovement(g, f.emperor, truth, f.bg, 1, to.territory);
+ assert.ok(viewGame(g, f.bg).truthMovementAnswers!.includes('yes'));
+ g = act(g, f.bg, { type: 'truthAnswer', answer: 'yes' });
+ const first = viewGame(g, f.bg).movementPromisePolicy?.[0];
+ assert.equal(first?.type, 'move'); assert.equal(first.territory, 'carthag'); assert.equal(first.fighters, true);
+ g = act(g, f.bg, first);
+ assert.equal(g.response?.kind, 'advisorFlip');
+ assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
+ g = advance(g, clean);
+ assert.deepEqual(viewGame(g, f.bg).movementPromisePolicy?.[0], { type: 'card', card: hajr.id });
+ g = advance(act(g, f.bg, { type: 'card', card: hajr.id }), clean);
+ g = act(g, f.bg, viewGame(g, f.bg).movementPromisePolicy![0]);
+ assert.equal(g.movementPromises![0].fulfilled, true);
+ assert.deepEqual(inventory(g), f.originalInventory);
+});
+
+void test('all four existing policies answer and complete the genuine Advanced movement question legally', () => {
+ const f = createTruthMovementAdvancedFixture();
+ const question = askAdvancedMovement(f.game, f.asker, f.truthtrance, f.target);
+ for (const difficulty of DIFFICULTIES) {
+ let g = question, count = 0;
+ while (g.phase === 5 && g.active === f.target && count++ < 20) {
+ if (!clean(g) && !g.truthtrance) { g = advance(g, clean); continue; }
+ const view = viewGame(g, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
+ const action = botActions(view)[0]; assert.ok(action);
+ g = act(g, f.target, action);
+ }
+ assert.ok(count < 20); assert.equal(g.movementPromises![0].fulfilled, true);
+ }
+});