diff --git a/components/movement-promises.tsx b/components/movement-promises.tsx index 36edec05..075c1270 100644 --- a/components/movement-promises.tsx +++ b/components/movement-promises.tsx @@ -12,13 +12,18 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac const destination = next && game.combatLocations.find(location => location.id === next.territory)?.name; const destinationLabel = destination ?? (typeof next?.territory === 'string' ? next.territory : 'the chosen destination'); const sector = typeof next?.sector === 'number' ? next.sector : 0; - const label = next?.type === 'card' ? 'Play Hajr to prepare the required movement' + const card = game.players.find(player => player.id === game.me)?.hand?.find(card => card.id === next?.card); + const recovered = game.nullentropy?.search?.cards.find(card => card.id === next?.card); + const label = next?.type === 'decision' ? `Choose ${recovered?.name ?? 'the required card'} in your paid Box search` + : 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 === '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. Opposing effects release an answer only if it becomes impossible.

+

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.

{next && }
; } diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 29d9bce0..18ecf20c 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -348,7 +348,7 @@ export function Truthtrance({ {kind === 'movement' ? ( <> -

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 and Ornithopter can prepare a later move. No stays binding after earlier nonmatching moves.

+

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.

}

{truthQuestionText(question, leaderName, locationName)}

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6923ff96..e44d5849 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,27 @@ open mode gates. ## Current checkpoint and work +**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; +paid inspection keeps its full original choice/event and single two-spice fee. +An incompatible Yes selection rejects before shuffle, preserving that paid +decision. No-only searches retain ordinary selection before End movement. +Physical Hajr replay, native Kulon counters and existing full-hand policy remain. + +A disjoint knowledge/source lane, bounded preparation review and owner +integration pass **17 files231/231**, types/lint/build, three actual-control/table +rendering programmes and four existing profiles with40 accepted actions. Public +Box source44 uses actual purchases1+1, desert shipment6 and two completed Hajr +moves; native held-Kulon source25 uses actual shipment1 before route4. Full-hand, +legacy-knowledge, fee-loss and acquired-counter edges disclose controlled +conserved staging. Two stale Auditor fixture failures reproduced on the previous +engine and now acknowledge the actual battle result; test-only const lint was +repaired. [Source, exact proof and remaining scope](TRUTHTRANCE_MOVEMENT_PROMISES.md#owned-box-and-kulon-preparation--24-october-2026). +Other timing/preparations/counter mitigation, unknown private possibilities, +optional/Advanced, full modes and final assurance remain open. No strategy +tuning or saved-room/reset/restart/deployment operation. + **Basic Truthtrance ground-movement promises — 24 October. Prototyped / Partial.** Structured future ground-movement answers now bind a clean active post-shipment Basic native turn. One qualifying physical move fulfills Yes before arrival or diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 1d275b31..f59017ec 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,27 @@ # Dune implementation status +## 24 October 2026 — Owned movement-card preparation + +Extend bounded Basic movement promises with held native Kulon and owned paid +Box recovery. Pre-payment routes use known current-discard identities; private +choices invalidate location certainty without erasing historical memory. Actual +paid selection retains all entitled faces, its original event and one two-spice +fee. Reject a Yes-breaking choice before shuffle and keep that paid decision. +No-only searches retain ordinary selection; physical Hajr replay, native counters, +full-hand policy and persistent busy-safe controls remain. + +Disjoint knowledge/source lanes, bounded review and owner integration pass +17 files231/231, types/lint/build, three actual-control/table-rendering programmes +and four existing profiles with40 accepted proposals. Original public sources +44/25 retain actual Box/Hajr purchases, shipment costs6/1 and native Kulon route4. +Separate full-hand/knowledge/fee-loss/counter edges disclose conserved staging. +Two old Auditor fixtures reproduce on the previous engine; actual battle-result +acknowledgements repair their source. Test-only const lint was corrected. +[Current authority, exact source and limits](TRUTHTRANCE_MOVEMENT_PROMISES.md#owned-box-and-kulon-preparation--24-october-2026). +Broader timing, preparations/counter mitigation, unknown private possibilities, +optional/Advanced, full modes, final assurance and deployed acceptance remain +open. No tuning or saved-room/reset/restart/deployment operation. + ## 24 October 2026 — Basic Truthtrance ground-movement commitments Add the missing structured future single-movement predicate for clean active diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 60fd0cd5..93edb5d5 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. 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 owned preparations, 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 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) | | 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 0da64f02..da0ade2a 100644 --- a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md +++ b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md @@ -37,8 +37,9 @@ reconfigured and absent promise fields remain absent until a real answer binds. - Feasible answers use actual board/typed groups and the shared human movement declaration validator, not the strategic bot shortlist. The finite search considers ordinary moves, held Hajr, both held/active Ornithopter modes and - their original cohort, current city access and current native bonuses such as - CHOAM Kulon. Preparations use private detached execution; actual cards retain + their original cohort, current city access, current native bonuses, held + CHOAM Kulon and the owned Box recovery described below. Preparations use + private detached execution; actual cards retain their ordinary effects, counters, discard/response and arrival continuations. Unknown future draws/gifts and unresolved opposing outcomes are not invented. - Voluntary moves/actions cannot remove the last legal completion. A declared @@ -53,11 +54,84 @@ reconfigured and absent promise fields remain absent until a real answer binds. views without strategy changes or calibration. This is bounded reachability, not complete arbitrary Truthtrance enforcement. -Other owned preparation families (including Box recovery and native relocation -preparation), earlier timing, optional/Advanced profiles and comprehensive +Other owned preparation families (including native relocation preparation), +earlier timing, optional/Advanced profiles and comprehensive combination/recovery/privacy/custody acceptance remain due. -## Source programmes and current verification +## Owned Box and Kulon preparation — 24 October 2026 + +The same bare Basic predicate now considers a held native CHOAM Kulon and an +owned Nullentropy Box with its printed **two uncommitted spice** fee. Box +authority and the existing provisional atomic full-hand exchange are unchanged; +see [the Box source contract](NULLENTROPY_BOX_ENGINE_AUDIT.md). This extends +bounded owned reachability, not the supported mode/timing profile. + +Before payment, only a relevant physical card known still to be in the discard +may supply a Box witness. Public disposals teach all actual seats; private +disposals teach their former holder. Hajr, canonical Ornithopter and Kulon are +the only remembered faces needed here. A private Box choice or mixed Semuta +claim invalidates other seats' certainty of those candidates' **current +location**; its entitled inspector knows the remaining faces. Rebuilding the +deck removes current-discard certainty. This bookkeeping does not erase anyone's +historical memory, reveal discard names/counts before payment or retroactively +inspect unconfirmed old piles. Unknown/private preparation possibilities outside +that provable knowledge remain unfinished. The metadata is server-only; the +ordinary owner sees only the next legal action. + +After actual payment, the original private search still shows every eligible +card, retains its event and charges once. A live Yes supplies a compliant +movement-card selection in the persistent promise control. A selection that +would destroy the last route rejects **before** the real shuffle and leaves the +paid decision available; its fee is neither repeated nor refunded. No-only +promises keep the ordinary paid choice and do not offer End movement until that +choice finishes. Minimal policies use the same guided paid selection rather +than repeatedly choosing an incompatible high-value card. + +Speculation uses detached copies and deterministic pile order; it never pays, +shuffles or samples the live game. Actual Box settlement retains its one server +shuffle, physical exchange and native discard continuation. Held Kulon uses its +original native legality/cost/bonus; actual counters keep it held and blocked +when canceled. Opposing loss can release an impossible answer, while voluntary +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 + +`tests/fixture-truth-movement-box.ts` selects one original unopened Guild +Truthtrance deal, then uses actual public Basic Richese/Emperor/Guild setup, +Box1/Hajr1 purchases, three-force desert shipment6 and real Hajr plus two +one-force moves. Its44-action source leaves the original Hajr in discard, held +Box and exactly two owned spice. The actual definite Truthtrance answer adds +the second paid-search candidate. Yes recovers that same Hajr and makes the +third move; No uses the original ordinary paid-selection control. Native +CHOAM source25 stops before actual Kulon, then the question, held Kulon and +city-enabled route4 execute normally. Only unopened positions and native scalar +lotteries are selected; no dealt hand, wallet, force, clock or receipt is installed. +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 +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 +actions and finish legally without strategy refinement. Two older Box/Auditor +fixtures failed identically on the previous engine and now acknowledge the +actual battle result; a test-only const lint issue was repaired. Bounded review +checked printed search entitlement, full-hand policy, original paid continuation, +primitive disposal knowledge, memoized preparation state and paid-choice priority. + +Frozen base `edbcbe28de2128c1b59ab7b17bdf7328bddc7416`, tree +`fd900a863da9cbc9826c22ca7627c26123d1cad04915d059735cb32471c7ba00`, +2636 files. Private logs, original-state control HTML and receipts: +`/home/quintine/.local/share/dune/checkpoints/20261024-movement-card-preparation/`, +`frozen-integrated-3/report.json`. Only final checkpoint/reference prose overlays +follow that proof. Framework aliases/CSS stubbing establish server rendering, +not browser/visual/deployed acceptance. Other timing/preparations/counter +mitigation, unknown private possibilities, optional/Advanced, full modes and +comprehensive assurance remain open. No saved-room/D1/reset/restart/deployment. + +## Preceding movement checkpoint `tests/fixture-truth-movement.ts` uses genuine public Basic Atreides/Emperor setup, original33-card inventory and actual dealt Truthtrance/Hajr. Only unopened @@ -81,7 +155,7 @@ Separate multiple-question and impossibility edges explicitly stage a conserved second original Truthtrance acquisition and conserved force-loss outcomes. They are not natural second-purchase or native Atomics/Terror source evidence. -The seventeen-file affected union passes **218/218**, with types/lint and the +The preceding seventeen-file affected union passed **218/218**, with types/lint and the production build passing. Seven actual gameplay/control/table-rendering programmes cover direct Yes, later Hajr Yes, No surviving Hajr, both actual Ornithopter groups, its range-three option and native current-Kulon route4. diff --git a/game/engine.ts b/game/engine.ts index 3afc0055..442064c0 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,4 @@ +import { recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, recordMovementClaimKnowledge, clearMovementDiscardKnowledge, type MovementDiscardKnowledge } from './movement-discard-knowledge'; 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'; @@ -1973,6 +1974,8 @@ export type Game = { truthHistory?: TruthRecord[]; shipmentPromises?: ShipmentPromise[]; movementPromises?: MovementPromise[]; + /** Server-only certainty of movement-relevant cards still in the discard. */ + movementDiscardKnowledge?: MovementDiscardKnowledge; ixSetupCards?: Card[] | null; ixAuction?: { count: number; cards: Card[]; authority?: string } | null; ixAuctionKnown?: { turn: number; cards: Card[]; controller?: string } | null; @@ -4559,6 +4562,14 @@ function finishNullentropy( preview.ownerHand, { type: 'card', card: pending.box }, ); + const movementPreview = structuredClone(context); + getPlayer(movementPreview, player).hand = preview.ownerHand; + movementPreview.discard = preview.discard; + setMovementDiscardKnowledge(movementPreview, recordMovementBoxKnowledge(movementPreview.movementDiscardKnowledge, + movementPreview.players.map(p => p.id), player, preview.discard)); + requireRule(!liveMovementPromises(g.movementPromises ?? [], player, g.turn).some(promise => promise.answer) || + groundMovementCompletion(movementPreview, player) !== null, + 'Choose a card that preserves your accepted ground-movement promises. The paid search remains available.'); // All rejection paths precede this single server shuffle. The paid record and // final shuffled order are each persisted by the room's normal CAS fence. const order = shuffle(remaining).map((c) => c.id); @@ -4573,6 +4584,8 @@ function finishNullentropy( ); owner.hand = result.ownerHand; g.discard = result.discard; + setMovementDiscardKnowledge(g, recordMovementBoxKnowledge(g.movementDiscardKnowledge, + g.players.map(p => p.id), player, result.discard)); g.pendingNullentropy = null; g.decision = null; log( @@ -5210,6 +5223,14 @@ export function joinGame(g: Game, p: Player) { g.players.forEach((x) => (x.ready = !!x.bot)); log(g, `${p.name} joined as ${faction(p.faction).name}.`); } +function setMovementDiscardKnowledge(g: Game, knowledge: MovementDiscardKnowledge | undefined) { + if (knowledge === undefined) delete g.movementDiscardKnowledge; + else g.movementDiscardKnowledge = knowledge; +} +function rememberMovementDiscards(g: Game, entries: FreshDiscardBatch['entries']) { + setMovementDiscardKnowledge(g, recordMovementDiscardKnowledge(g.movementDiscardKnowledge, + g.players.map(p => p.id), entries)); +} function draw(g: Game) { requireRule( !g.pendingTreacheryDiscard, @@ -5222,6 +5243,7 @@ function draw(g: Game) { if (!g.deck.length) { g.deck = shuffle(g.discard); g.discard = []; + setMovementDiscardKnowledge(g, clearMovementDiscardKnowledge(g.movementDiscardKnowledge)); } return g.deck.shift(); } @@ -5250,6 +5272,7 @@ function discard(g: Game, p: Player, id: string, role?: Omit c.id !== id); return c; } @@ -7877,6 +7900,7 @@ function stageTreacheryDiscard( continuation: NonNullable['continuation'], ) { requireRule(!g.pendingTreacheryDiscard, 'Finish the prior discard first.'); + rememberMovementDiscards(g, entries); treacheryDiscardIntegrity(g); const sequence = (g.treacheryDiscardSequence ?? 0) + 1; g.treacheryDiscardSequence = sequence; @@ -7918,6 +7942,10 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string if (result && owner) { owner.hand = result.ownerHand; g.discard = result.discard; + setMovementDiscardKnowledge(g, recordMovementClaimKnowledge(g.movementDiscardKnowledge, + g.players.map(p => p.id), owner.id, pending.batch.entries.filter(entry => entry.discardedBy !== owner.id), claim!.card)); + rememberMovementDiscards(g, pending.batch.entries.filter(entry => entry.discardedBy === owner.id) + .map(entry => ({ ...entry, publicFace: false }))); log(g, `${owner.name} played Semuta Drug and recovered one freshly discarded Treachery Card. The acquired card stays private.`, { faction: owner.faction, name: 'Semuta Drug' }); } @@ -13572,15 +13600,17 @@ function projectedBribes(g: Game, p: Player): BribeOptions { const battlePromises = g.battle?.truthPromises?.some(promise => promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]); const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0; + const movementPromises = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer); // Reuse the authoritative promise checks on an isolated resource-only copy. // No payment, receipt or live continuation is created by this projection. - const maximum = !blocked && (battlePromises || shipmentPromises) + const maximum = !blocked && (battlePromises || shipmentPromises || movementPromises) ? maximumBribe(available, amount => { const probe = structuredClone(g); getPlayer(probe, p.id).spice -= amount; try { if (battlePromises) reconcileBattlePromises(probe, { actor: p.id, action: { type: 'bribe' } }); if (shipmentPromises) reconcileShipmentPromises(probe, { actor: p.id, action: { type: 'bribe' } }); + if (movementPromises && !groundMovementCompletion(probe, p.id)) return false; return true; } catch (error) { if (error instanceof RuleError) return false; @@ -16406,7 +16436,8 @@ function groundMovementProbe(source: Game): Game { game.pendingKarama = null; game.pendingTreacheryDiscard = null; game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null; game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null; - game.pendingArrivalReaction = []; game.pendingExchange = null; game.pendingNullentropy = null; + game.pendingArrivalReaction = []; game.pendingExchange = null; + if (game.pendingNullentropy && source.decision?.kind === 'nullentropy') game.decision = structuredClone(source.decision); return game; } function groundMovementEvent(move: MovementOrder) { @@ -16420,23 +16451,71 @@ function projectGroundMovement(source: Game, move: MovementOrder): Game { flight.completed++; if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) { if (flight.mode === 'twoGroups') player.ornithopterExtraMoves = (player.ornithopterExtraMoves ?? 0) + 1; - game.discard.push(flight.card); game.ornithopter = null; + game.discard.push(flight.card); + rememberMovementDiscards(game, [{ card: flight.card, discardedBy: player.id, publicFace: true }]); + game.ornithopter = null; } } return game; } +function projectMovementBoxRecovery(source: Game, actor: string, box: string, selected: string, paid: boolean): Game | null { + const game = structuredClone(source), owner = getPlayer(game, actor); + try { + if (paid) requireRule(game.pendingNullentropy?.player === actor && !nullentropyIntegrity(game), + 'Retain the original paid search.'); + else { validateNullentropyBegin(game, owner, box); owner.spice -= 2; } + const context = nullentropyContext(game), player = getPlayer(context, actor); + let result: ReturnType; + try { result = resolveNullentropyBox(player.hand, box, context.discard, selected, + context.discard.filter(card => card.id !== selected).map(card => card.id), handLimit(player), true); } + catch (error) { throw new RuleError(error instanceof Error ? error.message : 'Invalid owned Box recovery.'); } + validateTransferCompletion(context, player, player, result.ownerHand, result.ownerHand, { type: 'card', card: box }); + player.hand = result.ownerHand; context.discard = result.discard; + setMovementDiscardKnowledge(context, recordMovementBoxKnowledge(context.movementDiscardKnowledge, + context.players.map(p => p.id), actor, result.discard)); + return groundMovementProbe(context); + } catch (error) { + if (error instanceof RuleError) return null; + throw error; + } +} function groundMovementCompletion(source: Game, actor: string, promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn)): MovementCompletion | null { const game = groundMovementProbe(source); - if (!movementPromiseTargetAvailable(game, actor)) return null; + if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null; return findMovementCompletion(game, actor, promises, { allowance: movesAllowed, - prepareHajr(state, owner) { - const player = getPlayer(state, owner); - const card = player.hand.find(card => card.effect === 'hajr'); - if (!card || !hajrAvailable(state, player) || transferCardBlock(state, player, card)) return null; - try { return groundMovementProbe(applyActionInner(state, owner, { type: 'card', card: card.id }, 'movementPreparation')); } - catch (error) { if (error instanceof RuleError) return null; throw error; } + *preparations(state, owner) { + const player = getPlayer(state, owner), paid = state.pendingNullentropy; + const relevant = (card: Card) => card.effect === 'hajr' || card.effect === 'ornithopter' || + (player.faction === 'choam' && card.kind === 'worthless' && card.name === 'Kulon'); + if (paid) { + if (paid.player !== owner || nullentropyIntegrity(state)) return; + for (const card of eligibleNullentropyCards(state.discard).filter(relevant)) { + const projected = projectMovementBoxRecovery(state, owner, paid.box, card.id, true); + if (projected) yield { game: projected, actions: [{ type: 'decision', event: paid.event, card: card.id }] }; + } + return; + } + for (const card of player.hand) { + if (card.effect === 'hajr' && hajrAvailable(state, player) && !transferCardBlock(state, player, card)) { + try { yield { game: groundMovementProbe(applyActionInner(state, owner, + { type: 'card', card: card.id }, 'movementPreparation')), actions: [{ type: 'card', card: card.id }] }; } + catch (error) { if (!(error instanceof RuleError)) throw error; } + } + if (player.faction === 'choam' && card.kind === 'worthless' && card.name === 'Kulon') { + const action: Action = { type: 'card', mode: 'choam', card: card.id }; + try { yield { game: groundMovementProbe(applyActionInner(state, owner, action, 'movementPreparation')), actions: [action] }; } + catch (error) { if (!(error instanceof RuleError)) throw error; } + } + if (richeseCardDefinition(card)?.card.effect === 'nullentropyBox') { + // Never inspect unconfirmed private faces to advertise a pre-payment route. + for (const known of knownMovementDiscards(state.movementDiscardKnowledge, owner, state.discard).filter(relevant)) { + const projected = projectMovementBoxRecovery(state, owner, card.id, known.id, false); + if (projected) yield { game: projected, actions: [{ type: 'card', card: card.id }] }; + } + } + } }, reachable(state, player, origin, destination, sector) { // Include current native range (notably CHOAM Kulon) and the card's @@ -16455,8 +16534,10 @@ function groundMovementCompletion(source: Game, actor: string, }); } function projectedGroundMovementPolicy(g: Game, player: Player): Action[] | null { - if (g.truthtrance || !movementPromiseTargetAvailable(g, player.id) || - !liveMovementPromises(g.movementPromises ?? [], player.id, g.turn).length) return null; + const live = liveMovementPromises(g.movementPromises ?? [], player.id, g.turn); + const paid = g.pendingNullentropy?.player === player.id; + if (g.truthtrance || !live.length || (paid && !live.some(promise => promise.answer)) || + !movementPromiseTargetAvailable(paid ? { ...g, decision: null } : g, player.id)) return null; const completion = groundMovementCompletion(g, player.id); return completion ? completion.actions.length ? completion.actions.slice(0, 1) : [{ type: 'endMovement' }] : null; } @@ -27065,6 +27146,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl return; } if (pending.effect === 'kulon') { + rememberMovementDiscards(g, [{ card: used, discardedBy: choam.id, publicFace: pending.nexusEvent === undefined }]); if (g.semutaPreview && (used.name === 'Kulon' || pending.nexusEvent) && !pending.storm && !pending.revival && !pending.movement && !pending.mentat) { @@ -29990,7 +30072,7 @@ function gamontAvailable( e = p.elites?.forces[key] ?? 0; return elite === 1 ? e > 0 : elite === 0 && n > e; } -function playChoamWorthless(g: Game, p: Player, action: Action) { +function playChoamWorthless(g: Game, p: Player, action: Action, movementPreparation = false) { const storming = g.decision?.kind === 'choamStorm' && g.decision.player === p.id; const reactive = @@ -30134,6 +30216,13 @@ function playChoamWorthless(g: Game, p: Player, action: Action) { 'That player is already prevented from free revival.', ); } + if (movementPreparation) { + requireRule(effect === 'kulon' && !nexus, 'Only native Kulon is an owned movement preparation.'); + const used = discard(g, p, card.id); + rememberMovementDiscards(g, [{ card: used, discardedBy: p.id, publicFace: true }]); + completeChoamKulon(g, p); + return; + } g.pendingChoamWorthless = { owner: p.id, card: card.id, @@ -30609,8 +30698,13 @@ function refundStoppedShipmentKarama(g: Game, shipper: Player) { if (benefit?.player !== shipper.id) return; if (benefit.card && !benefit.costClaimed) { const index = g.discard.findIndex(card => card.id === benefit.card); - if (index >= 0) - getPlayer(g, benefit.owner).hand.push(...g.discard.splice(index, 1)); + if (index >= 0) { + const [card] = g.discard.splice(index, 1); + getPlayer(g, benefit.owner).hand.push(card); + setMovementDiscardKnowledge(g, recordMovementClaimKnowledge(g.movementDiscardKnowledge, + g.players.map(p => p.id), benefit.owner, + [{ card, discardedBy: benefit.owner, publicFace: true }], card.id)); + } } g.karamaShipping = null; } @@ -31777,7 +31871,7 @@ function applyActionInner( // Conversion spends the selected card as fuel; its printed effect must not // intercept that explicit mode (including Recruits and other special faces). if (t === 'card' && action.mode === 'choam') { - playChoamWorthless(g, p, action); + playChoamWorthless(g, p, action, execution === 'movementPreparation'); return g; } if (t === 'card' && action.card === RECRUITS_CARD_ID) { diff --git a/game/movement-discard-knowledge.ts b/game/movement-discard-knowledge.ts new file mode 100644 index 00000000..8fbb5c67 --- /dev/null +++ b/game/movement-discard-knowledge.ts @@ -0,0 +1,82 @@ +import type { Card } from './cards'; +import type { FreshDiscardBatch } from './semuta-drug'; + +/** Remembered movement-relevant discard identities, never a public pile view. */ +export type MovementDiscardKnowledge = Record; +type Entries = FreshDiscardBatch['entries']; +type Knowledge = MovementDiscardKnowledge | undefined; + +const relevant = (card: Card): boolean => + (card.kind === 'special' && card.effect === 'hajr') || + (card.kind === 'special' && card.id === 'richese-ornithopter' && card.effect === 'ornithopter') || + (card.kind === 'worthless' && card.name === 'Kulon'); + +function viewers(seats: readonly string[]): string[] { + return [...new Set(seats)].sort(); +} +function remembered(state: Knowledge, seats: readonly string[]) { + return new Map(viewers(seats).map(player => [player, new Set(state?.[player] ?? [])])); +} +function finish(knowledge: Map>): Knowledge { + const entries = [...knowledge].filter(([, ids]) => ids.size) + .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) + .map(([player, ids]) => [player, [...ids].sort()] as const); + return entries.length ? Object.fromEntries(entries) : undefined; +} + +/** Public disposal teaches every seat; private disposal teaches its actual + * former holder only. Unrelated cards create no new preparation knowledge. */ +export function recordMovementDiscardKnowledge( + state: Knowledge, seats: readonly string[], entries: Entries, +): Knowledge { + const knowledge = remembered(state, seats); + for (const entry of entries) { + if (!relevant(entry.card)) continue; + for (const [player, known] of knowledge) + if (entry.publicFace || entry.discardedBy === player) known.add(entry.card.id); + } + return finish(knowledge); +} + +/** Current remembered faces only. An unknown face cannot become a preparation + * witness merely because the authoritative pile happens to contain it. */ +export function knownMovementDiscards( + state: Knowledge, player: string, discard: readonly Card[], +): Card[] { + const known = new Set(state?.[player] ?? []), selected = new Set(); + return structuredClone(discard.filter(card => { + if (!known.has(card.id) || !relevant(card) || selected.has(card.id)) return false; + selected.add(card.id); return true; + })); +} + +/** A private Box choice may remove any remembered card. The paid search owner + * alone knows the remaining eligible faces after the search has settled. */ +export function recordMovementBoxKnowledge( + _state: Knowledge, seats: readonly string[], owner: string, remainingDiscard: readonly Card[], +): Knowledge { + const knowledge = remembered(undefined, seats); + const own = knowledge.get(owner); + if (own) for (const card of remainingDiscard) if (relevant(card)) own.add(card.id); + return finish(knowledge); +} + +/** Others cannot infer the privately selected member of a fresh mixed batch. + * The committed claimant has inspected that batch and knows its remainder. */ +export function recordMovementClaimKnowledge( + state: Knowledge, seats: readonly string[], owner: string, + batchEntries: Entries, claimedId: string, +): Knowledge { + const knowledge = remembered(state, seats); + const possible = new Set(batchEntries.map(entry => entry.card.id)); + for (const known of knowledge.values()) for (const id of possible) known.delete(id); + const own = knowledge.get(owner); + if (own) for (const { card } of batchEntries) + if (card.id !== claimedId && relevant(card)) own.add(card.id); + return finish(knowledge); +} + +/** Rebuilding the draw deck removes all remembered discard preparation faces. */ +export function clearMovementDiscardKnowledge(_state: Knowledge): Knowledge { + return undefined; +} diff --git a/game/movement-promise-search.ts b/game/movement-promise-search.ts index fe78175e..3a8e1789 100644 --- a/game/movement-promise-search.ts +++ b/game/movement-promise-search.ts @@ -4,7 +4,7 @@ import { liveMovementPromises, movementEventAllowed, settleMovementEvent, type M export type MovementPromiseAdapter = { allowance: (game: Game, player: Player) => number; - prepareHajr: (game: Game, player: string) => Game | null; + 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; }; @@ -31,7 +31,8 @@ function* groups(player: Player, keys: string[], amount: number, index = 0, } /** Finite owned-action reachability. Each move consumes a real entitlement; - * Hajr and Ornithopter are existing physical cards, never invented future draws. + * Hajr, Ornithopter, Kulon and Box use owned physical cards and known or + * already-paid recovery choices, never invented unknown future draws. * The adapter shares authoritative movement validation/physical commitment. * Opposing arrival outcomes are not simulated or assumed. */ export function findMovementCompletion(source: Game, actor: string, @@ -44,13 +45,15 @@ export function findMovementCompletion(source: Game, actor: string, 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), - player.hajrPlays, player.ornithopterExtraMoves, game.hajr, game.ornithopter ? [game.ornithopter.mode, game.ornithopter.startingMove, game.ornithopter.completed, game.ornithopter.cohort] : null, + player.hajrPlays, player.ornithopterExtraMoves, player.spice, game.choamMovement, + 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])]); 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 (player.moved < adapter.allowance(game, player)) { + if (!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; @@ -80,10 +83,8 @@ export function findMovementCompletion(source: Game, actor: string, } } } - const prepared = adapter.prepareHajr(game, actor); - if (prepared) { - const card = player.hand.find(card => card.effect === 'hajr')!; - const result = search(prepared, live, [...actions, { type: 'card', card: card.id }]); + for (const prepared of adapter.preparations(game, actor)) { + const result = search(prepared.game, live, [...actions, ...prepared.actions]); if (result) return result; } failed.add(key); return null; diff --git a/game/reference.ts b/game/reference.ts index a7794c39..d3674339 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2933,17 +2933,18 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', '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.', - '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 and native bonuses such as Kulon. Unknown future draws/gifts are not invented. Other preparation families, including Box recovery and native relocation preparation, remain unfinished.', + '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.', '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: 'A pure ledger and finite owned-action search reuse authoritative declaration and physical commitment. Yes settles before arrivals/disposal; No closes only at actual turn end. Shared native movement, current Kulon and original card cohorts remain. Earlier timing, other preparations and optional/Advanced profiles stay unfinished.', evidence: ['game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/truthtrance.ts', 'game/engine.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Dedicated destination/minimum, exact public preview, private feasible answers and persistent busy-safe next actions have current Basic controls. Browser/visual acceptance remains open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-movement-controls.test.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'All four existing profiles make twelve accepted answer/move/end proposals from real public Basic source views. This is minimal legal participation without strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-engine.test.ts'] }, + { 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: '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: 'Seventeen-file218/218, types/lint/build and seven original-action promise-handler/table-rendering programmes pass. Public classic source20, native paid Ornithopter source58 and genuine Kulon source26 retain actual pieces/cards/payments; only unopened positions and scalar lotteries are selected. Separate extra-card/force-loss edges disclose conserved staging. Review corrected Kulon reachability/selective release; private preparation and old fixture feedback were repaired. No browser/visual/deployed, full-mode or comprehensive assurance claim.', evidence: ['tests/movement-promises.test.ts', 'tests/truth-movement-engine.test.ts', 'tests/truth-movement-controls.test.tsx', 'tests/fixture-truth-movement.ts', 'tests/fixture-truth-movement-choam.ts', 'tests/fixture-basic-semuta-ambassador.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.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'] }, ], }, { diff --git a/tests/box-discard-continuations.test.ts b/tests/box-discard-continuations.test.ts index c1714f2b..32b7994e 100644 --- a/tests/box-discard-continuations.test.ts +++ b/tests/box-discard-continuations.test.ts @@ -673,6 +673,12 @@ for (const stage of ['response', 'payment'] as const) }); initial = applyAction(initial, 'p', { type: 'traitorCall', call: false }); initial = applyAction(initial, 'q', { type: 'traitorCall', call: false }); + while (initial.battle?.outcomeWindow) { + const window = initial.battle.outcomeWindow; + const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); + assert.ok(actor); + initial = applyAction(initial, actor, { type: 'battleOutcomeReady', event: window.event }); + } assert.equal(initial.decision?.kind, 'choamAudit'); initial = applyAction(initial, 'q', { type: 'decision', diff --git a/tests/fixture-hajr-replay.ts b/tests/fixture-hajr-replay.ts index fe83af75..392ce289 100644 --- a/tests/fixture-hajr-replay.ts +++ b/tests/fixture-hajr-replay.ts @@ -51,7 +51,7 @@ export function advanceHajrReplay(game: Game, until: (state: Game) => boolean, /** Genuine original host, setup, paid Box/Hajr lots and paid desert shipment. * Only unopened Hajr/Spice deck positions and scalar lotteries are selected. * No hand, wallet, force, phase or completed-effect receipt is installed. */ -export function createHajrReplayFixture(options: { advanced?: boolean } = {}) { +export function createHajrReplayFixture(options: { advanced?: boolean; truthForGuild?: boolean } = {}) { const advanced = options.advanced ?? false; let game = createGame('HAJRREPLAY', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']); for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction)); @@ -62,8 +62,22 @@ export function createHajrReplayFixture(options: { advanced?: boolean } = {}) { const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); const setup = structuredClone(game); + const startingDealSelection: { position: number; from: number; card: string }[] = []; + let guildTruth: string | null = null; + if (options.truthForGuild) { + assert.equal(game.status, 'setup'); + assert.ok(game.players.every(player => player.hand.length === 0), 'Select only an unopened starting deal'); + const position = game.players.findIndex(player => player.id === 'guild'); + const from = game.deck.findIndex(card => card.effect === 'truthtrance'); + assert.ok(position >= 0 && from >= position); + guildTruth = game.deck[from].id; + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + startingDealSelection.push({ position, from, card: guildTruth }); + } + const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing'); const afterSetup = structuredClone(game), front: Game['spiceDeck'] = []; + if (guildTruth) assert.ok(hajrReplayPlayer(game, 'guild').hand.some(card => card.id === guildTruth)); for (let count = 0; count < (advanced ? 2 : 1); count++) { const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]); @@ -83,7 +97,8 @@ export function createHajrReplayFixture(options: { advanced?: boolean } = {}) { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 3, elite: advanced ? 2 : 0, allyPayment: 0, } }; act(shipmentStep.actor, shipmentStep.action); reach(hajrReplayClean); - return { game, lobby, setup, afterSetup, beforePurchases, acquired, beforeShipment, shipmentStep, + return { game, lobby, setup, beforeStartingDeal, startingDealSelection, guildTruth, + afterSetup, beforePurchases, acquired, beforeShipment, shipmentStep, afterShipment: structuredClone(game), hajr, box: HAJR_REPLAY_BOX, owner: 'emperor', source: 'imperial_basin:9', actions, staging: ['Original native setup and paid cache1/normal1 lots acquire Box and the single Hajr. An original paid3-counter desert shipment leaves the two-spice Box fee. Only unopened deck positions and scalar lotteries are selected.'] }; diff --git a/tests/fixture-truth-movement-box.ts b/tests/fixture-truth-movement-box.ts new file mode 100644 index 00000000..57b9da76 --- /dev/null +++ b/tests/fixture-truth-movement-box.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { location, splitLocation } from '../game/board'; +import { + actHajrReplay, advanceHajrReplay, createHajrReplayFixture, hajrReplayClean, + hajrReplayMove, hajrReplayPlayer, nextHajrReplayStep, type HajrReplayStep, +} from './fixture-hajr-replay'; + +export type TruthMovementBoxStep = HajrReplayStep; +export const actTruthMovementBox = actHajrReplay; +export const truthMovementBoxPlayer = hajrReplayPlayer; +export const truthMovementBoxClean = hajrReplayClean; +export function truthMovementBoxInventory(game: Game) { + const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(player => player.hand)]; + const ids = cards.map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'Every original physical card has one current location'); + return ids; +} +export const truthMovementBoxBalances = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice])); + +/** Native source responses only; the caller owns the question, paid private + * selection and the active respondent's movement, including its ending. */ +export function nextTruthMovementBoxStep(game: Game): TruthMovementBoxStep | null { + if (game.truthtrance || game.pendingNullentropy) return null; + if (game.phase === 5 && game.active === 'emperor' && hajrReplayClean(game)) return null; + return nextHajrReplayStep(game); +} +export function advanceTruthMovementBox(game: Game, until: (state: Game) => boolean, + actions: TruthMovementBoxStep[] = []): Game { + for (let count = 0; count < 600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Box/movement programme ended before its next control'); + const next = nextTruthMovementBoxStep(game); + assert.ok(next, 'Supply the original Truthtrance, paid Box selection or active movement choice'); + game = actHajrReplay(game, next.actor, next.action, actions); + } + throw Error('The original Box/movement programme did not reach its finite boundary'); +} + +/** Public Basic setup and original purchases/forces only. The first actual Hajr + * is spent and both earned movements are completed, so another ground move + * requires recovery and replay of that same physical card. No question, Box + * payment, private search, promise or third movement is submitted here. */ +export function createTruthMovementBoxFixture() { + const fixture = createHajrReplayFixture({ truthForGuild: true }); + const owner = fixture.owner, target = owner, asker = 'guild', truth = fixture.guildTruth; + assert.ok(truth); + let game = structuredClone(fixture.game); + const originalCards = truthMovementBoxInventory(fixture.setup); + const actions: TruthMovementBoxStep[] = structuredClone(fixture.actions); + const act = (actor: string, action: Action) => { game = actHajrReplay(game, actor, action, actions); }; + const reach = () => { game = advanceHajrReplay(game, hajrReplayClean, actions); }; + const balances = truthMovementBoxBalances(game); + const beforeHajr = structuredClone(game); + act(owner, { type: 'card', card: fixture.hajr }); reach(); + const afterHajr = structuredClone(game); + const moves: { before: Game; step: TruthMovementBoxStep; after: Game }[] = []; + let from = fixture.source; + for (let count = 0; count < 2; count++) { + const before = structuredClone(game), step: TruthMovementBoxStep = { actor: owner, action: hajrReplayMove(game, from) }; + act(step.actor, step.action); reach(); + moves.push({ before, step: structuredClone(step), after: structuredClone(game) }); + from = location(String(step.action.territory), Number(step.action.sector)); + } + const source = structuredClone(game), own = hajrReplayPlayer(game); + const ownView = viewGame(game, owner).players.find(player => player.id === owner)!; + assert.equal(game.advanced, false); assert.equal(game.phase, 5); assert.equal(game.active, owner); + assert.equal(own.moved, 2); assert.equal(ownView.movesAllowed, 2); assert.equal(ownView.hajrPlays, 1); + assert.equal(own.spice, 2, 'The genuine purchases and shipment retain exactly the printed Box fee'); + assert.ok(own.hand.some(card => card.id === fixture.box)); + assert.equal(game.discard.filter(card => card.id === fixture.hajr).length, 1); + assert.ok(!own.hand.some(card => card.id === fixture.hajr)); + assert.ok(hajrReplayPlayer(game, asker).hand.some(card => card.id === truth)); + assert.ok(!game.truthtrance && !game.pendingNullentropy && !game.pendingTreacheryDiscard); + assert.equal(game.movementPromises, undefined); + assert.deepEqual(truthMovementBoxBalances(game), balances); + assert.deepEqual(truthMovementBoxInventory(game), originalCards); + const proposedThirdMove = hajrReplayMove(game, from); + const destination = splitLocation(location(String(proposedThirdMove.territory), Number(proposedThirdMove.sector))); + const claim = { kind: 'movement' as const, target, territory: destination.territory, minimum: 1 }; + return { + game, source, fixture, lobby: fixture.lobby, setup: fixture.setup, beforeStartingDeal: fixture.beforeStartingDeal, + startingDealSelection: fixture.startingDealSelection, afterSetup: fixture.afterSetup, + acquired: fixture.acquired, beforeShipment: fixture.beforeShipment, shipmentStep: fixture.shipmentStep, + afterShipment: fixture.afterShipment, beforeHajr, afterHajr, moves, + owner, target, asker, truth, hajr: fixture.hajr, box: fixture.box, boxFee: 2, + claim, proposedThirdMove, thirdOrigin: from, destination, originalCards, + actions, sourceActionCount: actions.length, balances, + staging: [ + ...fixture.staging, + 'One original unopened Guild starting-deal slot receives original Truthtrance. The native source actually buys Box for1 and Hajr for1 and ships three physical Emperor forces at its original six-spice desert tariff. Actual Basic Hajr plus two ordinary one-force movements exhaust the two current allowances while leaving exactly two owned spice and the held Box. The first Hajr is physically discarded once and publicly used; native Semuta acknowledgements finish that same original cost. No dealt hand, wallet, force, clock, bonus, promise or pending receipt is installed. This source stops before the Truthtrance declaration/answer and before paying for the Box. The later real definite Truthtrance answer naturally adds another eligible discard, so the paid search can have multiple actual cards without an injected distractor.', + ], + }; +} +export type TruthMovementBoxFixture = ReturnType; diff --git a/tests/movement-discard-knowledge.test.ts b/tests/movement-discard-knowledge.test.ts new file mode 100644 index 00000000..0b243cfe --- /dev/null +++ b/tests/movement-discard-knowledge.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Card } from '../game/cards'; +import type { FreshDiscardBatch } from '../game/semuta-drug'; +import { + recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, + recordMovementClaimKnowledge, clearMovementDiscardKnowledge, + type MovementDiscardKnowledge, +} from '../game/movement-discard-knowledge'; + +const seats = ['owner', 'rival', 'observer']; +const hajr: Card = { id: 'physical-hajr', kind: 'special', name: 'Hajr', effect: 'hajr' }; +const ornithopter: Card = { id: 'richese-ornithopter', kind: 'special', name: 'Ornithopter', effect: 'ornithopter' }; +const kulon: Card = { id: 'physical-kulon', kind: 'worthless', name: 'Kulon' }; +const shield: Card = { id: 'physical-shield', kind: 'shield', name: 'Shield' }; +const entry = (card: Card, discardedBy = 'owner', publicFace = false): FreshDiscardBatch['entries'][number] => + ({ card, discardedBy, publicFace }); + +void test('a public relevant disposal is remembered by all seats without creating unrelated face knowledge', () => { + const entries = [entry(hajr, 'rival', true), entry(shield, 'rival', true)]; + const before = structuredClone(entries); + const result = recordMovementDiscardKnowledge(undefined, seats, entries); + assert.deepEqual(result, { observer: [hajr.id], owner: [hajr.id], rival: [hajr.id] }); + for (const player of seats) assert.deepEqual(knownMovementDiscards(result, player, [shield, hajr]), [hajr]); + assert.deepEqual(entries, before); +}); + +void test('a private disposal teaches only its holder and an unknown relevant face cannot leak through lookup', () => { + const result = recordMovementDiscardKnowledge(undefined, seats, [entry(hajr), entry(kulon, 'rival')]); + assert.deepEqual(result, { owner: [hajr.id], rival: [kulon.id] }); + assert.deepEqual(knownMovementDiscards(result, 'owner', [hajr, kulon, ornithopter]), [hajr]); + assert.deepEqual(knownMovementDiscards(result, 'observer', [hajr, kulon, ornithopter]), []); +}); + +void test('canonical relevance excludes an Ornithopter lookalike, unrelated Worthless and absent state', () => { + const fake = { ...ornithopter, id: 'different-ornithopter' }; + const otherWorthless: Card = { id: 'other-worthless', name: 'Baliset', kind: 'worthless' }; + assert.equal(recordMovementDiscardKnowledge(undefined, seats, [entry(fake, 'owner', true), entry(otherWorthless), entry(shield)]), undefined); + assert.equal(recordMovementDiscardKnowledge(undefined, seats, []), undefined); + assert.deepEqual(knownMovementDiscards(undefined, 'owner', [hajr, ornithopter, kulon]), []); + assert.deepEqual(recordMovementDiscardKnowledge(undefined, seats, [entry(ornithopter), entry(kulon)]), + { owner: [kulon.id, ornithopter.id].sort() }); +}); + +void test('a paid private Box invalidates other memories and grants only its owner the actual remaining faces', () => { + const state: MovementDiscardKnowledge = { owner: [hajr.id], rival: [hajr.id, ornithopter.id], observer: [kulon.id] }; + const before = structuredClone(state), pile = [shield, ornithopter, kulon], originalPile = structuredClone(pile); + const result = recordMovementBoxKnowledge(state, seats, 'owner', pile); + assert.deepEqual(result, { owner: [kulon.id, ornithopter.id].sort() }); + assert.deepEqual(knownMovementDiscards(result, 'owner', pile), [ornithopter, kulon]); + assert.deepEqual(knownMovementDiscards(result, 'rival', pile), []); + assert.equal(recordMovementBoxKnowledge(state, seats, 'owner', [shield]), undefined); + assert.deepEqual(state, before); assert.deepEqual(pile, originalPile); +}); + +void test('a private mixed claim removes ambiguous fresh memories while preserving identities outside that batch', () => { + const state: MovementDiscardKnowledge = { + owner: [hajr.id, ornithopter.id], rival: [hajr.id, ornithopter.id], observer: [kulon.id, ornithopter.id], + }; + const before = structuredClone(state), entries = [entry(hajr, 'rival'), entry(kulon, 'observer')]; + const originalEntries = structuredClone(entries); + const result = recordMovementClaimKnowledge(state, seats, 'owner', entries, hajr.id); + assert.deepEqual(result, { + observer: [ornithopter.id], owner: [kulon.id, ornithopter.id].sort(), rival: [ornithopter.id], + }); + assert.deepEqual(knownMovementDiscards(result, 'owner', [ornithopter, kulon]), [ornithopter, kulon]); + assert.deepEqual(knownMovementDiscards(result, 'observer', [ornithopter, kulon]), [ornithopter]); + assert.deepEqual(state, before); assert.deepEqual(entries, originalEntries); +}); + +void test('a full discard reshuffle clears preparation knowledge without mutating the former record', () => { + const state = { owner: [hajr.id], rival: [ornithopter.id] }, before = structuredClone(state); + assert.equal(clearMovementDiscardKnowledge(state), undefined); + assert.equal(clearMovementDiscardKnowledge(undefined), undefined); + assert.deepEqual(state, before); +}); + +void test('lookup filters stale identities, preserves physical pile order and returns independent card faces', () => { + const state = { owner: [hajr.id, 'removed-card', kulon.id, shield.id] }; + const pile = [kulon, shield, hajr], before = structuredClone(pile); + const faces = knownMovementDiscards(state, 'owner', pile); + assert.deepEqual(faces, [kulon, hajr]); + faces[0].name = 'changed'; faces.push(ornithopter); + assert.deepEqual(pile, before); + assert.deepEqual(state.owner, [hajr.id, 'removed-card', kulon.id, shield.id]); +}); + +void test('repeated observations use deterministic unique identities and do not alias original state arrays', () => { + const state = { owner: [hajr.id, hajr.id] }, before = structuredClone(state); + const events = [entry(ornithopter, 'owner', true), entry(kulon), entry(hajr)]; + const first = recordMovementDiscardKnowledge(state, seats, events)!; + const second = recordMovementDiscardKnowledge(first, [...seats].reverse(), [...events].reverse())!; + assert.deepEqual(first, second); assert.deepEqual(state, before); + second.owner.push('new-id'); + assert.deepEqual(first.owner, [hajr.id, kulon.id, ornithopter.id].sort()); +}); diff --git a/tests/truth-movement-preparation-controls.test.tsx b/tests/truth-movement-preparation-controls.test.tsx new file mode 100644 index 00000000..864e4685 --- /dev/null +++ b/tests/truth-movement-preparation-controls.test.tsx @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MovementPromises } from '../components/movement-promises'; +import { NullentropySearch } from '../components/nullentropy-box'; +import { viewGame, type Action } from '../game/engine'; +import { createTruthMovementBoxFixture, actTruthMovementBox as act } from './fixture-truth-movement-box'; + +function clickables(node: unknown): { children?: unknown; onClick: () => void }[] { + if (Array.isArray(node)) return node.flatMap(clickables); + if (!node || typeof node !== 'object' || !('props' in node)) return []; + const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props; + return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...clickables(props.children)]; +} +function bound(answer = 'yes') { + const f = createTruthMovementBoxFixture(); 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 }); + game = act(game, f.target, { type: 'truthAnswer', answer }); + if (game.pendingTreacheryDiscard?.reaction) { + const event = game.pendingTreacheryDiscard.batch.event; + for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event }); + } + return { f, game }; +} +void test('persistent Box guidance pays once then submits the original required paid selection without hiding other faces', () => { + const { f, game: source } = bound(), actions: Action[] = []; + const submit = (action: Action) => { actions.push(action); }; + const before = { game: viewGame(source, f.target), act: submit, busy: false }; + assert.match(renderToStaticMarkup(createElement(MovementPromises, before)), /Pay 2 spice and search with Nullentropy Box/); + clickables(MovementPromises({ ...before, busy: true }))[0].onClick(); assert.equal(actions.length, 0); + clickables(MovementPromises(before))[0].onClick(); assert.deepEqual(actions.pop(), { type: 'card', card: f.box }); + const game = act(source, f.target, { type: 'card', card: f.box }), view = viewGame(game, f.target); + const paid = { game: view, act: submit, busy: false }; + const html = renderToStaticMarkup(createElement(MovementPromises, paid)); + assert.match(html, /Choose Hajr in your paid Box search/); assert.match(html, /fee is paid only once/); + assert.doesNotMatch(html, /Pay 2 spice and search with/); + const inspection = renderToStaticMarkup(createElement(NullentropySearch, paid)); + assert.match(inspection, /Take Hajr and finish search/); assert.match(inspection, /Take Truthtrance and finish search/); + clickables(MovementPromises({ ...paid, busy: true }))[0].onClick(); assert.equal(actions.length, 0); + clickables(MovementPromises(paid))[0].onClick(); + assert.deepEqual(actions, [{ type: 'decision', event: view.nullentropy!.search!.event, card: f.hajr }]); + const next = act(game, f.target, actions[0]); assert.equal(next.pendingNullentropy, null); + assert.equal(next.players.find(p => p.id === f.target)!.spice, 0); + assert.equal(MovementPromises({ ...paid, game: viewGame(game, f.asker) }), null); +}); +void test('No remains readable during a paid search without an invalid End movement button', () => { + const { f, game: source } = bound('no'), game = act(source, f.target, { type: 'card', card: f.box }); + const props = { game: viewGame(game, f.target), act() { assert.fail('Rendering cannot submit.'); }, busy: false }; + const html = renderToStaticMarkup(createElement(MovementPromises, props)); + assert.match(html, /No: you must not/); assert.match(html, /paid Box search must finish before moving/); + assert.equal(clickables(MovementPromises(props)).length, 0); + assert.match(renderToStaticMarkup(createElement(NullentropySearch, props)), /Take Hajr and finish search/); +}); diff --git a/tests/truth-movement-preparation-engine.test.ts b/tests/truth-movement-preparation-engine.test.ts new file mode 100644 index 00000000..ca636ab6 --- /dev/null +++ b/tests/truth-movement-preparation-engine.test.ts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { TERRITORIES, gameDistance, splitLocation } from '../game/board'; +import { createTruthMovementChoamFixture } from './fixture-truth-movement-choam'; +import { viewGame, normalizeAutomaticGame, type Game } from '../game/engine'; +import { createTruthMovementBoxFixture, actTruthMovementBox as act } from './fixture-truth-movement-box'; + +function ask(game: Game, asker: string, card: string, question: Record) { + game = act(game, asker, { type: 'card', card }); + for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' }); + return act(game, asker, { type: 'truthAsk', question }); +} +void test('actual owned paid Box recovery makes a third Basic movement promise feasible', () => { + const f = createTruthMovementBoxFixture(); + const game = ask(f.game, f.asker, f.truth, f.claim); + assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes')); +}); + +function passDiscard(game: Game): Game { + if (!game.pendingTreacheryDiscard?.reaction) return game; + const event = game.pendingTreacheryDiscard.batch.event; + for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event }); + return game; +} +function bindBox(answer: 'yes' | 'no' = 'yes') { + const f = createTruthMovementBoxFixture(); + let game = ask(f.game, f.asker, f.truth, f.claim); + game = act(game, f.target, { type: 'truthAnswer', answer }); + game = passDiscard(game); return { f, game }; +} +void test('the actual paid search selects required Hajr once and finishes the third move without a second fee', () => { + const { f, game: bound } = bindBox(); let game = bound; + const before = structuredClone(game), first = viewGame(game, f.target).movementPromisePolicy?.[0]; + assert.deepEqual(first, { type: 'card', card: f.box }); + assert.deepEqual(game, before, 'Private feasibility cannot pay or shuffle the live game'); + game = act(game, f.target, first!); + assert.equal(game.players.find(p => p.id === f.target)!.spice, 0); + const paid = viewGame(game, f.target).nullentropy!.search!; + assert.ok(paid.cards.some(card => card.id === f.hajr)); assert.ok(paid.cards.some(card => card.id === f.truth)); + const required = viewGame(game, f.target).movementPromisePolicy?.[0]; + assert.deepEqual(required, { type: 'decision', event: paid.event, card: f.hajr }); + const snapshot = structuredClone(game); + assert.throws(() => act(game, f.target, { type: 'decision', event: paid.event, card: f.truth })); + assert.deepEqual(game, snapshot, 'Rejected selection keeps the paid event and fee'); + assert.throws(() => act(game, f.target, { type: 'decision', event: 'stale', card: f.hajr })); + game = act(game, f.target, required!); game = passDiscard(game); + assert.equal(game.pendingNullentropy, null); + const hajr = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.deepEqual(hajr, { type: 'card', card: f.hajr }); + game = act(game, f.target, hajr!); game = passDiscard(game); + 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.equal(game.players.find(p => p.id === f.target)!.spice, 0); + assert.equal(game.players.find(p => p.id === f.target)!.hajrPlays, 2); + assert.equal(game.discard.filter(card => card.id === f.box).length, 1); + assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1); +}); + +void test('a No-only paid search retains its full original choice and never proposes End movement while searching', () => { + const { f, game: bound } = bindBox('no'); let game = act(bound, f.target, { type: 'card', card: f.box }); + assert.equal(viewGame(game, f.target).movementPromisePolicy, null); + const search = viewGame(game, f.target).nullentropy!.search!; + assert.ok(search.cards.some(card => card.id === f.truth)); + game = act(game, f.target, { type: 'decision', event: search.event, card: f.truth }); + game = passDiscard(game); + assert.equal(game.movementPromises![0].fulfilled, undefined); + game = act(game, f.target, { type: 'endMovement' }); + assert.equal(game.movementPromises![0].fulfilled, true); + assert.equal(game.players.find(p => p.id === f.target)!.spice, 0); +}); +void test('a voluntary bribe cannot spend the Box fee reserved by the only remaining Yes route', () => { + const { f, game } = bindBox(); const before = structuredClone(game); + const view = viewGame(game, f.target); + assert.equal(view.bribeOptions.targets.find(target => target.id === f.asker)!.maximum, 0); + assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: 1 })); + assert.deepEqual(game, before); +}); +void test('controlled full-hand recovery keeps the approved atomic Box exchange and original paid event', () => { + const f = createTruthMovementBoxFixture(); + const source = structuredClone(f.game), owner = source.players.find(p => p.id === f.target)!; + while (owner.hand.length < 4) owner.hand.push(source.deck.pop()!); + let game = ask(source, f.asker, f.truth, f.claim); + assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes')); + game = passDiscard(act(game, f.target, { type: 'truthAnswer', answer: 'yes' })); + game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]); + const search = viewGame(game, f.target).nullentropy!.search!; + game = act(game, f.target, { type: 'decision', event: search.event, card: f.hajr }); + assert.equal(game.players.find(p => p.id === f.target)!.hand.length, 4); + assert.equal(game.players.find(p => p.id === f.target)!.spice, 0); +}); + +void test('controlled legacy certainty cannot inspect hidden Box contents before payment', () => { + const f = createTruthMovementBoxFixture(), unknown = structuredClone(f.game); + delete unknown.movementDiscardKnowledge; + const changed = structuredClone(unknown), index = changed.discard.findIndex(card => card.id === f.hajr); + const alternate = changed.deck.findIndex(card => card.kind === 'shield'); assert.ok(alternate >= 0); + [changed.discard[index], changed.deck[alternate]] = [changed.deck[alternate], changed.discard[index]]; + for (const source of [unknown, changed]) { + const game = ask(source, f.asker, f.truth, f.claim), view = viewGame(game, f.target); + assert.deepEqual(view.truthMovementAnswers, ['no']); + assert.equal(Object.hasOwn(view, 'movementDiscardKnowledge'), false); + assert.equal(view.nullentropy?.search, null); + } +}); +void test('a controlled opposing loss of the fee releases the Box-dependent Yes while keeping its actual pieces', () => { + const { f, game: bound } = bindBox(); const harmed = structuredClone(bound); + harmed.players.find(p => p.id === f.target)!.spice--; + harmed.players.find(p => p.id === f.asker)!.spice++; + const game = normalizeAutomaticGame(harmed); + assert.equal(game.movementPromises![0].released, true); + assert.ok(game.players.find(p => p.id === f.target)!.hand.some(card => card.id === f.box)); + assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1); +}); +function farChoamDestination(game: Game) { + 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); return destination; +} +void test('a genuinely held Kulon supplies its original native preparation before a promised four-step move', () => { + const f = createTruthMovementChoamFixture(), source = f.beforeKulon, destination = farChoamDestination(source); + let game = ask(source, f.asker, f.truth, { kind: 'movement', target: f.target, territory: destination.territory, minimum: 1 }); + assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes')); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'card', mode: 'choam', card: f.kulon }]); + game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]); + assert.equal(game.choamMovement?.bonus, 1); + assert.ok(game.movementDiscardKnowledge?.[f.asker].includes(f.kulon), 'Public direct Kulon disposal establishes current-pile certainty'); + game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]); + assert.equal(game.movementPromises![0].fulfilled, true); +}); +void test('a controlled real Karama counter keeps Kulon held/blocked and releases the impossible promised route', () => { + const f = createTruthMovementChoamFixture(), source = structuredClone(f.beforeKulon), destination = farChoamDestination(source); + const karama = source.deck.splice(source.deck.findIndex(card => card.effect === 'karama'), 1)[0]; assert.ok(karama); + source.players.find(p => p.id === f.asker)!.hand.push(karama); + let game = ask(source, f.asker, f.truth, { kind: 'movement', target: f.target, territory: destination.territory, minimum: 1 }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]); + assert.equal(game.response?.kind, 'choamWorthless'); + assert.equal(game.movementPromises![0].released, undefined, 'A pending opposing choice does not release the answer'); + game = act(game, f.asker, { type: 'card', card: karama.id, mode: 'cancel' }); + assert.equal(game.movementPromises![0].released, true); + assert.ok(game.players.find(p => p.id === f.target)!.hand.some(card => card.id === f.kulon)); + assert.equal(game.choamMovement, undefined); +}); +void test('all four minimal profiles select the required original paid event and complete real Box recovery', () => { + const { f, game: bound } = bindBox(); + for (const difficulty of DIFFICULTIES) { + let game = act(bound, f.target, { type: 'card', card: f.box }), count = 0; + while (game.phase === 5 && game.active === f.target && count++ < 20) { + if (game.pendingTreacheryDiscard) { game = passDiscard(game); continue; } + const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty; + const action = botActions(view)[0]; assert.ok(action); + if (game.pendingNullentropy) assert.deepEqual(action, { type: 'decision', event: game.pendingNullentropy.event, card: f.hajr }); + game = act(game, f.target, action); + } + assert.ok(count < 20); assert.equal(game.movementPromises![0].fulfilled, true); + assert.equal(game.players.find(p => p.id === f.target)!.spice, 0); + } +});