From f0b2ac82d924ca8568bb7b58b5c290ad75cd04be Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:10:49 +1100 Subject: [PATCH] Support Basic movement promises before shipment Allow clean active Basic ground-movement questions before or after shipment. Enumerate ordinary reserve amounts, native physical allocations, destinations and pledged payment splits through the actual declaration and shared physical commit. Detached projection stops before income/arrival effects and never counts shipment as ground fulfillment. Joint movement No and shipment Yes retain a legal next action rather than invalid End guidance. Preserve exact pending ordinary reserve and native Guild permission/rate declarations when checking the remaining route. Voluntary wrong destination, force splitting or fee loss cannot release Yes through an opponent response. Guard unsupported sources before full speculative continuation. Existing live payment, cancellation, income and entry paths remain. Persistent busy-safe controls display reinforcement and both obligations; all four existing profiles use the same legal witness without strategy refinement. Verify 19 files165/165, types/lint/build, six original-action gameplay/control/table-rendering programmes and four profiles with16 accepted actions. Genuine sources19/37/24/19 retain classic Arrakeen10 reinforced to11, native zero-board Emperor/CHOAM starts and original Guild5/15/5 reserve/cross transport. Second-card/counter cases explicitly transfer conserved originals. Bounded review repaired source guarding, funding completeness and exact pending-frame behavior. Stop an observed expensive search, reorder complete candidates by useful proximity and conservatively prune dead-end final moves; twelve new engine cases pass in3.2s. Correct a display-template lint issue. Earlier phases/waiting turns, native transport/relocation preparation, No-Field/module sources, broader interactions, full modes, comprehensive assurance and deployed acceptance remain open. No strategic tuning or saved-room/reset/restart/deployment operation. --- components/movement-promises.tsx | 3 +- components/shipment-promises.tsx | 5 +- components/truthtrance.tsx | 2 +- docs/CURRENT_STATUS.md | 21 +++ docs/IMPLEMENTATION_STATUS.md | 24 +++ docs/RULE_DECISIONS.md | 2 +- docs/TRUTHTRANCE_MOVEMENT_PROMISES.md | 70 +++++++- game/engine.ts | 55 ++++-- game/movement-promise-search.ts | 20 ++- game/movement-promises.ts | 2 +- game/movement-shipment-preparation.ts | 39 ++++ game/reference.ts | 11 +- game/truthtrance.ts | 2 +- tests/fixture-truth-movement-shipment.ts | 170 ++++++++++++++++++ tests/movement-promises.test.ts | 4 +- tests/movement-shipment-preparation.test.ts | 27 +++ .../truth-movement-shipment-controls.test.tsx | 51 ++++++ tests/truth-movement-shipment-engine.test.ts | 163 +++++++++++++++++ 18 files changed, 637 insertions(+), 34 deletions(-) create mode 100644 game/movement-shipment-preparation.ts create mode 100644 tests/fixture-truth-movement-shipment.ts create mode 100644 tests/movement-shipment-preparation.test.ts create mode 100644 tests/truth-movement-shipment-controls.test.tsx create mode 100644 tests/truth-movement-shipment-engine.test.ts diff --git a/components/movement-promises.tsx b/components/movement-promises.tsx index 075c1270..4aaf9401 100644 --- a/components/movement-promises.tsx +++ b/components/movement-promises.tsx @@ -18,12 +18,13 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac : next?.type === 'card' && card?.effect === 'nullentropyBox' ? 'Pay 2 spice and search with Nullentropy Box' : next?.type === 'card' && next.mode === 'choam' ? 'Play Kulon to extend the required movement' : next?.type === 'card' ? 'Play Hajr to prepare the required movement' + : next?.type === 'ship' ? `Ship ${amount} forces to ${destinationLabel}, sector ${sector}, before the required movement` : next?.type === 'endMovement' ? 'Finish movement while honoring No' : `${next?.movementCard ? 'Use Ornithopter and move' : 'Move'} ${amount} forces to ${destinationLabel}, sector ${sector}`; return

Your ground-movement promises

{own.map((promise, index) =>

{promise.answer ? 'Yes: you must' : 'No: you must not'} {movementClaimText(promise)} before this Movement turn ends.

)} -

One qualifying move fulfills Yes. No remains binding through every later Hajr or Ornithopter move. A paid Box search must finish before moving; its fee is paid only once. Opposing effects release an answer only if it becomes impossible.

+

One qualifying ground move fulfills Yes; a shipment can prepare that move but does not fulfill it. No remains binding through every later Hajr or Ornithopter move. A paid Box search must finish before moving; its fee is paid only once. Opposing effects release an answer only if it becomes impossible.

{next && }
; } diff --git a/components/shipment-promises.tsx b/components/shipment-promises.tsx index 2b99f505..4562b97a 100644 --- a/components/shipment-promises.tsx +++ b/components/shipment-promises.tsx @@ -1,6 +1,7 @@ 'use client'; import type { Action, GameView } from '@/game/engine'; import { liveShipmentPromises, matchesShipment, shipmentClaimText } from '@/game/shipment-promises'; +import { liveMovementPromises } from '@/game/movement-promises'; import { truthQuestionText } from '@/game/truthtrance'; import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; import { territory } from '@/game/board'; @@ -20,7 +21,8 @@ export function ShipmentPromises({ if (!promises.length) return null; const me = g.players.find((p) => p.id === g.me)!; const early = g.phase < 5; - const next = g.phase === 5 ? g.shipmentCompletion?.actions[0] : undefined; + const jointMovement = liveMovementPromises(g.movementPromises ?? [], g.me, g.turn).length > 0; + const next = g.phase === 5 && !jointMovement ? g.shipmentCompletion?.actions[0] : undefined; const preparation = early ? g.shipmentPreparation?.actions[0] : undefined; const preparationCard = preparation?.type === 'card' ? me.hand?.find(card => card.id === preparation.card) : undefined; @@ -72,6 +74,7 @@ export function ShipmentPromises({ id="shipment-promise-guidance" >

Your shipment promises

+ {jointMovement &&

Your ground-movement promise guidance below keeps both sets of answers possible.

} {early ?

Your shipment opportunity is later this turn, during Shipment and Movement. Keep your needed spice, forces and cards available to honor diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 18ecf20c..bf83497a 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -349,7 +349,7 @@ export function Truthtrance({ <>

One ground movement of the chosen physical minimum, from now until the respondent’s Movement turn ends. Shipment, HMS relocation and Ambassador relocation do not count. Owned Hajr, Ornithopter, native Kulon and paid Box recovery of a known discard can prepare a later move. No stays binding after earlier nonmatching moves.

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

Choose the active Basic player after shipment and finish pending decisions. Optional-module and Advanced movement promises remain unfinished.

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

Choose the active Basic player and finish pending decisions. Optional-module and Advanced movement promises remain unfinished.

}

{truthQuestionText(question, leaderName, locationName)}

) : kind === 'shipment' || kind === 'mixedShipment' ? ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e44d5849..1c2687eb 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,27 @@ open mode gates. ## Current checkpoint and work +**Basic movement promises before shipment — 24 October. Prototyped / Partial.** +Clean active Basic turns now include ordinary reserve shipment preparation, with +actual tariffs, typed withdrawals and pledged payment splits. Shipment itself +never fulfills movement; a movement No cannot bypass a shipment Yes. Pending +ordinary reserve/Guild transport keeps its exact declaration, while unsupported +sources stay guarded. Persistent joint guidance keeps both obligations readable. + +A disjoint original-source lane, bounded response review and owner integration +pass **19 files165/165**, types/lint/build, six actual-control/table-rendering +programmes and four existing profiles with16 accepted actions. Classic source19 +reinforces original Arrakeen10 to11 for a later move, or ships under No. Native +Emperor37, CHOAM24 and Guild19 retain original cards/payments; Guild remains +5 board/15 reserve/5 spice. Second-card/counter edges disclose conserved staging. +Review repaired exact-source projection, pledged-share enumeration and joint +completion; observed search expansion prompted complete candidate reordering +and conservative dead-end pruning. Twelve new engine cases then pass in3.2s; +a display-label lint issue was corrected. [Current source/proof and remaining scope](TRUTHTRANCE_MOVEMENT_PROMISES.md#before-shipment-movement-promises--24-october-2026). +Earlier phases/waiting turns, native transport/relocation preparation, +No-Field/module sources and wider interactions remain unfinished. No strategy +tuning, comprehensive assurance or saved-room/reset/restart/deployment operation. + **Owned movement-card preparation — 24 October. Prototyped / Partial.** Basic movement promises now include held native Kulon and owned paid Box recovery. Before payment, only known current-discard identities supply a route; diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f59017ec..0883d07a 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,29 @@ # Dune implementation status +## 24 October 2026 — Basic movement promises before shipment + +Extend active Basic questions to unused ordinary shipment. Shared declaration, +tariffs and physical commit supply typed reserve arrivals and all pledged splits, +without speculative income/entry effects. Actual response/arrival paths remain. +Shipment never fulfills ground movement, and joint No/positive-shipment guidance +cannot offer illegal End. Preserve exact pending ordinary/Guild frames so a +voluntary bad shipment cannot release Yes on an opponent pass. Guard unsupported +sources before any full speculative continuation. Persistent guidance serves +the joint route and remains busy-safe. + +A disjoint original-source lane, bounded review and owner integration pass +19 files165/165, types/lint/build, six gameplay/control/table-rendering programmes +and four existing profiles with16 accepted actions. Sources19/37/24/19 retain +actual classic reinforcement10→11, native zero-board starts and original Guild +5/15/5. Controlled second-card/counter cases are separately labelled. Review +corrected exact-source and funding completeness; an observed expensive search +was stopped and candidate ordering/dead-end pruning repaired without deleting +legal candidates. Twelve new engine cases pass in3.2s; label lint was corrected. +[Authority, exact source and limits](TRUTHTRANCE_MOVEMENT_PROMISES.md#before-shipment-movement-promises--24-october-2026). +Earlier phases/waiting turns, other preparation families, No-Field/modules, +full modes, comprehensive assurance and deployed acceptance remain open. +No strategic tuning or saved-room/reset/restart/deployment operation. + ## 24 October 2026 — Owned movement-card preparation Extend bounded Basic movement promises with held native Kulon and owned paid diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 93edb5d5..c71d5bbd 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -907,7 +907,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Advanced Kull specific effect | Supplied physical29/logical27 restricts the attempted Karama for a specific effect, unlike publisher Basic phase-wide wording. New signed Advanced attempts bind semantic purpose; purchase/payment and native special variants normalize, as do enhanced Prescience/advisor conversion. CHOAM effects and special-force faction powers stay distinct. Retaining turn/phase expiry is explicit provisional composition under the 14 October policy, not supplied wording. Legacy unstamped pending/restriction frames retain broad scope. Prior distinct counter, BG-before-conversion and deferred overbid selections stay. | [Source cutover and keys](CHOAM_KULL_SOURCE_UPDATE.md#advanced-specific-effect-scope--18-october-2026), [connected implementation and limits](CHOAM_KULL_DESIGN.md#advanced-effect-scope--18-october-2026) | | Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) | | Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) | -| Truthtrance / Basic ground movement | Compose the general FAQ binding-while-possible rule with one future qualifying physical ground movement before the active post-shipment turn ends. Yes survives nonmatches while an owned completion remains; No forbids every later match through Hajr/Ornithopter until actual closure. Commit before arrival/disposal, preserve native controls, and retain compatible promises in accepted order after an opposing loss. Held native Kulon and owned paid Box recovery use actual legality; before payment, only known current-discard identities supply a Box witness. Retain original paid selection/event, the two-spice fee and existing provisional full-hand exchange; do not inspect unknown piles or erase historical memory. Shipment/HMS/Ambassador relocation and the No-Field marker itself do not count as ordinary physical ground movement. This bounded predicate and release priority are software compositions, not a movement-specific publisher FAQ. Earlier timing, wider preparations/counter mitigation, optional/Advanced and full acceptance remain unfinished. | [Authority, behavior and bounded evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md) | +| Truthtrance / Basic ground movement | Compose the general FAQ binding-while-possible rule with one future qualifying physical ground movement before the active turn ends, now before or after shipment. Ordinary reserve preparation uses actual prices, typed physical withdrawals and pledged payment splits; shipment itself never fulfills movement. Joint answers govern completion, and pending ordinary/Guild declarations retain their exact frame. Yes survives nonmatches while an owned completion remains; No forbids every later match through Hajr/Ornithopter until actual closure. Commit before arrival/disposal, preserve native controls and compatible promises after opposing loss. Held native Kulon and paid Box use actual legality and known current-discard identities before payment; retain original paid event, fee and provisional full-hand exchange without unknown inspection or erased historical memory. Shipment/HMS/Ambassador relocation and the No-Field marker do not count as ordinary physical ground movement. This predicate/release policy is software composition, not a movement-specific publisher FAQ. Earlier phases/waiting turns, native transport preparation, wider/counter combinations, optional/Advanced and full acceptance remain unfinished. | [Authority, behavior and bounded evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md) | | Semuta / native Basic entries | Fresh bare public Basic Richese previews initialize the existing protocol before genuine setup with selected native E1/E2/E3 families; no optional/Advanced inheritance or old-game retrofit. Clean source-witnessed shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: keep CHOAM's committed credit and draw the Ixian replacement only after the reaction. This composes printed component rules, not a new timing FAQ. Existing Basic Terror overflow retains the neutral all-seat response, committed-only face access, original victim/draw, removed token, payment, forces and optional gift. Nested parents, completed movement-card provenance and wider module/full-game coverage remain unfinished implementation boundaries, not printed Semuta prohibitions. | [Fresh Basic and Ambassador scope](SEMUTA_SOURCE_UPDATE_20260907.md#fresh-basic-starts-and-ambassador-discards--24-october-2026), [native Terror suffix](TERROR_DISCARD_CONTINUATIONS.md) | Shipment and mixed current-fact/shipment AND/OR follow the FAQ's whole-question diff --git a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md index da0ade2a..0a479d6a 100644 --- a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md +++ b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md @@ -17,8 +17,8 @@ movement statement, the owned-action witness and accepted-order release policy below are explicit software compositions, not a publisher movement-specific FAQ or Basic adoption of the unofficial Advanced revision. -Fresh or existing Basic native tables can ask at a clean **active post-shipment -turn**, including selected E1/E2/E3 factions. No optional modules or Advanced +Fresh or existing Basic native tables can ask at a clean **active Shipment and +Movement turn**, before or after shipment, including selected E1/E2/E3 factions. No optional modules or Advanced inherit this entry. The actual native roster, physical inventory and route validation remain authoritative; no mode or publication gate opens. Earlier phase/waiting-turn movement questions, compound movement/fact questions and wider @@ -55,9 +55,69 @@ reconfigured and absent promise fields remain absent until a real answer binds. This is bounded reachability, not complete arbitrary Truthtrance enforcement. Other owned preparation families (including native relocation preparation), -earlier timing, optional/Advanced profiles and comprehensive +earlier phases/waiting turns, optional/Advanced profiles and comprehensive combination/recovery/privacy/custody acceptance remain due. +## Before-shipment movement promises — 24 October 2026 + +The same existential ground-movement question now includes the active player's +unused ordinary reserve shipment. This is a software reachability extension of +the general FAQ contract above; it introduces no new shipment price, force or +fulfillment rule. A shipment may create or reinforce a later ground group; it +does not fulfill Yes or violate No by itself. Both existing shipment answers and +movement answers constrain the same remaining owned turn. No-only movement +guidance first honors a required shipment instead of incorrectly suggesting End. + +Ordinary preparation enumerates physical amounts, native special allocations, +destinations/sectors and explicit pledged ally-payment splits. The actual +declaration, shared tariff/funding validation and physical commit primitive +decide legality. Its detached mode stops after payment, typed withdrawal, +placement, stance, shipment-promise settlement and consuming the current price +benefit. It invents no income, draws, opponent choices or arrival effects. +Actual actions keep their original response and entry continuations. + +A pending ordinary reserve shipment or native Guild cross/return projects its +**exact** declaration before later movement is checked. A player cannot declare +a bad destination/force split and then use an opponent's pass to release Yes. +Unsupported Ambassador/No-Field/module sources retain their boundary rather +than executing a full speculative continuation. Native cross/return preparation +is not enumerated yet, though actual legal choices preserve the existing promise. +Other preparation families, earlier phases/waiting turns, optional/Advanced and +comprehensive acceptance remain unfinished. + +### Current before-shipment evidence + +`tests/fixture-truth-movement-shipment.ts` retains actual source prefixes before +their original shipment. Classic19 has Arrakeen10 plus10 reserves: a one-spice +reinforcement permits a later eleven-force ground move. Native Emperor37 has +the real bought Box/Hajr and no board forces; CHOAM24 has its actually dealt +Kulon and no board forces. Fresh public Guild19 preserves original Tuek5, +reserves15 and spice5, with actual reserve and cross transport. Only unopened +deals/Spice and native scalar lotteries are selected. No hand, wallet, force, +clock or receipt is installed. Separate second-Truth and Karama acquisition +edges explicitly transfer conserved originals; actual rate cancellation and +joint answers then use their original controls. + +The current nineteen-file union passes **165/165**, types/lint and production +build. Six actual guidance-handler/GameTable programmes cover classic Yes/No, +Emperor, CHOAM with actual Kulon and Guild reserve/cross transport; busy handlers +submit nothing. Four existing profiles make16 accepted answer/ship/move/end +actions without tuning. Bounded review repaired ordinary-source guarding, +pledged-share enumeration, exact pending declarations and joint completion. +One expensive search run was stopped; all candidates remain after ordering by +reinforcement and useful nearby routes. Conservative last-move pruning retains +owned movement cards. The twelve new engine cases subsequently pass in3.2s. +A display-template lint issue was repaired. + +Frozen base `b5a53da7126f2be5066db0723536aea3b2fa91a6`, tree +`05a19569eee3e40f07c1be2f5c4cdcb51b4772a720bafd203a9ab0acadb02d26`, +2641 files. Private logs, original-state control HTML and receipts: +`/home/quintine/.local/share/dune/checkpoints/20261024-movement-before-shipment/`, +`frozen-integrated-3/report.json`. Only final checkpoint/reference prose overlays +follow proof. Framework aliases/CSS stubbing establish server rendering, not +browser/visual/deployed acceptance. Full modes, wider timing/preparations and +comprehensive assurance remain open. No saved-room/D1/reset/restart/deployment. + ## Owned Box and Kulon preparation — 24 October 2026 The same bare Basic predicate now considers a held native CHOAM Kulon and an @@ -96,7 +156,7 @@ spending cannot remove the only remaining fee-funded route. Broader preparation through native relocation, counter mitigation, earlier timing and optional or Advanced rules remains unfinished. -### Current preparation evidence +### Preceding card-preparation evidence `tests/fixture-truth-movement-box.ts` selects one original unopened Guild Truthtrance deal, then uses actual public Basic Richese/Emperor/Guild setup, @@ -111,7 +171,7 @@ lotteries are selected; no dealt hand, wallet, force, clock or receipt is instal Separate full-hand, unknown legacy certainty, opposing fee-loss and acquired Karama-counter tests explicitly stage conserved original outcomes. -The current seventeen-file union passes **231/231**, types/lint and production +The preceding seventeen-file union passed **231/231**, types/lint and production build. Three actual-control/GameTable-rendering programmes retain full paid inspection, original event, single fee, rejected selection and busy no-submit. Four existing profiles make40 accepted paid-selection/Hajr/move/end/neutral-pass diff --git a/game/engine.ts b/game/engine.ts index 442064c0..d7205353 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,4 +1,5 @@ import { recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, recordMovementClaimKnowledge, clearMovementDiscardKnowledge, type MovementDiscardKnowledge } from './movement-discard-knowledge'; +import { movementShipmentPreparations } from './movement-shipment-preparation'; import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises'; import { findMovementCompletion, type MovementCompletion } from './movement-promise-search'; import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile'; @@ -16437,6 +16438,15 @@ function groundMovementProbe(source: Game): Game { game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null; game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null; game.pendingArrivalReaction = []; game.pendingExchange = null; + if (game.pendingShipment?.player === game.active && recoverableUnsupportedShipment(game.pendingShipment)) { + const shipment = game.pendingShipment; game.pendingShipment = null; + commitShipment(game, shipment, undefined, true); + } + if (game.pendingGuildTransport?.player === game.active) { + const intent = game.pendingGuildTransport; game.pendingGuildTransport = null; + commitGuildTransport(game, getPlayer(game, intent.player), guildTransportOrder(intent), intent.fromReserves, + undefined, undefined, true); + } if (game.pendingNullentropy && source.decision?.kind === 'nullentropy') game.decision = structuredClone(source.decision); return game; } @@ -16485,6 +16495,10 @@ function groundMovementCompletion(source: Game, actor: string, if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null; return findMovementCompletion(game, actor, promises, { allowance: movesAllowed, + canFinish(state, owner) { + return !state.pendingNullentropy && liveShipmentPromises(state.shipmentPromises ?? [], owner, state.turn) + .every(promise => matchesShipment(promise, null) === promise.answer); + }, *preparations(state, owner) { const player = getPlayer(state, owner), paid = state.pendingNullentropy; const relevant = (card: Card) => card.effect === 'hajr' || card.effect === 'ornithopter' || @@ -16497,6 +16511,14 @@ function groundMovementCompletion(source: Game, actor: string, } return; } + yield* movementShipmentPreparations(state, owner, (source, actor, action) => { + const payer = getPlayer(source, actor), share = Number(action.allyPayment); + const cost = reserveShipmentCost({ faction: payer.faction, halfRate: shipmentHalfRate(source, payer) }, + territory(String(action.territory)).type, Number(action.amount)); + if (share > cost || share > (aidFor(source, payer)?.amount ?? 0) || cost - share > payer.spice) return null; + try { return groundMovementProbe(applyActionInner(source, actor, action, 'movementPreparation')); } + catch (error) { if (error instanceof RuleError) return null; throw error; } + }); for (const card of player.hand) { if (card.effect === 'hajr' && hajrAvailable(state, player) && !transferCardBlock(state, player, card)) { try { yield { game: groundMovementProbe(applyActionInner(state, owner, @@ -28951,6 +28973,7 @@ function commitGuildTransport( g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean, guildSecretEvent?: string, settlement?: {recipient: string | null}, + movementPreparation = false, ) { requireRule(!g.guildBetrayalPreview || !guildSecretEvent, 'Guild Betrayal native transport does not combine with a Guild Secret Ally producer.'); @@ -28963,16 +28986,18 @@ function commitGuildTransport( bindGuildCunningShipment(g, p, 'guild', frame); payWithAlly(g, p, cost, allyPayment); if (settlement?.recipient) getPlayer(g, settlement.recipient).spice += cost; - const guild = byFaction(g, 'guild'); - const guildPayment = guildShipmentIncome({ - guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment, - bankOnly: g.karamaShipping?.player === p.id, - }); - if (!settlement?.recipient && guild && guildPayment > 0) - g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment), - allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment }, - ...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]); - settleBankerShipmentIncome(g, p, cost, allyPayment); + if (!movementPreparation) { + const guild = byFaction(g, 'guild'); + const guildPayment = guildShipmentIncome({ + guild: guild?.id, shipper: p.id, ally: p.ally, cost, allyPayment, + bankOnly: g.karamaShipping?.player === p.id, + }); + if (!settlement?.recipient && guild && guildPayment > 0) + g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment), + allyPayment ? undefined : p.id, [{ payer: p.id, amount: p.id === guild.id ? 0 : cost - allyPayment }, + ...(allyPayment && p.ally !== guild.id ? [{ payer: p.ally!, amount: allyPayment }] : [])]); + settleBankerShipmentIncome(g, p, cost, allyPayment); + } if (fromReserves) { p.reserves -= amount; if (p.elites) p.elites.reserves -= elite; @@ -28993,6 +29018,7 @@ function commitGuildTransport( finishGuildCunningShipment(g, p.id, 'guild', frame); p.shipped = true; g.karamaShipping = null; + if (movementPreparation) return; log(g, `${p.name} used Guild transport for ${amount} forces.${guildSecretEvent ? ` Guild Secret Ally is spent; ${cost} spice was paid to the bank. Their ordinary movement remains available.` : ''}`, guildSecretEvent ? { faction: 'guild', name: 'Secret Ally transport' } @@ -29055,7 +29081,9 @@ function offerShipment(g: Game, shipment: PendingShipment) { ); } else if (!openGuildRate(g, shipment)) commitShipment(g, shipment); } -function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipient: string | null}) { +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 (shipment.source === 'ambassador') { commitAmbassadorShipment(g, shipment); return; @@ -29182,6 +29210,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi } if (advisors && n > 0) (p.advisors ??= {})[to] ??= {}; p.shipped = true; + if (movementPreparation) { g.karamaShipping = null; return; } if (p.faction !== 'fremen') techIncome(g, 'heighliners', p); const guild = byFaction(g, 'guild'); const guildPayment = guildShipmentIncome({ @@ -34908,6 +34937,10 @@ function applyActionInner( } if (nexusEvent) recordNexusRicheseShipment(g,shipment); bindGuildCunningShipment(g,p,'reserve',shipment); + if (execution === 'movementPreparation') { + commitShipment(g, shipment, undefined, true); + return g; + } if (shipment.noField) { // This cancellation window bypasses offerShipment, so reject known // unsupported arrival combinations before saving its private intent. diff --git a/game/movement-promise-search.ts b/game/movement-promise-search.ts index 3a8e1789..7506fb4c 100644 --- a/game/movement-promise-search.ts +++ b/game/movement-promise-search.ts @@ -1,9 +1,10 @@ import type { Action, Game, Player } from './engine'; import { gameTerritories, splitLocation } from './board'; -import { liveMovementPromises, movementEventAllowed, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises'; +import { liveMovementPromises, movementEventAllowed, movementMatches, settleMovementEvent, type MovementEvent, type MovementPromise } from './movement-promises'; export type MovementPromiseAdapter = { allowance: (game: Game, player: Player) => number; + canFinish: (game: Game, player: string) => boolean; preparations: (game: Game, player: string) => Iterable<{ game: Game; actions: Action[] }>; reachable: (game: Game, player: Player, origin: string, destination: string, sector: number) => boolean; project: (game: Game, player: string, action: Action) => { game: Game; event: MovementEvent } | null; @@ -40,12 +41,14 @@ export function findMovementCompletion(source: Game, actor: string, const failed = new Set(); const search = (game: Game, pending: readonly MovementPromise[], actions: Action[]): MovementCompletion | null => { const live = liveMovementPromises(pending, actor, game.turn); - if (!live.some(promise => promise.answer)) return { actions }; + const needsMove = live.some(promise => promise.answer); + if (!needsMove && adapter.canFinish(game, actor)) return { actions }; const player = game.players.find(candidate => candidate.id === actor)!; const physical = Object.values(player.forces).reduce((sum, amount) => sum + amount, 0); - if (live.some(promise => promise.answer && promise.minimum > physical)) return null; - const key = JSON.stringify([player.forces, player.elites, player.moved, player.hand.map(card => card.id), + if (live.some(promise => promise.answer && promise.minimum > physical + (player.shipped ? 0 : player.reserves))) return null; + const key = JSON.stringify([player.forces, player.elites, player.moved, player.shipped, player.reserves, player.hand.map(card => card.id), player.hajrPlays, player.ornithopterExtraMoves, player.spice, game.choamMovement, + game.aid, game.karamaShipping, game.guildRateBlocked, game.shipmentPromises, game.discard.map(card => card.id), game.movementDiscardKnowledge?.[actor], game.pendingNullentropy, game.choamWorthlessBlocked, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null, live.map(promise => [promise.territory, promise.minimum, promise.answer])]); @@ -53,7 +56,8 @@ export function findMovementCompletion(source: Game, actor: string, 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 (!game.pendingNullentropy && player.moved < adapter.allowance(game, player)) { + if (needsMove && !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 card = !game.ornithopter ? player.hand.find(card => card.effect === 'ornithopter') : null; @@ -61,6 +65,11 @@ export function findMovementCompletion(source: Game, actor: string, ? [{ ornithopterEvent: game.ornithopter.event }] : [{}, ...(card ? [{ movementCard: card.id, ornithopter: 'range3' }, { movementCard: card.id, ornithopter: 'twoGroups' }] : [])]; + // An exhausted ordinary move cannot leave Yes pending without an owned + // card that could grant another move. Treat held Box/Hajr/Ornithopter + // conservatively; their actual availability remains the adapter's job. + const mayMoveAgain = player.moved + 1 < adapter.allowance(game, player) || !!game.ornithopter || + player.hand.some(card => ['hajr', 'ornithopter', 'nullentropyBox'].includes(card.effect ?? '')); for (const destination of destinations) for (const sector of destination.sectors) for (const origin of origins) { 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); @@ -71,6 +80,7 @@ export function findMovementCompletion(source: Game, actor: string, 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) { const action = { ...group, ...mode, territory: destination.id, sector }; const next = adapter.project(game, actor, action); diff --git a/game/movement-promises.ts b/game/movement-promises.ts index 2465d503..6d36f89a 100644 --- a/game/movement-promises.ts +++ b/game/movement-promises.ts @@ -86,7 +86,7 @@ export function finishMovementPromises( export function movementPromiseTargetAvailable(g: Game, player: string): boolean { const target = g.players.find(candidate => candidate.id === player); return g.status === 'playing' && g.advanced === false && g.phase === 5 && - g.active === player && !!target && (target.shipped || target.moved > 0) && + g.active === player && !!target && !!g.movementRemaining?.includes(player) && g.players.length >= 2 && g.players.length <= 6 && g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) && diff --git a/game/movement-shipment-preparation.ts b/game/movement-shipment-preparation.ts new file mode 100644 index 00000000..8bef0d14 --- /dev/null +++ b/game/movement-shipment-preparation.ts @@ -0,0 +1,39 @@ +import { gameDistance, gameTerritories, splitLocation, territory } from './board'; +import type { Action, Game } from './engine'; +import { liveMovementPromises } from './movement-promises'; + +/** Ordinary physical reserve shipments, not an AI shortlist. The authoritative + * dispatcher supplies cost, funding, route and arrival legality. */ +export function* movementShipmentPreparations(game: Game, actor: string, + project: (game: Game, actor: string, action: Action) => Game | null, +): Generator<{ game: Game; actions: Action[] }> { + const player = game.players.find(player => player.id === actor); + if (!player || player.shipped || game.pendingNullentropy) return; + const present = (territory: string) => Object.entries(player.forces) + .filter(([key]) => splitLocation(key).territory === territory) + .reduce((sum, [, amount]) => sum + amount, 0); + const wanted = liveMovementPromises(game.movementPromises ?? [], actor, game.turn) + .filter(promise => promise.answer).map(promise => territory(promise.territory)); + const destinations = [...gameTerritories(game)]; + const proximity = new Map(destinations.map(destination => [destination.id, wanted.length + ? Math.min(...wanted.flatMap(target => destination.id === target.id ? [Number.MAX_SAFE_INTEGER] + : destination.sectors.flatMap(from => target.sectors.map(to => + 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 (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++) { + // The bare ordinary desert tariff is at most two per force. Enumerate + // funding splits as well: retaining owned spice can preserve a later + // Box fee. The dispatcher rejects stale/ineligible/over-price pledges. + const maximum = Math.min(2 * amount, game.aid[player.ally ?? '']?.amount ?? 0); + for (let allyPayment = maximum; allyPayment >= 0; allyPayment--) { + const action: Action = { type: 'ship', territory: destination.id, sector, amount, elite, allyPayment }; + const next = project(game, actor, action); + if (next) yield { game: next, actions: [action] }; + } + } + } +} diff --git a/game/reference.ts b/game/reference.ts index d3674339..02ec97f8 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2930,9 +2930,10 @@ export const RULE_TOPICS: RuleTopic[] = [ developmentStage: 'Prototyped', summary: 'Bind one future qualifying physical ground movement before an active Basic respondent finishes their current turn.', steps: [ - 'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Ask the active Basic player after shipment and finish pending decisions first. Selected native families are supported; optional modules, Advanced and earlier or waiting turns remain unfinished.', + 'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Ask the active Basic player before or after shipment and finish pending decisions first. Selected native families are supported; optional modules, Advanced, earlier phases and waiting turns remain unfinished.', '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.', 'Private feasible answers and the next owned step use actual movement legality, typed groups, held Hajr, both Ornithopter modes and original cohorts, current city access, held native Kulon and owned Box recovery. Before paying the two-spice Box fee, only a known card still in discard supplies a route; unknown future draws/gifts are not invented. Native relocation and wider preparation remain unfinished.', 'A paid Box search retains its full private choice and original event. A guided choice preserves Yes; an incompatible selection rejects before shuffle and keeps that paid search available without another fee. No-only promises retain ordinary selection before movement can end. The existing provisional atomic full-hand exchange remains.', 'Voluntary actions cannot erase the last required route. Native counter responses finish before impossibility is decided. An opposing loss releases only incompatible answers, retaining No constraints and still-possible Yes answers in accepted order. This owned-route and release priority is a provisional software composition of the general current-turn rule, not a publisher movement-specific FAQ.', @@ -2940,11 +2941,11 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'A pure ledger and finite owned-action search reuse authoritative declaration and physical commitment. Add held native Kulon and paid Box recovery using known current-discard identities before payment. Original paid event, fee, full choice, physical replay and provisional full-hand policy remain; wrong Yes selections reject before shuffle. Earlier timing, unknown private possibilities, wider preparations/counter mitigation and optional/Advanced stay unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/movement-discard-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Dedicated destination/minimum, public preview, private feasible answers and persistent busy-safe next actions include Box payment, original paid selection and held Kulon. Full paid inspection remains; No-only searches cannot offer an invalid End action. Browser/visual acceptance remains open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/truthtrance.tsx', 'components/nullentropy-box.tsx', 'components/game-table.tsx', 'tests/truth-movement-controls.test.tsx', 'tests/truth-movement-preparation-controls.test.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All four existing profiles prioritize the compliant original paid selection and finish real Box/Hajr/move/end continuations with40 accepted actions including neutral passes. Earlier twelve answer/move/end proposals remain historical. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-engine.test.ts', 'tests/truth-movement-preparation-engine.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'A pure ledger and finite search reuse authoritative declaration/physical commitment. Active Basic questions now include ordinary reserve shipment, typed allocations, pledged splits and joint shipment predicates. Exact pending reserve/Guild declarations project without invented income/arrival effects; unsupported sources stay guarded. Held Kulon and known-discard paid Box retain prior semantics. Earlier phases/waiting turns, native transport/relocation preparation, No-Field/modules and wider interactions remain unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/movement-shipment-preparation.ts', 'game/movement-discard-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Dedicated public question/private answers and persistent busy-safe guidance include reserve reinforcement before movement, joint obligations, Box payment/selection and held Kulon. Shipment does not fulfill movement, and No cannot bypass a shipment Yes. Browser/visual acceptance remains open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/shipment-promises.tsx', 'components/truthtrance.tsx', 'components/nullentropy-box.tsx', 'components/game-table.tsx', 'tests/truth-movement-shipment-controls.test.tsx', 'tests/truth-movement-preparation-controls.test.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'All four existing profiles make16 accepted answer/reinforce/move/end actions from the genuine ten-force pre-shipment source. Prior40 paid-Box actions remain historical. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-shipment-engine.test.ts', 'tests/truth-movement-preparation-engine.test.ts'] }, { area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, existential single-event meaning, explicit software composition, source selection and unfinished scope are documented.', evidence: ['docs/TRUTHTRANCE_MOVEMENT_PROMISES.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Current seventeen-file231/231, types/lint/build and three original-action promise/paid-choice-handler/table-rendering programmes pass. Public Box source44 retains purchases1+1, shipment6 and exhausted real Hajr; held-Kulon source25 precedes route4. Separate full-hand/legacy-knowledge/fee-loss/counter edges disclose controlled conservation. Two old Auditor fixtures reproduce on the previous engine and now acknowledge actual battle results. Earlier218/218 movement evidence remains historical. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/movement-discard-knowledge.test.ts', 'tests/truth-movement-preparation-engine.test.ts', 'tests/truth-movement-preparation-controls.test.tsx', 'tests/fixture-truth-movement-box.ts', 'tests/fixture-hajr-replay.ts', 'tests/fixture-truth-movement-choam.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Current nineteen-file165/165, types/lint/build and six actual-control/table-rendering programmes pass. Original sources19/37/24/19 retain classic reinforcement10→11, native Emperor/CHOAM zero-board starts and Guild5/15/5 reserve/cross transport. Controlled second-card/counter edges are separately labelled. Source/funding review findings and expensive search ordering were repaired; twelve new engine cases pass in3.2s. Prior231/231 and218/218 evidence stays historical. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/movement-shipment-preparation.test.ts', 'tests/truth-movement-shipment-engine.test.ts', 'tests/truth-movement-shipment-controls.test.tsx', 'tests/fixture-truth-movement-shipment.ts', 'tests/fixture-truth-movement.ts', 'tests/fixture-truth-movement-choam.ts', 'tests/fixture-hajr-replay.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] }, ], }, { diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 5e856039..8fbce78c 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -197,7 +197,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory', 'Supply one ground-movement destination, minimum and respondent.'); check(movementPromiseTargetAvailable(g, v.target as string), - 'Ground-movement promises require a clean active Basic turn after shipment, with no optional modules.'); + 'Ground-movement promises require a clean active Basic turn, with no optional modules.'); try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; } catch (error) { if (error instanceof MovementClaimError) throw new TruthError(error.message); throw error; } } diff --git a/tests/fixture-truth-movement-shipment.ts b/tests/fixture-truth-movement-shipment.ts new file mode 100644 index 00000000..065a6365 --- /dev/null +++ b/tests/fixture-truth-movement-shipment.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { TERRITORIES, gameDistance, splitLocation } from '../game/board'; +import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { + actTruthMovement, createTruthMovementFixture, truthMovementClean, + truthMovementInventory, truthMovementPlayer, type TruthMovementStep, +} from './fixture-truth-movement'; +import { createTruthMovementChoamFixture } from './fixture-truth-movement-choam'; +import { createHajrReplayFixture } from './fixture-hajr-replay'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export const actTruthMovementShipment = actTruthMovement; +export const truthMovementShipmentClean = truthMovementClean; +export const truthMovementShipmentInventory = truthMovementInventory; +export const truthMovementShipmentPlayer = truthMovementPlayer; + +/** A source fixture may continue past this snapshot for its own purpose. Keep + * only its actual prefix before the named shipment, never count later actions + * as part of this unopened shipment opportunity. */ +function beforeShipmentActions(actions: TruthMovementStep[], step: TruthMovementStep) { + const index = actions.findIndex(candidate => candidate.actor === step.actor && + JSON.stringify(candidate.action) === JSON.stringify(step.action)); + assert.ok(index >= 0, 'The source must contain its actual named shipment'); + return structuredClone(actions.slice(0, index)); +} +function unopened(game: Game, target: string) { + const player = truthMovementPlayer(game, target); + assert.equal(game.status, 'playing'); assert.equal(game.advanced, false); + assert.equal(game.phase, 5); assert.equal(game.active, target); + assert.ok(truthMovementClean(game)); assert.equal(player.shipped, false); + assert.equal(player.moved, 0); assert.equal(game.movementPromises, undefined); +} +function balances(game: Game) { + return Object.fromEntries(game.players.map(player => [player.id, player.spice])); +} + +/** Original public Basic Atreides/Emperor source before its first shipment. + * Ten board forces cannot satisfy the eleven-force statement without a real + * reserve arrival. No Truthtrance or suggested shipment/movement is played. */ +export function createTruthMovementShipmentFixture(options: { hajr?: boolean } = {}) { + const source = createTruthMovementFixture(options), game = structuredClone(source.beforeShipment); + unopened(game, source.target); + const player = truthMovementPlayer(game, source.target); + assert.deepEqual(player.forces, { 'arrakeen:10': 10 }); assert.equal(player.reserves, 10); + assert.ok(player.spice >= 1); + const actions = beforeShipmentActions(source.actions, source.shipmentStep); + assert.deepEqual(truthMovementInventory(game), source.originalCards); + return { + game, source, actions, sourceActionCount: actions.length, + target: source.target, asker: source.asker, truth: source.truth, hajr: source.hajr, + claim: { kind: 'movement' as const, target: source.target, territory: 'carthag', minimum: 11 }, + shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action, + move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 11 } satisfies Action, + shipmentCost: 1, originalCards: source.originalCards, balances: balances(game), + staging: [ + 'The original public Basic Atreides/Emperor starting-deal, ordinary Spice and native scalar selections are reused at their actual beforeShipment snapshot. The recorded action prefix ends before the original helper later ships to Polar Sink. Ten original Arrakeen forces and ten reserves remain untouched. The suggested one-spice Arrakeen shipment would create an eleven-force group for a later Carthag movement; neither action nor any question, answer or promise has been installed.', + ], + }; +} + +/** Genuine native CHOAM source before its paid city shipment and held Kulon. + * The later source's actual shipment/Kulon actions are excluded from this prefix. */ +export function createTruthMovementShipmentChoamFixture() { + const source = createTruthMovementChoamFixture(), game = structuredClone(source.beforeShipment); + unopened(game, source.target); + const player = truthMovementPlayer(game, source.target); + assert.equal(Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 0); + assert.ok(player.hand.some(card => card.id === source.kulon)); assert.equal(game.choamMovement, undefined); + const shipment: Action = { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 }; + const actions = beforeShipmentActions(source.actions, { actor: source.target, action: shipment }); + const destination = TERRITORIES.flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))).find(to => + gameDistance(game, 'arrakeen:10', `${to.territory}:${to.sector}`, key => { + const place = splitLocation(key); return place.sector !== 0 && place.sector === game.storm; + }) === 4); + assert.ok(destination); + assert.deepEqual(truthMovementInventory(game), source.originalCards); + return { + game, source, actions, sourceActionCount: actions.length, + target: source.target, asker: source.asker, truth: source.truth, kulon: source.kulon, + claim: { kind: 'movement' as const, target: source.target, territory: destination.territory, minimum: 1 }, + shipment, shipmentCost: 1, destination, originalCards: source.originalCards, balances: balances(game), + staging: [ + 'Original native public Basic CHOAM/Emperor setup, unopened Kulon/Truthtrance deal, ordinary Spice positions and native scalar lotteries only. The actual beforeShipment snapshot has zero CHOAM board forces and its original held Kulon. The source action prefix excludes its later one-spice Arrakeen shipment and allowed Kulon. No hand, wallet, force, bonus, phase clock, question or receipt is installed. The suggested city shipment, native Kulon and four-step route remain real actions for the caller.', + ], + }; +} + +/** The original native paid cache/normal purchases leave Emperor entirely in + * reserves with the real Box/Hajr, while Guild holds its original dealt Truth. */ +export function createTruthMovementShipmentEmperorFixture() { + const source = createHajrReplayFixture({ truthForGuild: true }), game = structuredClone(source.beforeShipment); + const target = source.owner, asker = 'guild'; unopened(game, target); + const truth = source.guildTruth; assert.ok(truth); + const player = truthMovementPlayer(game, target); + assert.equal(Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 0); + assert.equal(player.reserves, 20); + assert.ok(player.hand.some(card => card.id === source.box)); assert.ok(player.hand.some(card => card.id === source.hajr)); + const actions = beforeShipmentActions(source.actions, source.shipmentStep); + const originalCards = truthMovementInventory(source.setup); + assert.deepEqual(truthMovementInventory(game), originalCards); + return { + game, source, actions, sourceActionCount: actions.length, target, asker, truth, + hajr: source.hajr, box: source.box, + claim: { kind: 'movement' as const, target, territory: 'carthag', minimum: 1 }, + shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action, + move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action, + shipmentCost: 1, originalCards, balances: balances(game), + staging: [ + 'Original public Basic Richese/Emperor/Guild setup, unopened original Guild Truthtrance deal, native Box1 and Hajr1 purchases, ordinary Spice positions and scalar lotteries only. The actual beforeShipment snapshot and action prefix stop before the helper later ships three Emperor forces to desert. Emperor retains all twenty original reserves, no board forces, the real bought Box/Hajr and its untouched remaining wallet. The proposed one-spice city shipment and subsequent movement are unplayed; no source hand, resource, force, phase or receipt is installed.', + ], + }; +} + +/** Public native Basic Guild/Emperor setup. Guild's printed five Tuek's forces + * are retained; this source is not a zero-board replacement of native setup. */ +export function createTruthMovementShipmentGuildFixture() { + const target = 'guild', asker = 'emperor'; + let game = createGame('TRUTHMOVEMENTSHIPMENTGUILD', newPlayer(target, 'Spacing Guild', 'guild'), false); + joinGame(game, newPlayer(asker, 'Emperor', 'emperor')); + const actions: TruthMovementStep[] = []; + const act = (actor: string, action: Action) => { game = actTruthMovement(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { + for (let count = 0; !until(game) && count < 600; count++) { + const next = nextStrongholdFactionsNativeStep(game); assert.ok(next); + act(next.actor, next.action); + } + assert.ok(until(game), 'The native Guild source must reach its original clean turn'); + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); act(game.host, { type: 'start' }); + const setup = structuredClone(game), originalCards = truthMovementInventory(game); + assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => player.hand.length === 0)); + const ordinary = game.deck.find(card => card.kind === 'projectile'), truth = game.deck.find(card => card.effect === 'truthtrance'); + assert.ok(ordinary && truth); + const dealOrder = [ordinary.id, truth.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); reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + assert.deepEqual(truthMovementPlayer(game, target).hand.map(card => card.id), [ordinary.id]); + assert.deepEqual(truthMovementPlayer(game, asker).hand.map(card => card.id), [truth.id]); + 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) }; + }); + reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthMovementClean(state)); + unopened(game, target); + const player = truthMovementPlayer(game, target); + assert.deepEqual(player.forces, { 'tueks_sietch:5': 5 }); assert.equal(player.reserves, 15); assert.equal(player.spice, 5); + assert.deepEqual(truthMovementInventory(game), originalCards); + return { + game, lobby, setup, beforeStartingDeal, afterSetup, dealOrder, spiceOrder, + actions, sourceActionCount: actions.length, target, asker, truth: truth.id, ordinary: ordinary.id, + originalCards, balances: balances(game), + claim: { kind: 'movement' as const, target, territory: 'carthag', minimum: 1 }, + shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 } satisfies Action, + shipmentCost: 1, + discountedShipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, allyPayment: 0 } satisfies Action, + discountedShipmentCost: 1, discountedShipmentFullCost: 2, + move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 1 } satisfies Action, + crossShipment: { type: 'guildShip', from: 'tueks_sietch:5', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, allyPayment: 0 } satisfies Action, + returnShipment: { type: 'guildShip', from: 'tueks_sietch:5', territory: 'reserves', amount: 2, elite: 0, allyPayment: 0 } satisfies Action, + staging: [ + 'Fresh public Basic Guild/Emperor create, join, ready and start retain the original33-card inventory. Only the unopened Guild projectile/Emperor Truthtrance deal, two ordinary unopened Spice positions and native scalar lotteries are selected. Actual setup and phases stop at the clean active Guild turn before any shipment, ground movement, question or answer. Guild retains its original five Tuek\'s Sietch forces, fifteen reserves and five spice; no board evacuation or installed hand, wallet, faction, clock or receipt occurs. The suggested city1 costs1 at both rounded rates and creates no discount; city2 costs1 at Guild rate versus2 at full price and can exercise the actual rate response. Native cross/return suggestions use the same original Tuek\'s group. No counter card is injected; an extra counter edge must disclose its own actual or conserved acquisition.', + ], + }; +} diff --git a/tests/movement-promises.test.ts b/tests/movement-promises.test.ts index dc7c32cc..6bc94671 100644 --- a/tests/movement-promises.test.ts +++ b/tests/movement-promises.test.ts @@ -104,9 +104,9 @@ void test('the Basic profile permits a clean active post-shipment respondent wit assert.equal(movementPromiseTargetAvailable(g, 'target'), true); assert.equal(movementPromiseTargetAvailable(g, 'asker'), false); }); -void test('movement question profile rejects an unspent shipment, exhausted turn and active opposing response', () => { +void test('movement question profile permits an unspent shipment but rejects an exhausted turn and active opposing response', () => { const unused = movementBoundary(); unused.players[0].shipped = false; - assert.equal(movementPromiseTargetAvailable(unused, 'target'), false); + assert.equal(movementPromiseTargetAvailable(unused, 'target'), true); const ended = movementBoundary(); ended.movementRemaining = ['asker']; assert.equal(movementPromiseTargetAvailable(ended, 'target'), false); const response = movementBoundary(); response.response = { kind: 'fremenMovement', owner: 'target', location: 'carthag:11', amount: 1, passed: [] }; diff --git a/tests/movement-shipment-preparation.test.ts b/tests/movement-shipment-preparation.test.ts new file mode 100644 index 00000000..6742ec6f --- /dev/null +++ b/tests/movement-shipment-preparation.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { movementShipmentPreparations } from '../game/movement-shipment-preparation'; +import type { Game } from '../game/engine'; + +/** Structural enumeration fixture only, not a claimed natural gameplay source. */ +function source(): Game { + return { players: [{ id: 'owner', faction: 'ixians', forces: {}, reserves: 3, shipped: false, + elites: { reserves: 1 }, ally: 'ally' }], aid: { ally: { recipient: 'owner', amount: 3 } }, + expansions: [], advanced: false } as unknown as Game; +} +void test('preparation enumeration retains larger optional pledged shares and distinct physical special-force allocations', () => { + const game = source(), before = structuredClone(game); + const choices = [...movementShipmentPreparations(game, 'owner', (state, _actor, action) => + action.territory === 'imperial_basin' && action.sector === 9 && action.amount === 2 && action.allyPayment === 3 + ? structuredClone(state) : null)]; + assert.equal(choices.length, 2); + assert.deepEqual(choices.map(choice => choice.actions[0].elite), [0, 1]); + assert.deepEqual(choices.map(choice => choice.actions[0].allyPayment), [3, 3]); + assert.deepEqual(game, before); +}); +void test('spent shipment and paid Box decisions cannot start another preparation enumeration', () => { + const game = source(); game.players[0].shipped = true; + assert.deepEqual([...movementShipmentPreparations(game, 'owner', () => assert.fail('Shipment is spent'))], []); + game.players[0].shipped = false; game.pendingNullentropy = { player: 'owner' } as Game['pendingNullentropy']; + assert.deepEqual([...movementShipmentPreparations(game, 'owner', () => assert.fail('Finish the paid decision'))], []); +}); diff --git a/tests/truth-movement-shipment-controls.test.tsx b/tests/truth-movement-shipment-controls.test.tsx new file mode 100644 index 00000000..607f938a --- /dev/null +++ b/tests/truth-movement-shipment-controls.test.tsx @@ -0,0 +1,51 @@ +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 { Truthtrance } from '../components/truthtrance'; +import { ShipmentPromises } from '../components/shipment-promises'; +import { viewGame, type Action } from '../game/engine'; +import { createTruthMovementShipmentFixture, actTruthMovementShipment as act } from './fixture-truth-movement-shipment'; + +function buttons(node: unknown): { onClick: () => void }[] { + if (Array.isArray(node)) return node.flatMap(buttons); + if (!node || typeof node !== 'object' || !('props' in node)) return []; + const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props; + return [...(props.onClick ? [{ onClick: props.onClick }] : []), ...buttons(props.children)]; +} +void test('pre-shipment answers and persistent guidance submit the real reinforcement while busy controls submit nothing', () => { + const f = createTruthMovementShipmentFixture({ hajr: false }); + let game = act(f.game, f.asker, { type: 'card', card: f.truth }); + for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' }); + game = act(game, f.asker, { type: 'truthAsk', question: f.claim }); + const answer = renderToStaticMarkup(createElement(Truthtrance, { game: viewGame(game, f.target), act() {}, busy: false })); + assert.match(answer, /Answer Yes/); assert.match(answer, /Answer No/); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + const actions: Action[] = [], props = { game: viewGame(game, f.target), act(action: Action) { actions.push(action); }, busy: false }; + const html = renderToStaticMarkup(createElement(MovementPromises, props)); + assert.match(html, /Ship 1 forces to Arrakeen, sector 10, before the required movement/); + assert.match(html, /shipment can prepare that move but does not fulfill it/); + buttons(MovementPromises({ ...props, busy: true }))[0].onClick(); assert.equal(actions.length, 0); + buttons(MovementPromises(props))[0].onClick(); assert.deepEqual(actions, props.game.movementPromisePolicy); + game = act(game, f.target, actions[0]); assert.equal(game.movementPromises![0].fulfilled, undefined); + const moved = viewGame(game, f.target); assert.equal(moved.movementPromisePolicy![0].type, 'move'); + assert.match(renderToStaticMarkup(createElement(MovementPromises, { ...props, game: moved })), /Move 11 forces to Carthag/); +}); +void test('a controlled conserved second question keeps one joint next-step control and both readable obligations', () => { + const f = createTruthMovementShipmentFixture({ hajr: false }), source = structuredClone(f.game); + const index = source.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0); + const extra = source.deck.splice(index, 1)[0]; source.players.find(p => p.id === f.asker)!.hand.push(extra); + let game = source; + for (const [card, question, answer] of [[f.truth, { kind: 'shipment', target: f.target, territory: 'arrakeen', minimum: 1 }, 'yes'], + [extra.id, f.claim, 'no']] as const) { + game = act(game, f.asker, { type: 'card', card }); + for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' }); + game = act(game, f.asker, { type: 'truthAsk', question }); + game = act(game, f.target, { type: 'truthAnswer', answer }); + } + const props = { game: viewGame(game, f.target), act() {}, busy: false }; + const shipment = renderToStaticMarkup(createElement(ShipmentPromises, props)); + assert.match(shipment, /both sets of answers possible/); assert.doesNotMatch(shipment, /Suggested next step/); + assert.match(renderToStaticMarkup(createElement(MovementPromises, props)), /Ship 1 forces to Arrakeen/); +}); diff --git a/tests/truth-movement-shipment-engine.test.ts b/tests/truth-movement-shipment-engine.test.ts new file mode 100644 index 00000000..6bffb130 --- /dev/null +++ b/tests/truth-movement-shipment-engine.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { actTruthMovement as act } from './fixture-truth-movement'; +import { createTruthMovementShipmentFixture as classic, createTruthMovementShipmentChoamFixture as choam, + createTruthMovementShipmentEmperorFixture as emperor, createTruthMovementShipmentGuildFixture as guild, + truthMovementShipmentInventory as inventory } from './fixture-truth-movement-shipment'; + +function ask(game: Game, asker: string, card: string, question: Record) { + game = act(game, asker, { type: 'card', card }); + for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' }); + return act(game, asker, { type: 'truthAsk', question }); +} +void test('a genuine active pre-shipment turn can promise an eleven-force movement using actual reserve shipment', () => { + const f = classic({ hajr: false }); + const game = ask(f.game, f.asker, f.truth, f.claim); + assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes')); +}); +function pass(game: Game): Game { + if (game.pendingTreacheryDiscard?.reaction) { + const event = game.pendingTreacheryDiscard.batch.event; + for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event }); + } + while (game.response) { + const p = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(p); + game = act(game, p.id, { type: 'passResponse' }); + } + return game; +} +function bindClassic(answer = 'yes') { + const f = classic({ hajr: false }); + let game = ask(f.game, f.asker, f.truth, f.claim); + game = pass(act(game, f.target, { type: 'truthAnswer', answer })); + return { f, game }; +} +void test('real city reinforcement pays once, leaves the ground promise pending and moves the combined eleven forces', () => { + const { f, game: bound } = bindClassic(), before = structuredClone(bound); + const first = viewGame(bound, f.target).movementPromisePolicy![0]; + assert.equal(first.type, 'ship'); assert.equal(first.territory, 'arrakeen'); assert.equal(first.amount, 1); + assert.deepEqual(bound, before); + let game = pass(act(bound, f.target, first)); + const owner = game.players.find(p => p.id === f.target)!; + assert.equal(owner.spice, f.balances[f.target] - 1); assert.equal(owner.reserves, 9); + assert.equal(owner.forces['arrakeen:10'], 11); assert.equal(game.movementPromises![0].fulfilled, undefined); + game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]); + assert.equal(game.movementPromises![0].fulfilled, true); + game = act(game, f.target, { type: 'endMovement' }); + assert.deepEqual(inventory(game), f.originalCards); +}); +void test('voluntary shipment away from the only reinforcing origin cannot erase the accepted eleven-force route', () => { + const { f, game } = bindClassic(), before = structuredClone(game); + assert.throws(() => act(game, f.target, { ...f.shipment, territory: 'carthag', sector: 11 })); + assert.deepEqual(game, before); + assert.throws(() => act(game, f.target, { type: 'endMovement' })); +}); +void test('No allows a matching-destination shipment but still forbids the subsequent qualifying ground movement', () => { + const f = classic({ hajr: false }); + let game = ask(f.game, f.asker, f.truth, { ...f.claim, minimum: 1 }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); + game = pass(act(game, f.target, { ...f.shipment, territory: 'carthag', sector: 11 })); + assert.equal(game.movementPromises![0].fulfilled, undefined); + assert.throws(() => act(game, f.target, { ...f.move, amount: 1 })); + game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true); +}); +void test('genuine zero-board native Emperor and CHOAM sources can promise movement before their actual shipment', () => { + for (const f of [emperor(), choam()]) { + let game = ask(f.game, f.asker, f.truth, f.claim); + assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes')); + game = pass(act(game, f.target, { type: 'truthAnswer', answer: 'yes' })); + assert.equal(viewGame(game, f.target).movementPromisePolicy![0].type, 'ship'); + game = pass(act(game, f.target, f.shipment)); + assert.equal(game.movementPromises![0].fulfilled, undefined); + if ('kulon' in f) game = pass(act(game, f.target, { type: 'card', mode: 'choam', card: f.kulon })); + game = pass(act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0])); + assert.equal(game.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(game), f.originalCards); + } +}); +void test('the voluntary bribe quote retains the one-spice reinforcement needed by the eleven-force route', () => { + const { f, game } = bindClassic(), before = structuredClone(game); + const maximum = viewGame(game, f.target).bribeOptions.targets.find(p => p.id === f.asker)!.maximum; + assert.equal(maximum, f.balances[f.target] - 1); + assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: maximum + 1 })); + assert.deepEqual(game, before); + const after = act(game, f.target, { type: 'bribe', target: f.asker, amount: maximum }); + assert.equal(viewGame(after, f.target).movementPromisePolicy![0].type, 'ship'); +}); +void test('a controlled conserved second Truth answer keeps shipment Yes ahead of movement No and never suggests illegal End', () => { + const f = classic({ hajr: false }), source = structuredClone(f.game); + const extra = source.deck.splice(source.deck.findIndex(card => card.effect === 'truthtrance'), 1)[0]; assert.ok(extra); + source.players.find(p => p.id === f.asker)!.hand.push(extra); + let game = ask(source, f.asker, f.truth, { kind: 'shipment', target: f.target, territory: 'arrakeen', minimum: 1 }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = ask(game, f.asker, extra.id, f.claim); game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); + const first = viewGame(game, f.target).movementPromisePolicy![0]; assert.equal(first.type, 'ship'); + game = pass(act(game, f.target, first)); + assert.equal(game.shipmentPromises![0].fulfilled, true); assert.equal(game.movementPromises![0].fulfilled, undefined); + assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'endMovement' }]); + game = act(game, f.target, { type: 'endMovement' }); assert.equal(game.movementPromises![0].fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); +function counterSource() { + const f = guild(), source = structuredClone(f.game); + const counter = source.deck.splice(source.deck.findIndex(card => card.effect === 'karama'), 1)[0]; assert.ok(counter); + source.players.find(p => p.id === f.asker)!.hand.push(counter); + return { f, source, counter }; +} +void test('a controlled acquired counter holds the real Guild-rate declaration until its original allowed shipment', () => { + const { f, source } = counterSource(); let game = ask(source, f.asker, f.truth, f.claim); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = act(game, f.target, f.discountedShipment); + assert.equal(game.response?.kind, 'guildRate'); assert.equal(game.pendingShipment?.amount, 2); + const before = structuredClone(game); viewGame(game, f.target); viewGame(game, f.asker); assert.deepEqual(game, before); + assert.equal(game.movementPromises![0].fulfilled, undefined); assert.equal(game.movementPromises![0].released, undefined); + game = pass(game); + assert.equal(game.players.find(p => p.id === f.target)!.spice, 4); + assert.equal(game.players.find(p => p.id === f.target)!.forces['arrakeen:10'], 2); + game = act(game, f.target, f.move); assert.equal(game.movementPromises![0].fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); +void test('a controlled acquired counter raises the real Guild price and releases an impossible seven-force promise', () => { + const { f, source, counter } = counterSource(); + let game = ask(source, f.asker, f.truth, { ...f.claim, territory: 'south_mesa', minimum: 7 }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 4 }); + game = act(game, f.target, { ...f.discountedShipment, territory: 'tueks_sietch', sector: 5 }); + assert.equal(game.response?.kind, 'guildRate'); + game = act(game, f.asker, { type: 'card', card: counter.id, mode: 'cancel' }); + const own = game.players.find(p => p.id === f.target)!; + assert.equal(own.spice, 1); assert.equal(own.reserves, 15); assert.equal(own.shipped, false); + assert.deepEqual(own.forces, { 'tueks_sietch:5': 5 }); + assert.equal(game.movementPromises![0].released, true); assert.deepEqual(inventory(game), f.originalCards); +}); +void test('controlled acquired-counter Guild cross-shipment preserves the exact permission and rate frames before ground fulfillment', () => { + const { f, source } = counterSource(); let game = ask(source, f.asker, f.truth, f.claim); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = act(game, f.target, f.crossShipment); + assert.ok(game.pendingGuildTransport); const before = structuredClone(game); + viewGame(game, f.target); assert.deepEqual(game, before); + game = pass(game); + const own = game.players.find(p => p.id === f.target)!; + assert.equal(own.reserves, 15); assert.equal(own.spice, 4); assert.equal(own.forces['arrakeen:10'], 2); + game = act(game, f.target, f.move); assert.equal(game.movementPromises![0].fulfilled, true); +}); +void test('controlled acquired-counter Guild return cannot split away the only five-force ground group', () => { + const { f, source } = counterSource(); + let game = ask(source, f.asker, f.truth, { ...f.claim, territory: 'south_mesa', minimum: 5 }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); const before = structuredClone(game); + assert.throws(() => act(game, f.target, f.returnShipment)); assert.deepEqual(game, before); +}); +void test('all four minimal profiles reinforce the original ten-force group and finish its promised movement', () => { + const f = classic({ hajr: false }), asked = ask(f.game, f.asker, f.truth, f.claim); + for (const difficulty of DIFFICULTIES) { + let game = asked, count = 0; + while (game.phase === 5 && game.active === f.target && count++ < 12) { + const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty; + const action = botActions(view)[0]; assert.ok(action); game = pass(act(game, f.target, action)); + } + assert.ok(count < 12); assert.equal(game.movementPromises![0].fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); + } +});