From 724eabf99ce0243f9ef27982cbf2509de9ad7550 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:54:19 +1100 Subject: [PATCH] Support native Advanced and No-Field movement promises --- components/movement-promises.tsx | 4 + components/truthtrance.tsx | 5 +- docs/CURRENT_STATUS.md | 28 ++- docs/IMPLEMENTATION_STATUS.md | 39 +++++ docs/RICHESE_NO_FIELD_RULES.md | 10 ++ docs/RULE_DECISIONS.md | 13 ++ docs/TRUTHTRANCE_MOVEMENT_PROMISES.md | 62 +++++++ game/engine.ts | 24 ++- game/movement-promise-search.ts | 27 ++- game/movement-promises.ts | 7 +- game/movement-shipment-preparation.ts | 13 +- game/reference.ts | 15 +- game/truthtrance.ts | 2 +- tests/fixture-truth-movement-ixian.ts | 164 ++++++++++++++++++ tests/fixture-truth-movement-no-field.ts | 142 +++++++++++++++ tests/movement-promises.test.ts | 4 +- tests/movement-shipment-preparation.test.ts | 11 ++ tests/truth-movement-ixian-engine.test.ts | 34 ++++ .../truth-movement-no-field-controls.test.tsx | 30 ++++ tests/truth-movement-no-field-engine.test.ts | 158 +++++++++++++++++ 20 files changed, 762 insertions(+), 30 deletions(-) create mode 100644 tests/fixture-truth-movement-ixian.ts create mode 100644 tests/fixture-truth-movement-no-field.ts create mode 100644 tests/truth-movement-ixian-engine.test.ts create mode 100644 tests/truth-movement-no-field-controls.test.tsx create mode 100644 tests/truth-movement-no-field-engine.test.ts diff --git a/components/movement-promises.tsx b/components/movement-promises.tsx index fbdcf0ed..37a7f74a 100644 --- a/components/movement-promises.tsx +++ b/components/movement-promises.tsx @@ -33,9 +33,13 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac : 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 === 'revealNoField' ? 'Reveal your No-Field to prepare physical forces for the required movement' : next?.type === 'card' ? 'Play Hajr to prepare the required movement' + : next?.type === 'ship' && next.noField ? `Ship a concealed No-Field to ${destinationLabel}, sector ${sector}, before 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' + : next?.noField && !amount ? `Move your concealed No-Field to ${destinationLabel}, sector ${sector}, to prepare the required movement` + : next?.noField ? `Move ${amount} physical forces with your No-Field to ${destinationLabel}, sector ${sector}` : `${next?.movementCard ? 'Use Ornithopter and move' : 'Move'} ${amount} forces to ${destinationLabel}, sector ${sector}`; return

Your ground-movement promises

diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index d9bdb1d0..1fae51ed 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -350,8 +350,9 @@ 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.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.

} + {g.advanced &&

Advanced movement counts physical forces, including each elite once. Hajr remains available only immediately after a completed movement and its required arrival decisions.

} +

An owned No-Field may prepare a route through its normal shipment, movement or voluntary reveal. The concealed marker does not count toward the promised physical minimum; revelation uses only available reserves.

+ {!g.truthMovementTargets.includes(target) &&

Choose a supported Basic respondent this round or an Advanced respondent with declared native families during Movement, and finish pending decisions. Optional modules and earlier Advanced movement forecasts remain unfinished.

}

{truthQuestionText(question, leaderName, locationName)}

) : kind === 'shipment' || kind === 'mixedShipment' ? ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0ad2ac02..b36121ad 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,32 @@ open mode gates. ## Current checkpoint and work +**Native Advanced and No-Field movement promises — 25 October. Prototyped / Partial.** +Declared native Advanced families now share phase5 movement questions. Owned +No-Field shipment, reserve-capped reveal, concealed positioning and mixed moves +can prepare a route; the marker contributes zero toward the physical minimum. +Original tariff, token/event/history and Karama remain; a detached exact +preflight protects a new shipment without committing it early. Own optional +reveal cannot evade a promise. City preparation is tried earlier without +removing candidates. Earlier Advanced, modules and broader commitments remain. +[Contract and labelled source boundaries](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-advanced-and-no-field-preparation--25-october-2026). + +Disjoint fixture work, bounded source review and root integration pass the +**24-file163/163** related union. Fixture-only lint repairs (numeric comparator +and typed location) then pass all **3-file15/15** affected consumers, types/lint, +build and six native original-control/GameTable SSR journeys. Hash comparison +confirms application/other tests unchanged across those repairs. Genuine Basic24/ +Advanced27-action Richese sources retain original35+10 cards and paid marker; +43-action Ixian source earns original47 cards, paid Karama, HMS and two Cyborgs. +Actual reveal callbacks and one-spice shipment/reveal/physical moves complete; +Ixian allowance fulfills after its original counter, while prevention keeps the +move unused. Controlled reserve caps, Hajr/card acquisition, physical placement +and earlier phase are explicit. Four profiles use legal owned reveal guidance. +Fixture feedback corrects a Basic Black Market wait, native preview-marker +assumption, original token shape and real discard priority. Concealed city cases +pass after trying useful city preparation first. No AI tuning, complete-mode/ +final-assurance claim or saved-room/D1/server/reset/restart/deployment operation. + **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 @@ -6997,7 +7023,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 [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 and native Advanced 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 6bc8ef7c..c5a00830 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,44 @@ # Dune implementation status +## 25 October 2026 — Native Advanced and owned No-Field movement preparation + +Advanced phase5 now reuses the declared native-family admission. Richese's +owned No-Field shipment, reveal, marker-only positioning and physical-plus-marker +movement enter the actual route witness. Physical minima exclude the marker; +reveal is reserve capped and preserves native history/Ornithopter custody. +Exact quiet allowed-commit preflight protects a new live shipment before its +original counter. Prevention retains payment/pieces and can release impossible +Yes; an optional own reveal cannot evade a commitment. Memo retains original +No-Field/event and Ixian cancellation. City preparation is ordered early without +removing candidates. Human guidance names each preparation; minimal AI reuses it. + +Disjoint native fixtures, bounded source review and root integration pass +**24-file163/163** related cases. Three fixture lint findings are repaired in +exactly two test/fixture files: numeric sort comparator and typed location. +All application/other files remain unchanged; final **3-file15/15** consumers, +types/lint/build and six native original-control/GameTable SSR journeys pass. +Genuine Richese Basic24/Advanced27 sources earn actual35+10 setup/deal, bidding +and paid marker. Ixian43 earns native47 cards/HMS, original one-spice Karama +purchase and actual four-spice two-Cyborg shipment. Real reveal callbacks, +one-spice shipment/reveal/physical fulfillment and original Ixian range outcomes +pass. Controlled reserve/Tanks, physical/card and earlier-clock boundaries remain +explicit. Fixture repairs preserve Basic's absent Black Market, expansion start +markers, string token identity and original Semuta discard priority. + +Private proof/HTML: +`/home/quintine/.local/share/dune/checkpoints/20261025-truth-movement-native-no-field/`. +`frozen-integrated/report.json` retains the initial related pass/lint failure; +`fixture-lint-repair.json` binds the two repairs; final +`frozen-fixture-lint-repair/report.json` binds tree +`6963f2074dbcc48f56447963f4e6064d4344ebbd28abcea6dfc67429aab41609`, +2778files on base `2ae2b984c05c918c7df34be7c6234524c52f1c65`. +Final four prose/reference overlays are reviewed separately. Existing printed +No-Field and general current-turn sources compose this software predicate; no +new publisher FAQ is claimed. Earlier Advanced, modules, arbitrary commitments, +full modes and final assurance/browser/deployed acceptance remain open. No AI +refinement or saved-room/D1/server/reset/restart/deployment operation. +[Canonical contract](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-advanced-and-no-field-preparation--25-october-2026). + ## 25 October 2026 — Classic Advanced structured ground-movement Truthtrance Classic Advanced without modules now accepts structured movement questions in diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md index c95c7ce4..7739c15e 100644 --- a/docs/RICHESE_NO_FIELD_RULES.md +++ b/docs/RICHESE_NO_FIELD_RULES.md @@ -2,6 +2,16 @@ Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode. +## Owned movement-promise preparation — 25 October 2026 + +The [movement contract](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-advanced-and-no-field-preparation--25-october-2026) +now includes native owned shipment, reserve-capped voluntary reveal and concealed +marker positioning/carrying as preparations. The marker contributes zero to the +promised physical quantity. Original token identity, no-repeat history, tariff, +Karama and movement allowances remain authoritative; an optional reveal cannot +erase the last route. Basic and declared native Advanced phase5 are supported; +optional modules and complete-mode acceptance remain separate. + ## Basic native coalition composition — 24 October 2026 Bare Basic Ecaz/native E1/E2 tables now keep allied marker presence and the diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 99d1c8a2..f62a5843 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,18 @@ # Rule decision index +## Native Advanced and No-Field movement preparation — 25 October 2026 + +Reuse the existing physical-movement/current-turn composition and printed +No-Field lifecycle. Admit declared Advanced native families during phase5, +retaining module/earlier-phase boundaries. Owned shipment/reveal and concealed +movement may prepare a route, while only physical counters fulfill its minimum. +Materialization is reserve capped and preserves native token history/counters. +A detached exact allowed commitment checks a new declaration before its real +counter; prevention retains payment/pieces and may release impossible Yes. +Own optional revelation cannot evade a promise. This is software composition, +not a new No-Field-specific publisher FAQ or full-mode certification. +[Contract and labelled native/controlled evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-advanced-and-no-field-preparation--25-october-2026). + ## Classic Advanced ground-movement promises — 25 October 2026 Extend the existing general current-turn Truthtrance composition to clean diff --git a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md index 8f533cf4..29247113 100644 --- a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md +++ b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md @@ -1,5 +1,67 @@ # Truthtrance ground-movement promises +## Native Advanced and No-Field preparation — 25 October 2026 + +**Prototyped / Partial.** Advanced phase5 now accepts declared native expansion +families through the same roster checks as Basic. Optional modules and earlier +Advanced forecasts remain separate. This supersedes the preceding classic-only +admission below, not complete faction/mode acceptance. + +Richese's owned route can use its original No-Field shipment, voluntary reveal, +marker-only positioning or a physical group carrying the marker. The native +one-force tariff, exact event/token, last-used restriction, funding and original +Karama responses remain. Materialization uses only actual remaining reserves; +an owned Ghola may prepare those reserves through its existing physical path. +The marker itself contributes zero toward a movement promise. Reveal and shipment +prepare but do not fulfill it; only actual physical movement does. A concealed +zero can still supply city presence/range for a later physical Hajr move. + +Reuse the [printed lifecycle](RICHESE_NO_FIELD_ENGINE_AUDIT.md#source-boundary-and-model-contract) +and existing general current-turn composition below; no new publisher ruling is +claimed. The witness includes reserve-capped reveal potential before pruning +marker-only armies, keeps original marker/event and Ixian cancellation in its +memo, and tries city preparation early without dropping other legal candidates. +Before saving a live No-Field declaration, check its allowed exact commitment +on a detached copy. The real original counter still decides the shipment; +prevention can release an impossible Yes without charging or deploying it. +An optional own reveal, including in earlier Basic phases, cannot erase the last +required route and then claim opposing impossibility. + +The [Richese source](../tests/fixture-truth-movement-no-field.ts) earns public +Basic/Advanced main35/cache10, original dealt Truthtrance, actual native bidding +and paid concealed Basin shipment without installed played state. Its zero/3/5 +choices use original custody. The [Ixian source](../tests/fixture-truth-movement-ixian.ts) +earns actual Advanced setup/HMS, original Truthtrance, Emperor's paid Karama and +a paid two-Cyborg shipment before its original range counter. Controlled reserve +caps, physical placements, Hajr/Karama acquisition and an earlier phase boundary +are explicitly labelled in [separate regressions](../tests/truth-movement-no-field-engine.test.ts). +They are not genuine full-game traces. Human controls give distinct persistent +shipment/reveal/marker/mixed-group guidance; existing minimal policies follow +the same owned route. No strategy/calibration or complete-mode claim is added. + +The24-file163/163 related union passes. Fixture-only lint repairs in two files +then pass all3-file15/15 affected consumers, types/lint/build and six native +original-control/GameTable SSR journeys. An exact hash comparison retains all +application/other files across repairs. Basic24/Advanced27 Richese and Ixian43 +source prefixes earn original cards, payments and typed pieces. Real reveal +callbacks and one-spice shipment/reveal/movement complete. Four existing profiles +follow legal owned reveal guidance. Controlled cap/zero/mixed/Hajr/counter/early +boundaries retain their staging labels. Trying city preparation earlier makes +the concealed-positioning cases finite promptly without pruning legal choices. + +Private proof/HTML: +`/home/quintine/.local/share/dune/checkpoints/20261025-truth-movement-native-no-field/`. +`frozen-integrated/report.json` keeps initial related success and fixture lint +failure; `fixture-lint-repair.json` records exactly two changed test/fixture paths. +`frozen-fixture-lint-repair/report.json` binds tree +`6963f2074dbcc48f56447963f4e6064d4344ebbd28abcea6dfc67429aab41609`, +2778files on base `2ae2b984c05c918c7df34be7c6234524c52f1c65`. +Final prose/reference overlays are separately reviewed. These callbacks/SSR are +not hydration, browser, mobile or deployed acceptance. +Earlier Advanced, modules, arbitrary commitments and final assurance/deployed +acceptance remain unfinished. No saved-room, database, server restart or +deployment operation is part of this work. + ## Classic Advanced Movement — 25 October 2026 **Prototyped / Partial.** Classic Advanced tables without expansions or optional diff --git a/game/engine.ts b/game/engine.ts index b305dc5b..6ad1dc8c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -16745,6 +16745,12 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises: } catch (error) { if (!(error instanceof RuleError)) throw error; } } } + if (player.faction === 'richese' && player.noField?.deployed && !noFieldRevealBlock(state, player)) { + const action: Action = { type: 'revealNoField', token: player.noField.deployed.tokenId, event: player.noFieldEvent }; + try { + yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation'), 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) }, @@ -16790,7 +16796,10 @@ function groundMovementCompletionAtMovement(game: Game, actor: string, promises: // Include current native range (notably CHOAM Kulon) and the card's // explicit range-three option. This only prunes impossible geometry; // the actual declaration still validates exact speed/groups/custody. - return Object.keys(player.forces).some(key => player.forces[key] > 0 && splitLocation(key).territory === origin && + const marker = player.faction === 'richese' ? player.noField?.deployed : null; + const sources = [...Object.keys(player.forces).filter(key => player.forces[key] > 0), + ...(marker ? [location(marker.location.territory, marker.location.sector)] : [])]; + return sources.some(key => splitLocation(key).territory === origin && gameDistance(state, key, location(destination, sector), point => pathBlocked(state, player, point, isAdvisor(player, origin))) <= Math.max(3, movementRange(state, player, 1))); }, project(state, owner, action) { @@ -16881,13 +16890,14 @@ function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Actio if (!live.some(promise => promise.answer)) continue; if (g.pendingFremenMove?.order.player === p.id || g.pendingIxMove?.player === p.id || g.pendingChoamMove?.player === p.id || + g.pendingShipment?.player === p.id && !!g.pendingShipment.noField || 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 && action?.type !== 'passResponse' && (!early || !!action && - (['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) || + (['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revealNoField', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) || action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 || action.type === 'ready' && [2, 4].includes(sourcePhase))); const busy = !!(g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || @@ -30755,8 +30765,9 @@ function offerShipment(g: Game, shipment: PendingShipment, matchingApproved = fa } else if (!openGuildRate(g, shipment)) commitShipment(g, shipment); } function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipient: string | null}, movementPreparation = false) { - if (movementPreparation) requireRule(recoverableUnsupportedShipment(shipment), - 'Movement preparation requires an ordinary physical reserve shipment.'); + if (movementPreparation) requireRule(recoverableUnsupportedShipment(shipment) || + shipment.noField && !shipment.richesePair && recoverableUnsupportedShipment({ ...shipment, noField: undefined }), + 'Movement preparation requires an ordinary physical or owned No-Field shipment.'); if (shipment.source === 'ambassador') { commitAmbassadorShipment(g, shipment); return; @@ -36705,6 +36716,11 @@ function applyActionInner( // unsupported arrival combinations before saving its private intent. validateShipmentArrival(g, shipment); checkShipmentIncomeFunding(g, p, cost, allyPayment); + if (liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer)) { + const afterShipment = structuredClone(g); + commitShipment(afterShipment, shipment, undefined, true); + reconcileGroundMovementPromises(afterShipment, p.id, action, g.phase); + } if (richesePair) recordRichesePairShipment(g, shipment); g.pendingShipment = shipment; g.response = { kind: 'richeseNoField', owner: p.id, passed: [], diff --git a/game/movement-promise-search.ts b/game/movement-promise-search.ts index e770e2ee..162240db 100644 --- a/game/movement-promise-search.ts +++ b/game/movement-promise-search.ts @@ -49,10 +49,15 @@ export function findMovementCompletion(source: Game, actor: string, // or Box reuse. Tanks are only an optimistic bound; the adapter still // 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 marker = player.faction === 'richese' ? player.noField?.deployed : null; + const markerValue = marker ? player.noField!.tokens.find(token => token.id === marker.tokenId)?.value ?? 0 : 0; + const revealable = Math.min(markerValue, player.reserves + recovery); + if (live.some(promise => promise.answer && promise.minimum > physical + + (player.shipped ? revealable : player.reserves + recovery))) return null; 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, + player.fremenMovementBlocked, player.ixMovementBlocked, player.advisorFlipMovementBlocked, game.advisorMatchingDenied, + player.noField, player.noFieldEvent, game.advisorPreparation, game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises, game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy, @@ -61,11 +66,12 @@ export function findMovementCompletion(source: Game, actor: string, if (failed.has(key)) return null; const destinations = gameTerritories(game).filter(destination => !destination.id.startsWith('homeworld:')); destinations.sort((a, b) => Number(live.some(p => p.answer && p.territory === b.id)) - - Number(live.some(p => p.answer && p.territory === a.id))); - if (needsMove && !live.some(promise => promise.answer && promise.minimum > physical) && + Number(live.some(p => p.answer && p.territory === a.id)) || + (marker ? Number(['arrakeen', 'carthag'].includes(b.id)) - Number(['arrakeen', 'carthag'].includes(a.id)) : 0)); + if (needsMove && (marker || !live.some(promise => promise.answer && promise.minimum > physical)) && !game.pendingNullentropy && player.moved < adapter.allowance(game, player)) { - const origins = [...new Set(Object.keys(player.forces).filter(key => player.forces[key] > 0) - .map(key => splitLocation(key).territory))]; + const origins = [...new Set([...Object.keys(player.forces).filter(key => player.forces[key] > 0) + .map(key => splitLocation(key).territory), ...(marker ? [marker.location.territory] : [])])]; const card = !game.ornithopter ? player.hand.find(card => card.effect === 'ornithopter') : null; const modes: Partial[] = game.ornithopter ? [{ ornithopterEvent: game.ornithopter.event }] @@ -82,15 +88,18 @@ export function findMovementCompletion(source: Game, actor: string, if (!adapter.reachable(game, player, origin, destination.id, sector)) continue; const keys = Object.keys(player.forces).filter(key => player.forces[key] > 0 && splitLocation(key).territory === origin); const total = keys.reduce((sum, key) => sum + player.forces[key], 0); - const amounts = Array.from({ length: total }, (_, i) => i + 1); + const carriesMarker = marker?.location.territory === origin && !!player.noFieldEvent; + const amounts = [...(carriesMarker ? [0] : []), ...Array.from({ length: total }, (_, i) => i + 1)]; amounts.sort((a, b) => Number(live.some(p => p.answer && p.territory === destination.id && b >= p.minimum)) - Number(live.some(p => p.answer && p.territory === destination.id && a >= p.minimum)) || a - b); for (const amount of amounts) { 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) for (const stance of stances) { - const action = { ...group, ...mode, ...stance, territory: destination.id, sector }; + const markers: Partial[] = carriesMarker + ? [...(amount ? [{}] : []), { noField: marker!.tokenId, event: player.noFieldEvent }] : [{}]; + for (const group of groups(player, keys, amount)) for (const mode of modes) for (const stance of stances) for (const selected of markers) { + const action = { ...group, ...mode, ...stance, ...selected, 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 05b83861..737e2c63 100644 --- a/game/movement-promises.ts +++ b/game/movement-promises.ts @@ -84,11 +84,10 @@ 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 { - // 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. + // Basic retains early-phase admission. Advanced native families use the same + // declared roster during Movement; earlier forecasts/modules stay separate. return (g.advanced === false || - g.advanced === true && g.phase === 5 && g.expansions.length === 0) && + g.advanced === true && g.phase === 5) && 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/movement-shipment-preparation.ts b/game/movement-shipment-preparation.ts index 8bef0d14..d90bfcde 100644 --- a/game/movement-shipment-preparation.ts +++ b/game/movement-shipment-preparation.ts @@ -21,7 +21,17 @@ export function* movementShipmentPreparations(game: Game, actor: string, gameDistance(game, `${destination.id}:${from}`, `${target.id}:${to}`))))) : 0])); destinations.sort((a, b) => present(b.id) - present(a.id) || Number(b.type === 'stronghold') - Number(a.type === 'stronghold') || proximity.get(a.id)! - proximity.get(b.id)!); - for (const destination of destinations) for (const sector of destination.sectors) + for (const destination of destinations) for (const sector of destination.sectors) { + if (player.faction === 'richese' && player.noField && !player.noField.deployed && player.noFieldEvent) { + for (const token of player.noField.tokens.filter(token => token.id !== player.noField!.lastShipped)) { + for (let allyPayment = Math.min(2, game.aid[player.ally ?? '']?.amount ?? 0); allyPayment >= 0; allyPayment--) { + const action: Action = { type: 'ship', territory: destination.id, sector, amount: 1, elite: 0, + allyPayment, noField: token.id, event: player.noFieldEvent }; + const next = project(game, actor, action); + if (next) yield { game: next, actions: [action] }; + } + } + } for (let amount = 1; amount <= player.reserves; amount++) { const special = player.elites?.reserves ?? 0; for (let elite = Math.max(0, amount - (player.reserves - special)); elite <= Math.min(amount, special); elite++) { @@ -36,4 +46,5 @@ export function* movementShipmentPreparations(game: Game, actor: string, } } } + } } diff --git a/game/reference.ts b/game/reference.ts index 3ea89956..93c39554 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 supported Basic or classic Advanced respondent finishes their current-round turn.', + summary: 'Bind one future qualifying physical ground movement before a supported Basic or Advanced respondent with declared native families 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. Classic Advanced without modules also supports its current Movement phase. Advanced earlier phases/expansions, optional modules, 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. Advanced with declared native families and no modules supports its current Movement phase. Earlier Advanced phases, 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,16 +3015,17 @@ 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.', + 'Native 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.', + 'Richese may prepare the route with its actual one-force-price No-Field shipment, voluntary reserve-capped reveal, marker-only city positioning or a physical group carrying the token. Neither shipment nor reveal fulfills movement, and the concealed marker adds zero to the promised minimum. Native Karama, exact token events, no-repeat history and Hajr timing remain. Own optional revelation cannot evade the last completion; a completed opposing prevention may release it.', '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: '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: 'Implementation', status: 'Partial', detail: 'Declared native Advanced phase5 now reuses the typed route witness, immediate Hajr timing and owned advisor preparation/arrival flips with real counters. Owned No-Field shipment/reveal/marker movement uses native events, reserve caps and physical-only fulfillment. 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: 'Native No-Field preparation now has distinct persistent reveal/shipment/marker/mixed labels and actual reveal callbacks. 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 use actual native Advanced Richese reveal/physical movement legally. 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: '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'] }, + { area: 'Verification', status: 'Partial', detail: '25 October native No-Field/Ixian:24 files163/163 pass; two fixture-only lint repairs then pass3 files15/15 consumers, types/lint/build and six native control/GameTable SSR journeys. Hashes retain all application/other files. Original Richese Basic24/Advanced27 and Ixian43 source actions earn cards, payments and typed groups. Controlled cap/card/force/earlier-clock boundaries stay labelled. Earlier 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-no-field-engine.test.ts', 'tests/truth-movement-no-field-controls.test.tsx', 'tests/fixture-truth-movement-no-field.ts', 'tests/truth-movement-ixian-engine.test.ts', 'tests/fixture-truth-movement-ixian.ts', '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 f110d79e..5996c7a5 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 or a classic Advanced Movement turn, with pending decisions finished. Optional modules and Advanced expansion preparations remain unfinished.'); + 'Ground-movement promises need a supported Basic opportunity this round or an Advanced Movement turn with declared native families and pending decisions finished. Optional modules and earlier Advanced forecasts 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-ixian.ts b/tests/fixture-truth-movement-ixian.ts new file mode 100644 index 00000000..23fe4d38 --- /dev/null +++ b/tests/fixture-truth-movement-ixian.ts @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { location, MOBILE_LOCATION, TERRITORIES } from '../game/board'; +import { botGroundMoveAllowed, botMovementRange, groundMoveRouteDistance } from '../game/bot-mobility'; +import { + actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer, + type TruthMovementStep, +} from './fixture-truth-movement'; +import { + askTruthMovementAdvanced, nextTruthMovementAdvancedNativeStep, + type TruthMovementAdvancedTransition, +} from './fixture-truth-movement-advanced'; + +export const actTruthMovementIxian = actTruthMovement; +export const truthMovementIxianClean = truthMovementClean; +export const truthMovementIxianInventory = truthMovementInventory; +export const truthMovementIxianPlayer = (game: Game, actor = 'ixians') => truthMovementPlayer(game, actor); +export { askTruthMovementAdvanced as askTruthMovementIxian }; +export type TruthMovementIxianStep = TruthMovementStep; + +/** Actual native choices only. Keep the original Karama in the inspected auction + * pool by returning a different original card; no auction card is installed. */ +export function nextTruthMovementIxianNativeStep(game: Game): TruthMovementStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard && + game.decision?.kind === 'ixAuction') { + const card = game.ixAuction?.cards.find(card => card.effect !== 'karama'); + assert.ok(card, 'The original Ixian auction pool has a non-Karama card to return'); + return { actor: game.decision.player, action: { type: 'decision', card: card.id, position: 'bottom' } }; + } + return nextTruthMovementAdvancedNativeStep(game); +} + +export function advanceTruthMovementIxian(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 Ixian movement source ended before its requested control'); + const next = nextTruthMovementIxianNativeStep(game); + assert.ok(next, `Supply the real Truthtrance or Ixian 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 Ixian Truthtrance source did not reach its finite boundary'); +} + +/** Public Advanced preview, declared Ix expansion, and original Ixian forces. + * Only unopened deal/Spice positions and native scalar lotteries are selected. + * Actual setup, auction and shipment earn every held card and selected Cyborg. */ +export function createTruthMovementIxianFixture() { + const target = 'ixians', asker = 'emperor'; + let game = createGame('TRUTHMOVEMENTIXIAN', newPlayer(target, 'Ixians', 'ixians'), false, ['ix']); + 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 = advanceTruthMovementIxian(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.deepEqual(actions.at(-1)?.action, { type: 'start', advancedPreview: true }); + assert.deepEqual(game.expansions, ['ix']); + 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 === 0)); + const originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 47); + const targetCard = game.deck.find(card => card.kind === 'projectile' && !card.effect); + const truthtrance = game.deck.find(card => card.effect === 'truthtrance'); + const karama = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-')); + assert.ok(targetCard && truthtrance && karama); + const deal = [targetCard.id, truthtrance.id]; + const dealOrder = [...deal, karama.id].map((id, position) => { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: id }; + }); + const beforeStartingDeal = structuredClone(game); + let ixianSetup: TruthMovementAdvancedTransition | null = null; + for (let count = 0; game.status === 'setup' && count < 100; count++) { + const next = nextTruthMovementIxianNativeStep(game); assert.ok(next); + const before = game.decision?.kind === 'ixSetup' ? structuredClone(game) : null; + act(next.actor, next.action); + if (before) ixianSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) }; + } + assert.equal(game.status, 'playing'); assert.ok(ixianSetup); + const afterSetup = structuredClone(game), originalIxians = truthMovementPlayer(game, target); + assert.deepEqual(originalIxians.hand.map(card => card.id), [targetCard.id]); + assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]); + assert.deepEqual(originalIxians.forces, { [MOBILE_LOCATION]: 6 }); + assert.equal(originalIxians.elites?.forces[MOBILE_LOCATION], 3); + assert.equal(originalIxians.elites?.reserves, 4); assert.equal(originalIxians.reserves, 14); + const startingPositions = Object.fromEntries(game.players.map(player => [player.id, { + forces: structuredClone(player.forces), elites: structuredClone(player.elites), + reserves: player.reserves, spice: player.spice, + }])); + 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); + reach(state => state.turn === 1 && state.phase === 3 && truthMovementClean(state) && + state.auction?.active === asker && state.auction.cards[state.auction.index]?.id === karama.id); + const 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: 2, territory: 'polar_sink', sector: 0, elite: 2, 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.turn, 1); assert.equal(game.phase, 5); assert.equal(game.active, target); + assert.equal(player.shipped, true); assert.equal(player.moved, 0); + assert.equal(player.forces['polar_sink:0'], 2); assert.equal(player.elites?.forces['polar_sink:0'], 2); + assert.equal(player.reserves, 12); assert.equal(player.elites?.reserves, 2); assert.equal(shipmentCost, 4); + assert.equal(player.forces[MOBILE_LOCATION], 6); assert.equal(player.elites?.forces[MOBILE_LOCATION], 3); + assert.equal(game.mobileStronghold?.location, 'polar_sink:0'); + assert.ok(!Object.entries(player.forces).some(([key, amount]) => amount > 0 && /^(arrakeen|carthag):/.test(key))); + assert.ok(player.hand.some(card => card.id === targetCard.id)); + for (const card of [truthtrance, karama]) assert.ok(truthMovementPlayer(game, asker).hand.some(held => held.id === card.id)); + assert.deepEqual(truthMovementInventory(game), originalCards); + const view = viewGame(game, target), own = view.players.find(player => player.id === target)!; + assert.equal(botMovementRange(view, own, 2), 2); + const destination = TERRITORIES.filter(territory => territory.type === 'sand') + .flatMap(territory => territory.sectors.map(sector => ({ territory: territory.id, sector }))) + .find(to => to.sector !== game.storm && groundMoveRouteDistance(view, own, 'polar_sink:0', location(to.territory, to.sector)) === 2 && + botGroundMoveAllowed(view, own, 'polar_sink:0', location(to.territory, to.sector), 2)); + assert.ok(destination, 'The two original shipped Cyborgs have a legal native two-step route'); + const matchingMove: Action = { type: 'move', forces: { 'polar_sink:0': 2 }, + eliteForces: { 'polar_sink:0': 2 }, ...destination }; + const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } }; + const questionAction: Action = { type: 'truthAsk', question: { + kind: 'movement', target, territory: destination.territory, minimum: 2, + } }; + return { game, source: structuredClone(game), initial, rules, lobby, setup, beforeStartingDeal, afterSetup, + ixianSetup, beforeShipment, shipment: { ...shipment, step: shipmentStep }, shipmentStep, shipmentCost, + targetFaction: 'ixians' as const, asker, target, truth: truthtrance.id, truthtrance: truthtrance.id, + targetCard: targetCard.id, hajr: null, karama: karama.id, karamaAcquisition, + truthStep, declaration: truthStep.action, questionAction, matchingMove, + twoStepMove: matchingMove, twoStepDestination: destination.territory, destination, + typedGroup: { source: 'polar_sink:0', normal: 0, elite: 2 }, + 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 Ixians/Emperor lobby declares only the Ix expansion, selects Advanced through host rules, readies both seats and submits start advancedPreview:true. No optional module or development initializer is used.', + 'Select only original scalar lotteries, three unopened Treachery positions and two unopened ordinary land Spice positions. Ixians actually choose the original ordinary projectile from their starting inspection; Emperor receives the remaining original Truthtrance. The original 47-card Ix deck is conserved.', + 'Actual first Storm places the original Hidden Mobile Stronghold at Polar Sink with its untouched six native counters including three Cyborgs. The actual Ixian pre-auction inspection returns a non-Karama card to the bottom; Emperor buys the original Karama through an actual one-spice winning bid and native invoice/payment/income.', + 'Both Advanced Spice blows, Charity, Bidding and Revival precede an actual four-spice two-Cyborg reserve shipment to Polar Sink. Original HMS counters remain there and the selected board group has no city ornithopter range. The returned move selects both physical Cyborgs explicitly with forces and eliteForces and has an actual legal distance-two route.', + 'Stop at clean turn-one Shipment and Movement with Ixians active, shipment spent and moved zero. Truthtrance declaration/question/answer, the exact ground movement and its native Ixian counter remain caller-owned. No played hand, force, wallet, phase, allowance, promise, response or receipt is installed. This source establishes only this native Advanced expansion chronology.', + ] }; +} + +export { createTruthMovementIxianFixture as createIxianTruthMovementAdvancedFixture }; +export type TruthMovementIxianFixture = ReturnType; diff --git a/tests/fixture-truth-movement-no-field.ts b/tests/fixture-truth-movement-no-field.ts new file mode 100644 index 00000000..a5566219 --- /dev/null +++ b/tests/fixture-truth-movement-no-field.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import type { NoFieldValue } from '../game/richese-no-field'; +import { + actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer, + type TruthMovementStep, +} from './fixture-truth-movement'; +import { nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; + +export type TruthMovementNoFieldStep = TruthMovementStep; +export type TruthMovementNoFieldTransition = { before: Game; steps: TruthMovementStep[]; after: Game }; +export const actTruthMovementNoField = actTruthMovement; +export const truthMovementNoFieldClean = truthMovementClean; +export const truthMovementNoFieldInventory = truthMovementInventory; +export const truthMovementNoFieldPlayer = (game: Game, actor = 'richese') => truthMovementPlayer(game, actor); +export { actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer }; + +/** Real cache terms/pass/unbid controls only. The owned concealed revelation, + * Truthtrance and shipped target's actual ground moves remain caller choices. */ +export function nextTruthMovementNoFieldNativeStep(game: Game): TruthMovementStep | null { + if (game.truthtrance) return null; + if (game.phase === 5 && game.active === 'richese' && truthMovementPlayer(game, 'richese').shipped && truthMovementClean(game)) return null; + return nextTruthMovementNativeEarlyStep(game, { + position: 'first', method: 'onceAround', direction: 'clockwise', keepUnbid: false, + card: 'richese-stone-burner', + }); +} +export function advanceTruthMovementNoField(game: Game, until: (state: Game) => boolean, + actions: TruthMovementStep[] = []): Game { + for (let count = 0; count < 1600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original No-Field movement source ended before its requested control'); + const next = nextTruthMovementNoFieldNativeStep(game); + assert.ok(next, `Supply the original Truthtrance, reveal or movement choice at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`); + game = actTruthMovement(game, next.actor, next.action, actions); + } + throw Error('The native No-Field movement source did not reach its finite boundary'); +} + +/** Public native Basic/Advanced Richese+Emperor, original E2 main35/cache10. + * An original private marker really ships to Basin9 for one-marker price2. + * Stop with the token concealed and no physical board forces materialized. */ +export function createTruthMovementNoFieldFixture(options: { advanced?: boolean; value?: NoFieldValue } = {}) { + const advanced = options.advanced ?? false, value = options.value ?? 5; + const target = 'richese', asker = 'emperor', territory = 'imperial_basin', sector = 9, location = 'imperial_basin:9'; + let game = createGame('TRUTHMOVEMENTNOFIELD', newPlayer(target, 'Richese', 'richese'), advanced, ['choam']); + 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 = advanceTruthMovementNoField(game, until, actions); }; + const transition = (run: () => void): TruthMovementNoFieldTransition => { + const before = structuredClone(game), start = actions.length; run(); + return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) }; + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['choam']); + 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 originalMainDeck = game.deck.map(card => card.id), originalCache = game.richeseCache!.map(card => card.id); + assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10); + const originalCards = truthMovementInventory(game); assert.equal(originalCards.length, 45); + const ordinary = game.deck.find(card => card.kind === 'projectile'); + const truthtrance = game.deck.find(card => card.effect === 'truthtrance'); assert.ok(ordinary && truthtrance); + const deal = [ordinary.id, truthtrance.id]; + const dealOrder = deal.map((id, position) => { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: id }; + }); + const beforeStartingDeal = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game), originalNoField = structuredClone(truthMovementPlayer(game, target).noField); + assert.ok(originalNoField); assert.deepEqual(originalNoField.tokens.map(token => token.value).sort((a, b) => a - b), [0, 3, 5]); + assert.equal(originalNoField.deployed, null); assert.equal(originalNoField.lastShipped, null); + assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [ordinary.id]); + assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]); + assert.deepEqual(truthMovementPlayer(game, target).forces, {}); assert.equal(truthMovementPlayer(game, target).reserves, 20); + 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); + // Basic has no Black Market; both modes still enter their actual bidding + // phase and then use the native cache/auction controls below. + reach(state => state.turn === 1 && state.phase === 3); + const bidding = transition(() => { + reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthMovementClean(state)); + }); + const beforeShipment = structuredClone(game), before = truthMovementPlayer(game, target); + const token = before.noField!.tokens.find(token => token.value === value); assert.ok(token); + assert.equal(before.shipped, false); assert.equal(before.moved, 0); + assert.deepEqual(before.hand.map(card => card.id), [ordinary.id]); + const shipmentEvent = before.noFieldEvent; assert.ok(shipmentEvent); + const shipmentStep: TruthMovementStep = { actor: target, action: { + type: 'ship', noField: token.id, event: shipmentEvent, territory, sector, allyPayment: 0, + } }; + const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(truthMovementClean); }); + const player = truthMovementPlayer(game, target), noFieldEvent = player.noFieldEvent; assert.ok(noFieldEvent); + 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.deepEqual(player.forces, {}); assert.equal(player.reserves, 20); assert.equal(player.tanks, 0); + assert.equal(before.spice - player.spice, 2); + assert.equal(player.noField!.deployed!.tokenId, token.id); assert.equal(player.noField!.deployed!.controller, target); + assert.deepEqual(player.noField!.deployed!.location, { territory, sector }); + assert.equal(player.noField!.lastShipped, token.id); + assert.deepEqual(player.hand.map(card => card.id), [ordinary.id]); + assert.ok(truthMovementPlayer(game, asker).hand.some(card => card.id === truthtrance.id)); + assert.deepEqual(truthMovementInventory(game), originalCards); + const revealAction: Action = { type: 'revealNoField', event: noFieldEvent, token: token.id }; + const markerMove: Action = { type: 'move', forces: {}, noField: token.id, event: noFieldEvent, + territory: 'arrakeen', sector: 10 }; + const matchingMove: Action | null = value ? { type: 'move', from: location, amount: value, + territory: 'arrakeen', sector: 10 } : null; + const claim = { kind: 'movement' as const, target, territory: 'arrakeen', minimum: Math.max(1, value) }; + const truthStep: TruthMovementStep = { actor: asker, action: { type: 'card', card: truthtrance.id } }; + const askStep: TruthMovementStep = { actor: asker, action: { type: 'truthAsk', question: claim } }; + return { game, source: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup, + bidding, beforeShipment, shipment: { ...shipment, step: shipmentStep }, shipmentStep, shipmentAction: shipmentStep.action, + advanced, target, richese: target, asker, emperor: asker, targetCard: ordinary.id, hajr: null, + truth: truthtrance.id, truthtrance: truthtrance.id, card: truthtrance.id, + territory, sector, location, value, markerValue: value, token: token.id, tokenId: token.id, + event: noFieldEvent, noFieldEvent, shipmentEvent, noField: structuredClone(player.noField), + originalNoField, noFieldState: structuredClone(player.noField), + revealAction, reveal: revealAction, revealStep: { actor: target, action: revealAction } satisfies TruthMovementStep, + markerMove, matchingMove, claim, question: claim, truthStep, declarationStep: truthStep, askStep, + deal, dealOrder, spiceOrder, originalCards, originalInventory: originalCards, originalMainDeck, originalCache, originalSpice, + actions, steps: actions, sourceActionCount: actions.length, shipmentCost: 2, + balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])), + staging: [ + `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Emperor ready/start, selected native E2 main35 plus original cache10 and no optional module. Only original scalar shuffles, two unopened starting-deal positions and two original ordinary Spice positions are chosen.`, + 'Richese is actually dealt an ordinary projectile rather than Hajr; Emperor is actually dealt its original Truthtrance. Native private Traitor/setup controls create all three original No-Field identities, values and untouched twenty-force reserve pool; no card, token or hand is installed.', + `Actually complete safe first Storm, native Spice (both blows in Advanced), Charity, ${advanced ? 'real Richese Black Market decline' : 'Basic bidding without a Black Market'}, first Once Around/cache terms, all original ordinary/cache bid passes, unbid cache retirement and Revival. Every original main/cache ID remains in its actual deck/cache/retired/hand/discard slot; there is no manufactured purchase or card acquisition.`, + 'During its real unused first turn Richese actually ships the selected original concealed marker to Basin9 for the ordinary one-marker two-spice price. All twenty original physical reserves remain; the marker has not become physical ground forces, and ordinary movement remains unused.', + 'Stop before any Truthtrance declaration or voluntary reveal. The returned revealNoField event/token, marker-only move and post-reveal physical move are original caller choices. Zero/three/five variants use the same native setup and actual shipment; no later reserve cap, force, phase, wallet, promise, preparation or receipt is installed.', + ] }; +} +export type TruthMovementNoFieldFixture = ReturnType; diff --git a/tests/movement-promises.test.ts b/tests/movement-promises.test.ts index 19b6fdd2..d3473972 100644 --- a/tests/movement-promises.test.ts +++ b/tests/movement-promises.test.ts @@ -144,7 +144,7 @@ void test('earlier current-round movement question profiles include declared Bas } }); -void test('classic Advanced Movement admits active and unused waiting seats while retaining module and native-family boundaries', () => { +void test('Advanced Movement admits active and unused waiting seats with declared native families while retaining module boundaries', () => { const advanced = movementBoundary(); advanced.advanced = true; assert.equal(movementPromiseTargetAvailable(advanced, 'target'), true); assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), true); @@ -153,6 +153,8 @@ void test('classic Advanced Movement admits active and unused waiting seats whil 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'), true); + native.expansions = []; assert.equal(movementPromiseTargetAvailable(native, 'target'), false); advanced.players[1].shipped = true; assert.equal(movementPromiseTargetAvailable(advanced, 'asker'), false); diff --git a/tests/movement-shipment-preparation.test.ts b/tests/movement-shipment-preparation.test.ts index 6742ec6f..84415985 100644 --- a/tests/movement-shipment-preparation.test.ts +++ b/tests/movement-shipment-preparation.test.ts @@ -25,3 +25,14 @@ void test('spent shipment and paid Box decisions cannot start another preparatio game.players[0].shipped = false; game.pendingNullentropy = { player: 'owner' } as Game['pendingNullentropy']; assert.deepEqual([...movementShipmentPreparations(game, 'owner', () => assert.fail('Finish the paid decision'))], []); }); + +void test('owned No-Field shipment candidates preserve marker identity, one-force tariff shares and last-use restrictions', () => { + const game = source(), owner = game.players[0]; owner.faction = 'richese'; + owner.noField = { tokens: [{ id: 'zero', value: 0 }, { id: 'three', value: 3 }, { id: 'five', value: 5 }], + lastShipped: 'three', deployed: null }; owner.noFieldEvent = 'current'; + const choices = [...movementShipmentPreparations(game, owner.id, (state, _actor, action) => + action.noField && action.territory === 'imperial_basin' && action.sector === 9 && action.allyPayment === 2 + ? structuredClone(state) : null)].map(choice => choice.actions[0]); + assert.deepEqual(choices.map(choice => choice.noField), ['zero', 'five']); + assert.ok(choices.every(choice => choice.amount === 1 && choice.elite === 0 && choice.event === 'current')); +}); diff --git a/tests/truth-movement-ixian-engine.test.ts b/tests/truth-movement-ixian-engine.test.ts new file mode 100644 index 00000000..1c06a236 --- /dev/null +++ b/tests/truth-movement-ixian-engine.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { createTruthMovementIxianFixture as fixture, actTruthMovementIxian as act, + advanceTruthMovementIxian as advance, truthMovementIxianClean as clean, + truthMovementIxianPlayer as player, truthMovementIxianInventory as inventory, + askTruthMovementIxian as ask } from './fixture-truth-movement-ixian'; + +for (const cancel of [false, true]) void test(`native Advanced Ixian typed promise ${cancel ? 'retains its unused move after prevention' : 'fulfills only after its original range counter'}`, () => { + const f = fixture(); + let g = ask(f.game, f.asker, f.truthtrance, f.target, 2, f.twoStepDestination); + assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes')); + g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean); + const before = structuredClone(player(g)); + g = act(g, f.target, f.matchingMove); + assert.equal(g.response?.kind, 'ixMovement'); + assert.deepEqual(player(g).forces, before.forces); + assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released); + if (cancel) { + g = act(g, f.asker, { type: 'card', card: f.karama, mode: 'cancel' }); + assert.equal(player(g).moved, 0); assert.deepEqual(player(g).forces, before.forces); + assert.ok(!g.movementPromises![0].fulfilled); + const replacement = viewGame(g, f.target).movementPromisePolicy?.[0]; + if (replacement?.type === 'move') { + g = advance(act(g, f.target, replacement), clean); + assert.equal(g.movementPromises![0].fulfilled, true); + } else assert.equal(g.movementPromises![0].released, true); + } else { + g = advance(g, clean); assert.equal(g.movementPromises![0].fulfilled, true); + const key = `${f.destination.territory}:${f.destination.sector}`; + assert.equal(player(g).forces[key], 2); assert.equal(player(g).elites!.forces[key], 2); + } + assert.deepEqual(inventory(g), f.originalCards); +}); diff --git a/tests/truth-movement-no-field-controls.test.tsx b/tests/truth-movement-no-field-controls.test.tsx new file mode 100644 index 00000000..89cab60c --- /dev/null +++ b/tests/truth-movement-no-field-controls.test.tsx @@ -0,0 +1,30 @@ +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 { viewGame, type Action } from '../game/engine'; +import { askTruthMovementAdvanced as ask } from './fixture-truth-movement-advanced'; +import { createTruthMovementNoFieldFixture as fixture, actTruthMovementNoField as act, + advanceTruthMovementNoField as advance, truthMovementNoFieldClean as clean } from './fixture-truth-movement-no-field'; + +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('persistent native No-Field guidance submits the actual current reveal then offers physical movement', () => { + const f = fixture({ advanced: true }); + let g = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen'); + g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean); + 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, /Reveal your No-Field to prepare physical forces/); + callbacks(MovementPromises(props))[0](); assert.deepEqual(actions, view.movementPromisePolicy); + g = act(g, f.target, actions[0]); assert.ok(!g.movementPromises![0].fulfilled); + assert.match(renderToStaticMarkup(createElement(MovementPromises, + { ...props, game: viewGame(g, f.target) })), /Move 5 forces to Arrakeen/); + callbacks(MovementPromises({ ...props, busy: true }))[0](); assert.equal(actions.length, 1); +}); diff --git a/tests/truth-movement-no-field-engine.test.ts b/tests/truth-movement-no-field-engine.test.ts new file mode 100644 index 00000000..1e0d7f9b --- /dev/null +++ b/tests/truth-movement-no-field-engine.test.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { TERRITORIES, gameDistance, splitLocation } from '../game/board'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { askTruthMovementAdvanced as ask } from './fixture-truth-movement-advanced'; +import { createTruthMovementNoFieldFixture as fixture, actTruthMovementNoField as act, + advanceTruthMovementNoField as advance, truthMovementNoFieldClean as clean, + truthMovementNoFieldPlayer as player, truthMovementNoFieldInventory as inventory } from './fixture-truth-movement-no-field'; + +for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} No-Field reveal prepares five physical forces without fulfilling movement early`, () => { + const f = fixture({ advanced }); + let g = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen'); + assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['yes', 'no']); + g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean); + const old = structuredClone(player(g, f.target)), reveal = viewGame(g, f.target).movementPromisePolicy?.[0]; + assert.equal(reveal?.type, 'revealNoField'); + g = act(g, f.target, reveal!); + assert.equal(player(g, f.target).reserves, old.reserves - 5); + assert.equal(player(g, f.target).noField?.lastShipped, old.noField?.lastShipped); + assert.equal(player(g, f.target).moved, 0); assert.ok(!g.movementPromises![0].fulfilled); + const move = viewGame(g, f.target).movementPromisePolicy?.[0]; assert.equal(move?.type, 'move'); + g = act(g, f.target, move!); assert.equal(g.movementPromises![0].fulfilled, true); + assert.equal(player(g, f.target).forces['arrakeen:10'], 5); + assert.deepEqual(inventory(g), f.originalCards); +}); + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} one-spice native No-Field shipment and reveal supply the promised five-force ground move`, () => { + const f = fixture({ advanced }); + let g = act(f.beforeShipment, f.target, { type: 'bribe', target: f.asker, + amount: player(f.beforeShipment, f.target).spice - 1 }); + g = ask(g, f.asker, f.truthtrance, f.target, 5, 'arrakeen'); + assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes')); + g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean); + const shipment = viewGame(g, f.target).movementPromisePolicy?.[0]; + assert.equal(shipment?.type, 'ship'); assert.ok(shipment.noField); + g = advance(act(g, f.target, shipment), clean); + assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released); + assert.equal(player(g, f.target).spice, 0); assert.equal(player(g, f.target).reserves, 20); + g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]); + assert.ok(!g.movementPromises![0].fulfilled); + g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]); + assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards); +}); + +void test('native zero marker is presence rather than a promised physical force', () => { + const f = fixture({ value: 0 }); + const g = ask(f.game, f.asker, f.truthtrance, f.target, 1, 'arrakeen'); + assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['no']); +}); + +void test('controlled scarce reserves cap owned reveal preparation without changing printed token value', () => { + const f = fixture(), source = structuredClone(f.game), own = player(source, f.target); + // Controlled original reserve-to-Tanks allocation, not a native casualty claim. + own.tanks += own.reserves - 2; own.reserves = 2; + const impossible = ask(source, f.asker, f.truthtrance, f.target, 3, 'arrakeen'); + assert.deepEqual(viewGame(impossible, f.target).truthMovementAnswers, ['no']); + let g = ask(source, f.asker, f.truthtrance, f.target, 2, 'arrakeen'); + g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean); + g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]); + assert.equal(player(g, f.target).reserves, 0); assert.equal(player(g, f.target).forces['imperial_basin:9'], 2); + g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]); + assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards); +}); + +const distance = (g: Game, from: string, to: string) => gameDistance(g, from, to, + point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm); +function controlledPhysical(g: Game, actor: string, amount: number) { + const own = player(g, actor); own.reserves -= amount; own.forces['imperial_basin:9'] = amount; +} + +for (const advanced of [false, true]) void test(`controlled ${advanced ? 'Advanced' : 'Basic'} zero-marker city positioning prepares physical Hajr movement without counting the marker`, () => { + const f = fixture({ advanced, value: 0 }); let g = structuredClone(f.game); + // Original physical reserve counters and original Hajr are conserved controls. + controlledPhysical(g, f.target, 2); + const at = g.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0); + const hajr = g.deck.splice(at, 1)[0]; player(g, f.target).hand.push(hajr); + const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))) + .find(to => to.sector !== g.storm && distance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) === 3); + assert.ok(to); + g = ask(g, f.asker, f.truthtrance, f.target, 2, to.territory); + g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean); + const first = viewGame(g, f.target).movementPromisePolicy?.[0]; + assert.equal(first?.type, 'move'); assert.equal(first.noField, f.token); assert.deepEqual(first.forces, {}); + g = advance(act(g, f.target, first), clean); + assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released); + const play = viewGame(g, f.target).movementPromisePolicy?.[0]; assert.equal(play?.card, hajr.id); + g = advance(act(g, f.target, play!), clean); + g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]); + assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards); +}); + +void test('a mixed physical-plus-marker move fulfills only its physical quantity', () => { + const f = fixture({ value: 0 }), source = structuredClone(f.game); + controlledPhysical(source, f.target, 3); + const impossible = ask(source, f.asker, f.truthtrance, f.target, 4, 'arrakeen'); + assert.deepEqual(viewGame(impossible, f.target).truthMovementAnswers, ['no']); + let g = ask(source, f.asker, f.truthtrance, f.target, 3, 'arrakeen'); + g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean); + g = act(g, f.target, { type: 'move', forces: { 'imperial_basin:9': 3 }, + noField: f.token, event: player(g, f.target).noFieldEvent, territory: 'arrakeen', sector: 10 }); + assert.equal(g.movementPromises![0].fulfilled, true); + assert.equal(player(g, f.target).forces['arrakeen:10'], 3); + assert.equal(player(g, f.target).noField?.deployed?.location.territory, 'arrakeen'); +}); + +void test('controlled original Karama prevents a native No-Field preparation before payment and releases its now impossible promise', () => { + const f = fixture(), source = structuredClone(f.beforeShipment); + const at = source.deck.findIndex(card => card.effect === 'karama'); assert.ok(at >= 0); + const karama = source.deck.splice(at, 1)[0]; player(source, f.asker).hand.push(karama); + let g = act(source, f.target, { type: 'bribe', target: f.asker, amount: player(source, f.target).spice - 1 }); + g = ask(g, f.asker, f.truthtrance, f.target, 5, 'arrakeen'); + g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean); + const before = structuredClone(player(g, f.target)), shipment = viewGame(g, f.target).movementPromisePolicy?.[0]; + assert.ok(shipment?.noField); + g = act(g, f.target, shipment); + assert.equal(g.response?.kind, 'richeseNoField'); + assert.deepEqual(player(g, f.target).noField, before.noField); + assert.equal(player(g, f.target).spice, before.spice); + assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released); + g = act(g, f.asker, { type: 'card', card: karama.id, mode: 'cancel' }); + assert.equal(player(g, f.target).shipped, false); assert.equal(player(g, f.target).spice, before.spice); + assert.equal(g.movementPromises![0].released, true); assert.deepEqual(inventory(g), f.originalCards); +}); + +void test('controlled earlier Basic boundary cannot voluntarily reveal away the only city-range completion', () => { + const f = fixture({ value: 0 }); let g = structuredClone(f.game); + controlledPhysical(g, f.target, 2); + g = advance(act(g, f.target, f.markerMove), clean); + g = act(g, f.target, { type: 'bribe', target: f.asker, amount: player(g, f.target).spice }); + // Explicit earlier-phase boundary, not a claimed native second-round trace. + g.phase = 4; g.ready = []; + const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))) + .find(to => to.sector !== g.storm && distance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) === 3); + assert.ok(to); + g = ask(g, f.asker, f.truthtrance, f.target, 2, to.territory); + assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes')); + g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean); + const before = structuredClone(g), own = player(g, f.target); + assert.throws(() => act(g, f.target, { type: 'revealNoField', token: own.noField!.deployed!.tokenId, + event: own.noFieldEvent })); + assert.deepEqual(g, before); +}); + +void test('all existing profiles use the actual owned reveal then physical move legally', () => { + const f = fixture({ advanced: true }); + const question = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen'); + 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); + } +});