From c311ed42519bbc19d81655a82a2363da64d36ccd Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:05:41 +1100 Subject: [PATCH] Support native early Basic movement promises Price native Once Around and Silent funding in early movement forecasts, retaining full sealed donor locks and exact paid-sale boundaries. Add current zero/pass guidance, fixed non-CHOAM preopening Charity entitlement and shared owned normal-revival projection while actual counters remain pending. Preserve the mandatory Ix phase-opening continuation. Validation: affected rules/control tests, types/lint, production build and six gameplay/control/table-rendering programmes with eight existing-profile native runs. Wider preparations, CHOAM own unresolved opening, modules/Advanced, full rules and deployed acceptance remain open. No saved-room, database, restart or deployment operation. --- components/movement-promises.tsx | 3 +- components/truthtrance.tsx | 4 +- docs/CURRENT_STATUS.md | 22 +++ docs/IMPLEMENTATION_STATUS.md | 28 +++ docs/RULE_DECISIONS.md | 2 +- docs/TRUTHTRANCE_MOVEMENT_PROMISES.md | 86 +++++++- game/early-auction-funding.ts | 34 ++++ game/engine.ts | 112 ++++++++--- game/movement-promises.ts | 4 - game/reference.ts | 14 +- game/truthtrance.ts | 2 +- tests/early-auction-funding.test.ts | 57 ++++++ tests/fixture-truth-movement-native-early.ts | 187 ++++++++++++++++++ tests/movement-promises.test.ts | 4 +- ...th-movement-native-early-controls.test.tsx | 95 +++++++++ ...truth-movement-native-early-engine.test.ts | 187 ++++++++++++++++++ 16 files changed, 787 insertions(+), 54 deletions(-) create mode 100644 game/early-auction-funding.ts create mode 100644 tests/early-auction-funding.test.ts create mode 100644 tests/fixture-truth-movement-native-early.ts create mode 100644 tests/truth-movement-native-early-controls.test.tsx create mode 100644 tests/truth-movement-native-early-engine.test.ts diff --git a/components/movement-promises.tsx b/components/movement-promises.tsx index fd52530b..309c5688 100644 --- a/components/movement-promises.tsx +++ b/components/movement-promises.tsx @@ -12,7 +12,7 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac const next = game.phase >= 5 || paidDecision || (game.phase === 2 && ['charity', 'ready'].includes(proposed?.type ?? '')) || (game.phase === 4 && ['revive', 'ready'].includes(proposed?.type ?? '')) || - (game.phase === 3 && proposed?.type === 'passBid') ? proposed : undefined; + (game.phase === 3 && ['passBid', 'richeseBid'].includes(proposed?.type ?? '')) ? proposed : undefined; const amount = next?.forces && typeof next.forces === 'object' ? Object.values(next.forces as Record).reduce((sum, count) => sum + count, 0) : typeof next?.amount === 'number' ? next.amount : 0; const destination = next && game.combatLocations.find(location => location.id === next.territory)?.name; @@ -27,6 +27,7 @@ export function MovementPromises({ game, act, busy }: { game: GameView; act: (ac : next?.type === 'revive' ? `Revive ${amount} forces for your later movement` : next?.type === 'ready' ? 'Finish this preparation phase while preserving your promises' : next?.type === 'passBid' ? 'Pass this bid and preserve later movement' + : next?.type === 'richeseBid' ? next.amount === 0 ? 'Submit zero in this Silent auction and preserve later movement' : 'Pass this Richese bid and preserve later movement' : next?.type === 'card' && next.mode === 'shipment' ? `Use ${card?.name ?? 'your card'} for Guild shipment rates` : 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' diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 3fe4071a..0a07b42c 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -349,8 +349,8 @@ export function Truthtrance({ <>

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

- {g.phase < 5 &&

Movement comes later this round. Keep the forces, spice and cards needed by your answer; available Charity or Revival may prepare the route. Current preparation appears when it is available. Earlier-phase questions currently support classic factions.

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

Choose a supported Basic respondent this round and finish pending decisions. Earlier phases use classic factions; native questions currently begin during Movement. Optional-module and Advanced movement promises remain unfinished.

} + {g.phase < 5 &&

Movement comes later this round. Keep the forces, spice and cards needed by your answer; available Charity or Revival may prepare the route. Current auction commitments reserve their actual funding. Current preparation appears when it is available.

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

Choose a supported Basic respondent this round and finish pending decisions. Optional-module and Advanced movement promises remain unfinished.

}

{truthQuestionText(question, leaderName, locationName)}

) : kind === 'shipment' || kind === 'mixedShipment' ? ( diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 5523bb50..61ab9e6c 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,28 @@ open mode gates. ## Current checkpoint and work +**Native early Basic movement promises — 24 October. Prototyped / Partial.** +Earlier questions now include declared native families. Richese own/donor +funding retains exact unpaid invoices, full submitted-ally Silent pledge locks +and already-paid sale boundaries. Current native controls pass or submit zero +without exposing future movement. Non-CHOAM preopening Charity quotes its fixed +entitlement only; ordinary owned revival shares physical accounting while real +native counters remain pending. The mandatory Ix Movement opening retains the +future turn instead of vetoing preceding Ready. + +Disjoint source/control lanes, bounded funding review and owner integration pass +**29 files244/244**, types/lint/build and six gameplay/control/table-rendering +programmes. Original E2/E1/E3 Charity prefixes25/36/25 retain actual setup/cards +and Emperor10 spice/20 reserves; real first/last Once Around/Silent invoice2, +shipment8 and ground8 fulfill Yes. Eight profile runs make56 accepted proposals +plus148 separate native continuations. Controlled escrow, original-card/prior +loss and allowed/canceled Charity/revival cases disclose staging. The first +integrated run exposed the Ix opening boundary; its narrow repair passes before +the final frozen union. [Source, exact proof and limits](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-early-basic-funding--24-october-2026). +CHOAM's unresolved own opening, wider preparations, later rounds, modules/ +Advanced, full modes, comprehensive assurance and deployed acceptance remain +unfinished. No strategy tuning or saved-room/D1/reset/restart/deployment operation. + **Earlier classic Basic movement promises — 24 October. Prototyped / Partial.** Clean current-round phases0–4 now include owned Charity, ordinary auction payment and Revival before later ground movement. Reopen readiness, reserve diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 1cbd65a3..bcd73df3 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,33 @@ # Dune implementation status +## 24 October 2026 — Native early Basic movement promises + +Extend earlier questions to declared native families using actual Richese +own/donor funding and current paid-sale boundaries. Keep a submitted Silent +ally's whole pledge locked until reveal; exact private splits do not leak through +the forecast. Native current controls pass or submit zero even while a foreign +invoice temporarily prevents a witness. Positive own bids/gifts preserve the +route; actual opposing settlement may release it. Quote only non-CHOAM fixed +preopening Charity, with no fictional payer receipt or future native income. +Owned ordinary revival uses shared force accounting while native counters stay +pending. The mandatory Ix phase5 opening retains the owned future turn/reset. + +Disjoint source/control lanes, bounded funding review and owner integration pass +29 files244/244, types/lint/build and six gameplay/control/table-rendering +programmes. Original E2/E1/E3 Charity prefixes25/36/25 retain actual native setup +and cards, including Ixian Shield selection. Emperor retains10 spice/20 reserves; +actual first/last Once Around/Silent cache purchases2, shipment8 and ground8 +fulfill Yes. Eight profile runs make56 accepted proposals plus148 separate native +continuations. Controlled escrow, original-card acquisition/prior losses and +allowed/canceled Charity/revival cases disclose staging. Source feedback repaired +Silent actor selection and a nonexistent setup field; engine feedback repaired +owned ordinary revival projection and the mandatory Ix phase-opening gap. The +final frozen batch passes after the initially failing opening regression. +[Authority, exact source/proof and unfinished scope](TRUTHTRANCE_MOVEMENT_PROMISES.md#native-early-basic-funding--24-october-2026). +Unresolved CHOAM own opening, wider preparations, later rounds, optional/Advanced +and complete modes remain unfinished. No full AI refinement, comprehensive +assurance or saved-room/D1/reset/restart/deployment operation. + ## 24 October 2026 — Earlier classic Basic movement promises Extend current-round questions to clean classic Basic phases0–4. Owned Charity, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index cd82bc54..1faae82e 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/current-round rule with one future qualifying physical ground movement before the respondent's actual own turn ends. Classic Basic phases0–4 include owned Charity/ordinary Revival and a currently leading ordinary invoice, reopening Ready and preserving joint shipment predicates; current-phase guidance only. Actual cash/held Karama and a distinct later shipment-rate card retain their costs; mandatory Storm losses release impossibility and stale movement allowances reset. Native early auction liabilities stay unfinished. Active or unused waiting turns may answer; forecast current board/resources without foreign outcomes, and defer ordinary execution until active. Other players' moves/ends cannot settle this ledger. Actual paid Box selection remains actionable while waiting and may choose any entitled card preserving the route. Deferred gift/accompaniment costs retain owner preflight; native pending moves defer only their own moving player. Ordinary reserve preparation retains prices, typed withdrawals and pledged splits; joint answers and exact pending declarations remain. Yes survives nonmatches while possible; No forbids later matches through Hajr/Ornithopter until actual closure. Commit before arrival/disposal, preserve native controls and compatible answers after opposing loss. Held Kulon and known-discard Box retain fee/event/full-hand policy; no unknown inspection or erased historical memory. Shipment/HMS/Ambassador relocation and No-Field markers do not fulfill physical ground movement. Predicate/release/forecast policy is software composition, not a movement-specific publisher FAQ. Earlier native phases/later rounds, native transport preparation, wider/module combinations and full acceptance remain unfinished. | [Authority, behavior and bounded evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md) | +| Truthtrance / Basic ground movement | Compose the general FAQ binding-while-possible/current-round rule with one future qualifying physical ground movement before the respondent's actual own turn ends. Classic Basic phases0–4 include owned Charity/ordinary Revival and a currently leading ordinary invoice, reopening Ready and preserving joint shipment predicates; current-phase guidance only. Actual cash/held Karama and a distinct later shipment-rate card retain their costs; mandatory Storm losses release impossibility and stale movement allowances reset. Declared native early entries also price exact Richese own/donor commitments, lock the full submitted-ally Silent pledge, avoid double-debiting paid sales and retain current event-bearing zero/pass controls. Foreign invoice outcomes precede release. Non-CHOAM preopening Charity quotes its fixed entitlement only; owned normal revival projects shared physical accounting while native counters/income stay unresolved. CHOAM own unsettled opening remains outside the forecast. Active or unused waiting turns may answer; forecast current board/resources without foreign outcomes, and defer ordinary execution until active. Other players' moves/ends cannot settle this ledger. Actual paid Box selection remains actionable while waiting and may choose any entitled card preserving the route. Deferred gift/accompaniment costs retain owner preflight; native pending moves defer only their own moving player. Ordinary reserve preparation retains prices, typed withdrawals and pledged splits; joint answers and exact pending declarations remain. Yes survives nonmatches while possible; No forbids later matches through Hajr/Ornithopter until actual closure. Commit before arrival/disposal, preserve native controls and compatible answers after opposing loss. Held Kulon and known-discard Box retain fee/event/full-hand policy; no unknown inspection or erased historical memory. Shipment/HMS/Ambassador relocation and No-Field markers do not fulfill physical ground movement. Predicate/release/forecast policy is software composition, not a movement-specific publisher FAQ. Later rounds, native transport preparation, wider/module combinations and full acceptance remain unfinished. | [Authority, behavior and bounded evidence](TRUTHTRANCE_MOVEMENT_PROMISES.md) | | Semuta / native Basic entries | Fresh bare public Basic Richese previews initialize the existing protocol before genuine setup with selected native E1/E2/E3 families; no optional/Advanced inheritance or old-game retrofit. Clean source-witnessed shipment/movement CHOAM and Ixian Ambassador private discards, including BG copies, park the original entry: keep CHOAM's committed credit and draw the Ixian replacement only after the reaction. This composes printed component rules, not a new timing FAQ. Existing Basic Terror overflow retains the neutral all-seat response, committed-only face access, original victim/draw, removed token, payment, forces and optional gift. Nested parents, completed movement-card provenance and wider module/full-game coverage remain unfinished implementation boundaries, not printed Semuta prohibitions. | [Fresh Basic and Ambassador scope](SEMUTA_SOURCE_UPDATE_20260907.md#fresh-basic-starts-and-ambassador-discards--24-october-2026), [native Terror suffix](TERROR_DISCARD_CONTINUATIONS.md) | Shipment and mixed current-fact/shipment AND/OR follow the FAQ's whole-question diff --git a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md index 5dc72238..eb2380d5 100644 --- a/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md +++ b/docs/TRUTHTRANCE_MOVEMENT_PROMISES.md @@ -1,6 +1,6 @@ # Truthtrance ground-movement promises -The [earlier classic Basic follow-up](#earlier-classic-basic-phases--24-october-2026) +The [native early Basic follow-up](#native-early-basic-funding--24-october-2026) supersedes preceding timing boundaries. Previous checkpoint counts remain dated evidence, not complete rules or final acceptance. @@ -21,14 +21,14 @@ 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. -Classic Basic tables can also ask in clean earlier phases0–4 of the current -round, with the owned preparations below. Fresh or existing Basic native tables -can ask at a clean **active or still-unused +Basic tables with the declared native families can also ask in clean earlier +phases0–4 of the current round, with the owned preparations below. During +Movement, ask at a clean **active or still-unused waiting turn in the current Shipment and Movement queue**, before or after the active respondent's shipment, including selected E1/E2/E3 factions. No optional modules or Advanced inherit this entry. The actual native roster, physical inventory and route -validation remain authoritative; no mode or publication gate opens. Earlier -phases with native factions, later-round movement questions, compound movement/fact questions and wider +validation remain authoritative; no mode or publication gate opens. +Later-round movement questions, compound movement/fact questions and wider owned preparations/interactions remain unfinished. Existing saves are not reconfigured and absent promise fields remain absent until a real answer binds. @@ -62,9 +62,81 @@ 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 native phases/later rounds, optional/Advanced profiles and comprehensive +later rounds, optional/Advanced profiles and comprehensive combination/recovery/privacy/custody acceptance remain due. +## Native early Basic funding — 24 October 2026 + +Clean earlier Basic entry now includes declared E1/E2/E3 native factions without +optional modules. The existing [native auction contract](RICHESE_AUCTION_RULES.md) +and `richeseOwnCommitment` / `richeseAllyCommitment` price real pending invoices. +Subtract the current leading Once Around offer or an owned submitted Silent +split; losing and unsubmitted offers spend nothing. Native cache invoices have +no ordinary Karama payment alternative. A current paid sale is not charged +again. Clear only the detached auction/funding controls before the phase5 +forecast, so reserved own spice is not deducted twice. No unknown purchased +card or future seller/Emperor income supplies a route. + +During unfinished Silent bidding, a submitted ally locks the **entire outgoing +pledge**, using public submission status rather than exposing its private split +through feasibility. Only the actual revealed winner determines the final +donated cost/refund. Foreign auction/income controls finish before release. +Positive owner bids and card gifts cannot voluntarily erase the route. Current +guidance passes a live Once Around bid or submits Silent zero with its original +event; it does so even while foreign escrow temporarily prevents a witness. +A zero/pass is not a voluntary promise-breaking spend. Original cache choices, +native setup and post-auction decisions retain their own controls. +The mandatory Ix-enabled phase5 opening is also a forecast boundary: retain the +owned future turn with its normal reset until real opening readiness initializes +Movement. It cannot veto the owner's preceding Ready or expose movement early. + +Before CHOAM's opening response, another faction may quote its fixed ordinary +Charity entitlement using current Inflation. This detached credit creates no +CHOAM income, payment or fictional receipt; the actual phase2 claim still waits +for the real allowed/canceled payer receipt and affordability. CHOAM's own +unresolved opening income/claim is not precredited and remains a wider forecast +boundary. A declared owned ordinary force revival projects only its quoted +allowed physical return/cost through the same accounting as real revival. +CHOAM/Tleilaxu decisions, benefit checks and income remain real unresolved +controls; no quote executes their choices or awards a future receipt. A real +denial can release impossibility, or leave an affordable paid route intact. +This continues the explicit provisional owned-outcome policy above, not a new +publisher Truthtrance clarification. + +`tests/fixture-truth-movement-native-early.ts` supplies genuine original public +Basic Atreides/Emperor plus Richese/CHOAM, Ixians/Tleilaxu or Ecaz/Moritani. +Actual Ixian card selection/mobile placement and Ecaz/Moritani placement retain +their original counters; Atreides genuinely receives Truth and Emperor Baliset, +ten spice and twenty reserves. Actual questions, native first/last Once Around +or Silent invoices, shipment8 and ground8 are caller actions. Controlled prior +losses, original-card transfers, alliance escrow and preopening Charity cases +are separately disclosed. + +The final **29-file union passes244/244**, types/lint and production build. +Six actual gameplay/guidance-handler/GameTable programmes cover first/last +Once Around/Silent cache purchase2, shipment8 and ground8, plus original E1/E3 +early continuations. E2/E1/E3 source prefixes have25/36/25 actual actions. Eight +existing-profile runs make56 accepted guided proposals plus148 separate native +continuations, without tuning. Actual native zero/pass handlers and busy +protection also pass their two original-source control tests. Controlled tests +retain a real pending free revival before CHOAM's decision, then distinguish an +unaffordable denial from a retained paid route. Source corrections use actual +Silent submission status and Ix/Ecaz setup fields. Initial integrated feedback +exposed the Ix Movement opening; the narrow repair and final frozen union pass. + +Frozen base `f995b6f4cd9008954893f2d646ecb861986a43de`, tree +`5c366c0cd7200c61e4461ac148a3a15a98f53ef1dec5fd661d425cced6c1f675`, +2653 files. Private source-bound logs, actual-state HTML and receipts: +`/home/quintine/.local/share/dune/checkpoints/20261024-movement-native-early/`, +`frozen-integrated-2/report.json`. Only final checkpoint/reference prose overlays +follow proof. Framework aliases/CSS stubbing establish server rendering, not +browser/visual/deployed acceptance. No saved-room/D1/reset/restart/deployment. + +Wider owned preparations, CHOAM's unresolved own opening, later rounds, +optional/Advanced and mixed questions, full modes, comprehensive assurance and +browser/deployed acceptance remain unfinished. Native shipment-promise entry +is unchanged; this does not certify all promise families or faction rules. + ## Earlier classic Basic phases — 24 October 2026 The current-round question now includes classic Basic Storm, Spice Blow, diff --git a/game/early-auction-funding.ts b/game/early-auction-funding.ts new file mode 100644 index 00000000..46f1d5e4 --- /dev/null +++ b/game/early-auction-funding.ts @@ -0,0 +1,34 @@ +import type { RicheseAuction } from './richese-auction'; +import { richeseOwnCommitment, richeseAllyCommitment, type RicheseFunding } from './richese-funding'; + +export type EarlyAuctionFunding = { + phase: number; + owner: string; + ally: string | null; + paid: boolean; + normal: { bidder: string | null; bid: number; allyPayment?: number } | null; + native?: RicheseAuction | null; + funding?: RicheseFunding; + pledge: number; +}; + +/** Private owned-stage funding, not settlement or a prediction of who wins. + * Silent donor locks use submission status, never the ally's private split. + * A current sale already paid its invoice and consumed any donated escrow. */ +export function earlyAuctionFunding(input: EarlyAuctionFunding) { + const { owner, ally, pledge } = input; + const normal = input.phase === 3 && !input.paid ? input.normal : null; + const native = input.phase === 3 && !input.paid ? input.native : null; + const funding = input.funding ?? {}; + const normalCash = normal?.bidder === owner ? normal.bid - (normal.allyPayment ?? 0) : 0; + const nativeCash = richeseOwnCommitment(native, funding, owner); + const normalDonor = ally && normal?.bidder === ally ? normal.allyPayment ?? 0 : 0; + const sealedLock = native?.method === 'silent' && !native.outcome && ally && + Object.hasOwn(native.sealed, ally); + const nativeDonor = sealedLock ? pledge : richeseAllyCommitment(native, funding, owner); + const refund = pledge - normalDonor - nativeDonor; + for (const amount of [normalCash, nativeCash, refund]) + if (!Number.isSafeInteger(amount) || amount < 0) + throw new RangeError('The current auction funding exceeds its owned cash or pledged escrow.'); + return { normalCash, nativeCash, refund }; +} diff --git a/game/engine.ts b/game/engine.ts index 62c6c6b3..99472475 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,4 @@ +import { earlyAuctionFunding } from './early-auction-funding'; import { recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, recordMovementClaimKnowledge, clearMovementDiscardKnowledge, type MovementDiscardKnowledge } from './movement-discard-knowledge'; import { movementShipmentPreparations } from './movement-shipment-preparation'; import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseModeSupported, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises'; @@ -16434,6 +16435,10 @@ function recoverableUnsupportedShipment(shipment: PendingShipment) { function groundMovementProbe(source: Game, actor: string): Game { const game = structuredClone(source); projectPendingShipmentKarama(game, actor); + if (game.phase < 5) { + if (game.pendingRevival?.player === actor) projectOwnedOrdinaryRevival(game, actor); + else game.pendingRevival = null; + } if (game.active !== actor) { // Forecast only this owner's unused turn on the current board. Do not // complete a foreign declaration, price, flight or private search, and do @@ -16506,6 +16511,10 @@ function groundMovementCompletion(source: Game, actor: string, promises = liveMovementPromises(source.movementPromises ?? [], actor, source.turn)): MovementCompletion | null { if (!movementPromiseModeSupported(source) || source.phase < 0 || source.phase > 5) return null; const game = groundMovementProbe(source, actor); + if (source.phase === 5 && source.phaseOpening?.initialize) { + resetGroundMovementForecast(game, actor); + return groundMovementCompletionAtMovement(game, actor, promises); + } if (source.phase < 5) { const paid = source.pendingNullentropy; if (paid?.player === actor) { @@ -16519,10 +16528,7 @@ function groundMovementCompletion(source: Game, actor: string, return null; } for (const seed of earlyShipmentCompletionSeeds(game, source, actor)) { - resetPlayerMovement(getPlayer(seed.game, actor)); - seed.game.hajr = seed.game.hajr.filter(player => player !== actor); - seed.game.ornithopter = null; - seed.game.movementRemaining = [actor]; + resetGroundMovementForecast(seed.game, actor); const completion = groundMovementCompletionAtMovement(seed.game, actor, promises); if (completion) return { actions: [...seed.actions, ...completion.actions], early: seed.early }; } @@ -16530,6 +16536,12 @@ function groundMovementCompletion(source: Game, actor: string, } return groundMovementCompletionAtMovement(game, actor, promises); } +function resetGroundMovementForecast(game: Game, actor: string) { + resetPlayerMovement(getPlayer(game, actor)); + game.hajr = game.hajr.filter(player => player !== actor); + game.ornithopter = null; + game.movementRemaining = [actor]; +} function groundMovementCompletionAtMovement(game: Game, actor: string, promises: readonly MovementPromise[]): MovementCompletion | null { if (!movementPromiseTargetAvailable(game.pendingNullentropy?.player === actor ? { ...game, decision: null } : game, actor)) return null; return findMovementCompletion(game, actor, promises, { @@ -16617,12 +16629,21 @@ function earlyGroundMovementPolicy(g: Game, player: Player): Action[] | null { if (g.status !== 'playing' || !movementPromiseModeSupported(g) || !live.some(promise => promise.answer) || g.truthtrance || g.response || g.phaseOpening || g.pendingTreacheryDiscard || (g.decision && !(g.decision.kind === 'auctionPayment' && g.decision.player === player.id))) return null; + // Passing cannot spend the reserved route. In particular, a foreign Silent + // invoice may still refund escrow: do not replace a legal zero with a bid + // merely because that unresolved reservation prevents a current witness. + if (g.phase === 3 && !g.decision) { + const lot = g.richeseAuction; + if (lot) return !lot.outcome && lot.eligible.includes(player.id) && + (lot.method === 'silent' ? !Object.hasOwn(lot.sealed, player.id) : lot.active === player.id) + ? [{ type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null, allyPayment: 0 }] : []; + return g.auction?.active === player.id ? [{ type: 'passBid' }] : []; + } const completion = groundMovementCompletion(g, player.id); if (!completion) return null; const current = completion.early?.find(step => step.phase === g.phase)?.action; if (g.decision?.kind === 'auctionPayment') return [current?.type === 'decision' ? current : { type: 'decision', karama: false }]; - if (g.phase === 3) return g.auction?.active === player.id ? [{ type: 'passBid' }] : []; if (g.phase === 2 || g.phase === 4) return current ? [current] : g.ready.includes(player.id) ? [] : [{ type: 'ready' }]; return null; } @@ -16673,11 +16694,14 @@ function reconcileGroundMovementPromises(g: Game, actor?: string, action?: Actio g.pendingChoamMove?.player === p.id) continue; const early = g.phase < 5 || sourcePhase < 5; const voluntary = actor === p.id && (!early || !!action && - (['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) || + (['card', 'bribe', 'pledgeAid', 'emperorGift', 'bid', 'richeseGift', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival', 'decision'].includes(action.type) || + action.type === 'richeseBid' && typeof action.amount === 'number' && action.amount > 0 || action.type === 'ready' && [2, 4].includes(sourcePhase))); const busy = !!(g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard || g.pendingAmbassador || g.pendingTerrorEntry || g.pendingArrivalOverlap || g.pendingArrivalReaction?.length || - g.phase === 3 && !g.currentAuctionSale && g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0); + g.phase === 3 && !g.currentAuctionSale && + (g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 || + g.richeseAuction && !g.richeseAuction.outcome)); if (busy && !voluntary) continue; if (groundMovementCompletion(g, p.id, live)) continue; requireRule(!voluntary, 'Your action would remove the last legal completion of an accepted ground-movement promise.'); @@ -23341,7 +23365,14 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string if (p.charityTurn === g.turn || charityQuote(g, p).total <= 0) continue; try { const action: Action = { type: 'charity' }; - const next = applyActionInner(g, owner, action, 'shipmentPreparation'); + // Before CHOAM's opening response, another faction's fixed ordinary + // entitlement is unchanged by its outcome. Quote only that entitlement: + // no CHOAM income, payment or invented receipt enters this forecast. + let next: Game; + if (source.phase < 2 && byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn) { + if (p.faction === 'choam') continue; + p.spice += charityQuote(g, p).total; p.charityTurn = g.turn; next = g; + } else next = applyActionInner(g, owner, action, 'shipmentPreparation'); charitySeeds.push({ game: next, actions: [action], early: [{ phase: 2, action }] }); } catch (error) { if (!(error instanceof RuleError)) throw error; } } @@ -23352,15 +23383,17 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string for (const seed of seeds) { const p = getPlayer(seed.game, owner), a = source.phase === 3 && !source.currentAuctionSale ? source.auction : null; const ownBid = a?.bidder === owner && a.bid > 0; - const cash = ownBid ? a.bid - (a.allyPayment ?? 0) : 0; - const committed = source.phase === 3 && source.currentAuctionSale ? 0 : committedOutgoingAid(source, getPlayer(source, owner)); - const refund = (source.aid[owner]?.amount ?? 0) - committed; + const funding = earlyAuctionFunding({ phase: source.phase, owner, ally: p.ally, + paid: !!source.currentAuctionSale, normal: source.auction, + native: source.richeseAuction, funding: source.richeseFunding, + pledge: source.aid[owner]?.amount ?? 0 }); + const cash = funding.normalCash; const alternatives: (string | null)[] = [null, ...(ownBid ? p.hand .filter(card => canUseAsKaramaRole(seed.game, p, card)).map(card => card.id) : [])]; for (const card of alternatives) { const g = structuredClone(seed.game), beneficiary = getPlayer(g, owner); - if (!card && beneficiary.spice < cash) continue; - beneficiary.spice += refund - (card ? 0 : cash); + if (beneficiary.spice < funding.nativeCash + (card ? 0 : cash)) continue; + beneficiary.spice += funding.refund - (card ? 0 : cash) - funding.nativeCash; if (!Number.isSafeInteger(beneficiary.spice) || beneficiary.spice < 0) continue; const actions = [...seed.actions], early = [...seed.early]; if (card) { @@ -23372,6 +23405,7 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string // Native Bidding refunds its unused pledges. An ally's incoming // Bidding escrow is never a promised future Shipment contribution. g.aid = {}; g.auction = null; g.currentAuctionSale = null; + g.richeseAuction = null; g.richeseFunding = {}; g.richeseBidding = null; fundingSeeds.push({ game: g, actions, early }); } } @@ -23391,6 +23425,7 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string try { const action: Action = { type: 'revive', amount }; const next = applyActionInner(g, owner, action, 'shipmentPreparation'); + projectOwnedOrdinaryRevival(next, owner); revivalSeeds.push({ game: next, actions: [...seed.actions, action], early: [...seed.early, { phase: 4, action }] }); } catch (error) { if (!(error instanceof RuleError)) throw error; } } @@ -23701,9 +23736,10 @@ function reconcileShipmentPromises( const future = g.phase < 5 || g.active !== p.id; const earlyVoluntary = (voluntary?.phase ?? g.phase) < 5 && voluntary && (['bid', 'revive', 'reviveLeader', 'reviveKwisatz', 'emperorRevival'].includes(voluntary.action.type) || + voluntary.action.type === 'richeseBid' && typeof voluntary.action.amount === 'number' && voluntary.action.amount > 0 || voluntary.action.type === 'ready' && [2, 4].includes(voluntary.phase ?? g.phase)); const ownVoluntary = voluntary?.actor === p.id && - (['card', 'bribe', 'pledgeAid', 'emperorGift'].includes(voluntary.action.type) || + (['card', 'bribe', 'pledgeAid', 'emperorGift', 'richeseGift'].includes(voluntary.action.type) || earlyVoluntary || future && voluntary.action.type === 'decision'); // A foreign pending parent may spend or refund its approved funding. Wait @@ -23711,7 +23747,9 @@ function reconcileShipmentPromises( if (future && !ownVoluntary && (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingShipment || g.pendingGuildTransport || g.pendingHomeworldShipment || g.pendingTreacheryDiscard || g.ornithopter || - g.phase === 3 && !g.currentAuctionSale && g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0)) { + g.phase === 3 && !g.currentAuctionSale && + (g.auction?.bidder === p.ally && (g.auction?.allyPayment ?? 0) > 0 || + g.richeseAuction && !g.richeseAuction.outcome))) { kept.push(promise); continue; } @@ -26490,6 +26528,35 @@ function currentRevivalResumeQuote(g: Game, stage: 'stop' | 'finish') { throw error; } } +/** Shared physical accounting after the authoritative revival quote. */ +function commitRevivalForcePieces(g: Game, p: Player, revival: PendingRevival) { + const n = revival.amount!, elite = revival.elite ?? 0; + addNativeReserves(g, p, n, elite); + p.tanks -= n; + if (revival.emperorExtra) + g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n; + else if (!revival.nexusEmperorEvent) { + // Keep unknown historical free usage unknown in both real and quoted paths. + if (p.freeForcesRevived !== undefined || p.revived === 0) + p.freeForcesRevived = (p.freeForcesRevived ?? 0) + revival.free; + p.revived += n; + } + if (p.elites) { p.elites.tanks -= elite; p.elites.revived += elite; } +} +/** Private allowed-outcome projection for the owner's declared ordinary force + * request. Native CHOAM/Tleilaxu controls remain real unresolved decisions. + * Do not execute their choices, income, technology or continuation effects. */ +function projectOwnedOrdinaryRevival(g: Game, owner: string) { + const pending = g.pendingRevival; + if (!pending || pending.player !== owner || pending.kind !== 'forces' || + pending.emperorExtra || pending.nexusEmperorEvent || pending.payer && pending.payer !== owner || g.homeworlds) return; + const quote = currentRevivalResumeQuote({ ...g, pendingRevival: { ...pending, checks: [] } }, 'finish'); + if (quote.outcome !== 'revive') return; + const p = getPlayer(g, owner); + p.spice -= quote.pending.cost; + commitRevivalForcePieces(g, p, quote.pending); + g.pendingRevival = null; g.decision = null; g.response = null; +} function finishRevival( g: Game, quote: RevivalResumeQuote = currentRevivalResumeQuote(g, 'finish'), @@ -26538,20 +26605,7 @@ function finishRevival( const source = revival.nexusEmperorEvent ? 'nexusEmperor' : revival.emperorExtra ? 'emperorExtra' : 'normal'; const group = { amount: n, elite, free: revival.free }; - addNativeReserves(g, p, n, elite); - p.tanks -= n; - if (revival.emperorExtra) - g.emperorExtra[p.id] = (g.emperorExtra[p.id] ?? 0) + n; - else if (!revival.nexusEmperorEvent) { - // Preserve unknown legacy usage instead of guessing how earlier paid returns were priced. - if (p.freeForcesRevived !== undefined || p.revived === 0) - p.freeForcesRevived = (p.freeForcesRevived ?? 0) + revival.free; - p.revived += n; - } - if (p.elites) { - p.elites.tanks -= elite; - p.elites.revived += elite; - } + commitRevivalForcePieces(g, p, revival); finishNexusEmperorRevival(g, revival, 'returned'); if (quote.techIncome) { const income = quote.techIncome; diff --git a/game/movement-promises.ts b/game/movement-promises.ts index 4e703c9e..400559c5 100644 --- a/game/movement-promises.ts +++ b/game/movement-promises.ts @@ -85,10 +85,6 @@ export function finishMovementPromises( * in the engine. Truthtrance and an ordinary active Ornithopter may remain. */ export function movementPromiseModeSupported(g: Game): boolean { return g.advanced === false && - // Early owned-stage forecasts currently price ordinary classic invoices. - // Native phase5 coverage does not establish Richese/CHOAM bidding prices. - (g.phase >= 5 || (!g.expansions.length && g.players.every(player => - FACTIONS.find(entry => entry.id === player.faction)?.expansion === 'base'))) && g.players.length >= 2 && g.players.length <= 6 && g.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) && new Set(g.expansions).size === g.expansions.length && diff --git a/game/reference.ts b/game/reference.ts index 5f937ac4..7ee0d830 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2930,12 +2930,12 @@ export const RULE_TOPICS: RuleTopic[] = [ developmentStage: 'Prototyped', summary: 'Bind one future qualifying physical ground movement before a Basic respondent finishes their current-round turn.', steps: [ - 'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Classic Basic supports clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Selected native families currently begin in phase5; native early invoices, optional modules, Advanced and later rounds remain unfinished.', + 'Choose Bind ground movement this turn, a printed territory and a minimum of one to twenty physical forces. Basic with declared native families supports clean phases0–4 before Movement. During Movement ask an active player before or after shipment, or a still-unused waiting respondent. Finish pending decisions first. Optional modules, Advanced, later rounds and wider owned preparations remain unfinished.', 'Yes requires one qualifying ground movement from now until that player’s Shipment and Movement turn ends, while possible. Smaller groups do not add together and earlier moves do not count. Shipment, HMS relocation and Ambassador relocation do not fulfill this statement; a concealed No-Field marker itself is not a physical on-board force counter.', 'A nonmatching move leaves Yes pending when an owned later route remains. One matching physical commitment fulfills it before card disposal and later Intrusion, Ambassador or Terror effects. You are promising the move, not survival afterward. No forbids every later matching move, including Hajr and both Ornithopter groups, until the actual turn closes.', 'Ordinary reserve shipment may prepare or reinforce a later ground group using its actual cost, native allocation and pledged payment split. Shipment itself neither fulfills movement Yes nor violates movement No. Joint guidance honors a required shipment before End; an already declared reserve or native Guild transport keeps its exact source, destination and funding while responses finish.', 'Waiting answers use current board/resources without assuming foreign income, movement, cooperation or discarded cards. Another player cannot fulfill or close your promise by moving or ending. Ordinary guidance appears on your actual turn; your already-paid Box choice can finish now. Deferred gifts/accompaniment cannot voluntarily spend the last route before another player responds.', - 'Earlier classic answers include owned Charity and ordinary Revival, reopen readiness when needed, and reserve a current winning auction invoice using cash or a held Karama. Only current preparation, Ready, bid-pass or owned payment appears before Movement. Unknown purchases and future income are not assumed. Forced Storm losses can release impossibility; old movement allowances reset at actual phase entry. A separate held Karama may prepare Guild shipment rates.', + 'Earlier classic answers include owned Charity and ordinary Revival, reopen readiness when needed, and reserve a current winning auction invoice using cash or a held Karama. Only current preparation, Ready, bid-pass or owned payment appears before Movement. Unknown purchases and future income are not assumed. Forced Storm losses can release impossibility; old movement allowances reset at actual phase entry. A separate held Karama may prepare Guild shipment rates. Native Once Around and owned Silent bids reserve exact funding; a submitted ally locks its full pledge until reveal. Current native guidance passes or submits zero with the original event. A paid sale is not charged again, and foreign invoice outcomes precede release.', '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.', @@ -2943,11 +2943,11 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['card-truthtrance', 'truthtrance-shipment', 'movement', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Classic Basic phases0–4 forecast owned Charity, ordinary auction payment and Revival, reopen readiness, preserve joint shipment predicates and reset old allowances. Current cash/held Karama and a distinct later shipment-rate card retain costs. Forced Storm losses release impossibility; foreign invoices finish before release. Native phase5 waiting/card support remains. Native early invoices, later rounds, remaining preparations and modules 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 questions/private answers and persistent busy-safe guidance now include only current Charity, Revival, Ready, bid-pass or owned auction payment before Movement. No future shipment/move control appears early; combined guidance avoids conflicting shipment preparation. Existing waiting/paid-search controls remain. Browser/visual acceptance stays open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/shipment-promises.tsx', 'components/truthtrance.tsx', 'components/nullentropy-box.tsx', 'components/game-table.tsx', 'tests/truth-movement-waiting-controls.test.tsx', 'tests/truth-movement-early-controls.test.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'Four existing profiles make24 accepted early-preparation/later-movement proposals plus76 separate native continuations. Combined movement guidance precedes independent shipment suggestions; all-No leaves native auction payment intact. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-waiting-engine.test.ts', 'tests/truth-movement-early-engine.test.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Basic declared native families now join classic phases0–4. Exact Richese own/donor invoices, full submitted-ally Silent locks and already-paid boundaries prepare the owned turn without speculative bought cards/income. Fixed non-CHOAM preopening Charity and shared ordinary force-revival accounting preserve real native counter decisions. Mandatory Ix phase5 opening retains the future turn/reset. CHOAM own unresolved opening, later rounds, wider preparations/modules remain unfinished.', evidence: ['game/early-auction-funding.ts', 'game/movement-promises.ts', 'game/movement-promise-search.ts', 'game/movement-shipment-preparation.ts', 'game/movement-discard-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Persistent busy-safe guidance includes current Charity, Revival, Ready, ordinary bid-pass/payment and event-bearing native Once Around pass/Silent zero. Foreign pending funding cannot force a speculative positive bid or expose future movement. Actual native pass/zero handlers preserve unread obligations. Existing waiting/card controls remain; browser/visual acceptance stays open.', evidence: ['components/movement-claim-fields.tsx', 'components/movement-promises.tsx', 'components/shipment-promises.tsx', 'components/truthtrance.tsx', 'components/nullentropy-box.tsx', 'components/game-table.tsx', 'tests/truth-movement-waiting-controls.test.tsx', 'tests/truth-movement-early-controls.test.tsx', 'tests/truth-movement-native-early-controls.test.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Eight existing-profile runs across native Once Around/Silent make56 accepted early-preparation/later-movement proposals plus148 separate native continuations. Zero/pass preserves the route while foreign funding resolves; all-No retains ordinary payment. Minimal legal participation only; no strategy tuning or calibration.', evidence: ['game/bots.ts', 'tests/truth-movement-waiting-engine.test.ts', 'tests/truth-movement-early-engine.test.ts', 'tests/truth-movement-native-early-engine.test.ts'] }, { area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, existential single-event meaning, explicit software composition, source selection and unfinished scope are documented.', evidence: ['docs/TRUTHTRANCE_MOVEMENT_PROMISES.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Current21-file155/155, types/lint/build and four gameplay/control/table-rendering programmes pass. Original Storm8/Charity22 sources retain Emperor10 spice/20 reserves/zero board, then perform purchase2/shipment8/ground8. Controlled prior losses, conserved cards, ally escrow and stale allowance cases disclose staging. Prior125/125,165/165,231/231,218/218 remain historical. Native early invoices, complete modes, browser/deployed and comprehensive assurance remain open.', evidence: ['tests/truth-movement-waiting-engine.test.ts', 'tests/truth-movement-waiting-controls.test.tsx', 'tests/fixture-truth-movement-waiting.ts', 'tests/fixture-truth-movement-waiting-box.ts', 'tests/fixture-hajr-replay.ts', 'tests/truth-movement-early-engine.test.ts', 'tests/truth-movement-early-controls.test.tsx', 'tests/fixture-truth-movement-early.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Current29-file244/244, types/lint/build and six gameplay/control/table-rendering programmes pass. E2/E1/E3 original Charity prefixes25/36/25 retain native setup/cards and Emperor10 spice/20 reserves, then actual invoice2/shipment8/ground8. Controlled escrow, original-card/prior-loss and allowed/canceled Charity/revival cases disclose staging. Initial Ix opening failure is repaired and final frozen union passes. Earlier counts remain historical. Full modes, wider forecast scope, browser/deployed and comprehensive assurance remain open.', evidence: ['tests/truth-movement-waiting-engine.test.ts', 'tests/truth-movement-waiting-controls.test.tsx', 'tests/fixture-truth-movement-waiting.ts', 'tests/fixture-truth-movement-waiting-box.ts', 'tests/fixture-hajr-replay.ts', 'tests/truth-movement-early-engine.test.ts', 'tests/truth-movement-early-controls.test.tsx', 'tests/fixture-truth-movement-early.ts', 'tests/early-auction-funding.test.ts', 'tests/truth-movement-native-early-engine.test.ts', 'tests/truth-movement-native-early-controls.test.tsx', 'tests/fixture-truth-movement-native-early.ts', 'docs/TRUTHTRANCE_MOVEMENT_PROMISES.md'] }, ], }, { @@ -3044,7 +3044,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The questioned player answers publicly. A definite answer discards the card. If the answer cannot be known, the holder may ask a different question or save Truthtrance for later.', 'A saved definite answer resumes automatically: its public answer, binding promise and consumed card are recorded once, then the next queued question or interrupted action resumes. Private answer and preparation guidance is unavailable during this automatic recovery.', 'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.', - 'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier classic Basic phases with owned Charity/ordinary invoice/Revival preparation, and active or unused waiting native Basic phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.', + 'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.', 'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.', 'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.', 'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index ab4f4876..0d4c621d 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 need a supported Basic opportunity this round with pending decisions finished. Earlier phases currently use the classic roster; optional modules and Advanced remain unfinished.'); + 'Ground-movement promises need a supported Basic opportunity this round with pending decisions finished. Optional modules and Advanced remain unfinished.'); try { return { kind: 'movement', target: v.target as string, ...parseMovementClaim(v) }; } catch (error) { if (error instanceof MovementClaimError) throw new TruthError(error.message); throw error; } } diff --git a/tests/early-auction-funding.test.ts b/tests/early-auction-funding.test.ts new file mode 100644 index 00000000..2d17ee43 --- /dev/null +++ b/tests/early-auction-funding.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { earlyAuctionFunding, type EarlyAuctionFunding } from '../game/early-auction-funding'; +import { createRicheseAuction, submitRicheseBid } from '../game/richese-auction'; + +const input = (): EarlyAuctionFunding => ({ phase: 3, owner: 'buyer', ally: 'ally', + paid: false, normal: null, pledge: 8, funding: {} }); +function lot(method: 'onceAround' | 'silent') { + return createRicheseAuction({ event: 'native-lot', cardId: 'held-cache-card', + source: 'cache', owner: 'richese', method, eligible: ['buyer', 'ally', 'richese'], + order: ['buyer', 'ally', 'richese'], tieOrder: ['buyer', 'ally', 'richese'] }); +} +void test('an unpaid ordinary invoice keeps its own cash and donated escrow distinct', () => { + assert.deepEqual(earlyAuctionFunding({ ...input(), normal: { bidder: 'buyer', bid: 7, allyPayment: 2 } }), + { normalCash: 5, nativeCash: 0, refund: 8 }); + assert.deepEqual(earlyAuctionFunding({ ...input(), normal: { bidder: 'ally', bid: 7, allyPayment: 3 } }), + { normalCash: 0, nativeCash: 0, refund: 5 }); +}); +void test('native Once Around owns only the leading exact split and releases an outbid offer', () => { + const funding = { buyer: { amount: 7, allyPayment: 2, donor: 'ally' }, + ally: { amount: 8, allyPayment: 3, donor: 'buyer' } }; + let native = submitRicheseBid(lot('onceAround'), { event: 'native-lot', actor: 'buyer', amount: 7 }, 20); + assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 5, refund: 8 }); + native = submitRicheseBid(native, { event: 'native-lot', actor: 'ally', amount: 8 }, 20); + assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 0, refund: 5 }); +}); +void test('own Silent funding is exact while a submitted ally locks the whole pledge regardless of secret split', () => { + let native = submitRicheseBid(lot('silent'), { event: 'native-lot', actor: 'buyer', amount: 7 }, 20); + const funding = { buyer: { amount: 7, allyPayment: 2, donor: 'ally' }, + ally: { amount: 8, allyPayment: 1, donor: 'buyer' } }; + assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 5, refund: 8 }); + native = submitRicheseBid(native, { event: 'native-lot', actor: 'ally', amount: 8 }, 20); + for (const allyPayment of [0, 1, 5, 8]) { + const quote = earlyAuctionFunding({ ...input(), native, funding: { ...funding, + ally: { ...funding.ally, allyPayment } } }); + assert.deepEqual(quote, { normalCash: 0, nativeCash: 5, refund: 0 }); + } +}); +void test('a revealed native winner reserves its actual donor leg and a losing offer spends nothing', () => { + let native = lot('silent'); + for (const [actor, amount] of [['buyer', 7], ['ally', 8], ['richese', 0]] as const) + native = submitRicheseBid(native, { event: native.event, actor, amount }, 20); + const funding = { buyer: { amount: 7, allyPayment: 2, donor: 'ally' }, + ally: { amount: 8, allyPayment: 3, donor: 'buyer' } }; + assert.deepEqual(earlyAuctionFunding({ ...input(), native, funding }), { normalCash: 0, nativeCash: 0, refund: 5 }); +}); +void test('an already-paid sale and a later phase do not charge an old invoice twice', () => { + const native = submitRicheseBid(lot('onceAround'), { event: 'native-lot', actor: 'buyer', amount: 7 }, 20); + const state = { ...input(), native, funding: { buyer: { amount: 7, allyPayment: 0, donor: null } }, + normal: { bidder: 'buyer', bid: 4, allyPayment: 0 } }; + assert.deepEqual(earlyAuctionFunding({ ...state, paid: true }), { normalCash: 0, nativeCash: 0, refund: 8 }); + assert.deepEqual(earlyAuctionFunding({ ...state, phase: 2 }), { normalCash: 0, nativeCash: 0, refund: 8 }); +}); +void test('a malformed donated invoice cannot create a negative refund', () => { + assert.throws(() => earlyAuctionFunding({ ...input(), pledge: 2, + normal: { bidder: 'ally', bid: 4, allyPayment: 4 } }), RangeError); +}); diff --git a/tests/fixture-truth-movement-native-early.ts b/tests/fixture-truth-movement-native-early.ts new file mode 100644 index 00000000..fef05652 --- /dev/null +++ b/tests/fixture-truth-movement-native-early.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { + actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer, + type TruthMovementStep, +} from './fixture-truth-movement'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type TruthMovementNativeEarlyStep = TruthMovementStep; +export type TruthMovementNativeEarlyRoster = 'richese-choam' | 'ecaz-moritani' | 'ixians-tleilaxu'; +export type TruthMovementNativeEarlyAuction = { + position?: 'first' | 'last'; + method?: 'onceAround' | 'silent'; + direction?: 'clockwise' | 'counterclockwise'; + card?: string; + keepUnbid?: boolean; +}; +export const actTruthMovementNativeEarly = actTruthMovement; +export const truthMovementNativeEarlyClean = truthMovementClean; +export const truthMovementNativeEarlyPlayer = truthMovementPlayer; +export const truthMovementNativeEarlyInventory = truthMovementInventory; + +/** Consume live native controls only. Callers own Truthtrance, paid bids and + * Emperor's shipment/movement; cache defaults are actual zero/pass actions. */ +export function nextTruthMovementNativeEarlyStep(game: Game, + auction: TruthMovementNativeEarlyAuction = {}): TruthMovementNativeEarlyStep | null { + if (game.truthtrance) return null; + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) { + const decision = game.decision; + if (decision) { + const actor = decision.player, event = game.richeseBidding?.event; + if (decision.kind === 'richeseBlackMarket') + return { actor, action: { type: 'decision', event, decline: true } }; + if (decision.kind === 'richeseDeclaration') + return { actor, action: { type: 'decision', event, position: auction.position ?? 'first' } }; + if (decision.kind === 'richeseCacheTerms') return { actor, action: { + type: 'decision', event, method: auction.method ?? 'onceAround', + direction: auction.direction ?? 'clockwise', + } }; + if (decision.kind === 'richeseCache') { + const card = game.richeseCache?.find(card => auction.card ? card.id === auction.card : true); + assert.ok(card, 'Choose an original card still held in the native Richese cache'); + return { actor, action: { type: 'decision', event, card: card.id, + ...(actor === game.richeseBidding?.owner ? { + method: auction.method ?? 'onceAround', direction: auction.direction ?? 'clockwise', + } : {}), + } }; + } + if (decision.kind === 'richeseUnbid') + return { actor, action: { type: 'decision', event, keep: auction.keepUnbid ?? false } }; + if (decision.kind === 'ecazPlacement' || decision.kind === 'choamMovement') + return { actor, action: { type: 'decision', decline: true } }; + if (decision.kind === 'moritaniSetup') + return { actor, action: { type: 'decision', territory: 'oh_gap', sector: 9 } }; + if (decision.kind === 'ixSetup') { + const card = game.ixSetupCards?.find(card => card.kind === 'shield'); + assert.ok(card, 'Ixians must actually choose the original Shield from their starting-card inspection'); + return { actor, action: { type: 'decision', card: card.id } }; + } + } + const ecaz = game.players.find(player => player.faction === 'ecaz'); + if (!decision && game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20) + return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } }; + } + if (truthMovementNativeEarlyClean(game)) { + if (game.richeseAuction && !game.richeseAuction.outcome) { + const lot = game.richeseAuction; + const actor = lot.method === 'silent' + ? lot.eligible.find(id => !Object.hasOwn(lot.sealed, id)) : lot.active; + assert.ok(actor, 'An unfinished native cache lot needs its next eligible bidder'); + return { actor, action: { type: 'richeseBid', + event: lot.event, amount: lot.method === 'silent' ? 0 : null } }; + } + if (game.status === 'playing' && game.phase === 5 && game.active === 'emperor') return null; + } + return nextStrongholdFactionsNativeStep(game); +} + +export function advanceTruthMovementNativeEarly(game: Game, until: (state: Game) => boolean, + actions: TruthMovementNativeEarlyStep[] = [], auction: TruthMovementNativeEarlyAuction = {}): Game { + for (let count = 0; count < 1600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original native source ended before its requested control'); + const step = nextTruthMovementNativeEarlyStep(game, auction); + assert.ok(step, 'Supply the real Truthtrance, bid or Emperor movement choice before continuing'); + game = actTruthMovementNativeEarly(game, step.actor, step.action, actions); + } + throw Error('The original native early movement source did not reach its finite boundary'); +} + +/** Fresh public Basic expansion setup, stopped at clean Charity before any + * question or Bidding. Select only original unopened deal/Spice positions and + * scalar lotteries; original setup controls supply every physical counter. */ +export function createTruthMovementNativeEarlyFixture(options: { + roster?: TruthMovementNativeEarlyRoster; +} = {}) { + const roster = options.roster ?? 'richese-choam', asker = 'atreides', target = 'emperor'; + const native = roster === 'richese-choam' ? ['richese', 'choam'] as const + : roster === 'ecaz-moritani' ? ['ecaz', 'moritani'] as const : ['ixians', 'tleilaxu'] as const; + const expansions = roster === 'richese-choam' ? ['choam'] : roster === 'ecaz-moritani' ? ['ecaz'] : ['ix']; + let game = createGame(`TRUTHMOVEMENTEARLY${roster.toUpperCase()}`, + newPlayer(asker, 'Atreides', 'atreides'), false, expansions); + for (const faction of [target, ...native] as const) joinGame(game, newPlayer(faction, faction, faction)); + const actions: TruthMovementNativeEarlyStep[] = []; + const act = (actor: string, action: Action) => { + game = actTruthMovementNativeEarly(game, actor, action, actions); + }; + const reach = (until: (state: Game) => boolean) => { + game = advanceTruthMovementNativeEarly(game, until, actions); + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game), lobbyActionCount = actions.length; + act(game.host, { type: 'start' }); + const setup = structuredClone(game), setupActionCount = actions.length; + assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); + assert.deepEqual(game.expansions, expansions); + assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && + !game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery); + assert.ok(game.players.every(player => player.hand.length === 0), 'Choose only an unopened original deal'); + const originalMainDeck = game.deck.map(card => card.id), originalCache = (game.richeseCache ?? []).map(card => card.id); + const originalCards = truthMovementNativeEarlyInventory(game); + if (roster === 'richese-choam') { + assert.equal(originalMainDeck.length, 35); assert.equal(originalCache.length, 10); + } + const truth = game.deck.find(card => card.effect === 'truthtrance'); + const baliset = game.deck.find(card => card.name === 'Baliset'); + const shield = game.deck.find(card => card.kind === 'shield'); + const snooper = game.deck.find(card => card.kind === 'snooper'); + assert.ok(truth && baliset && shield && snooper); + const deal = [truth.id, baliset.id, shield.id, snooper.id]; + const dealOrder = deal.map((id, position) => { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: id }; + }); + const beforeStartingDeal = structuredClone(game); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game), afterSetupActionCount = actions.length; + for (const [index, player] of game.players.entries()) + assert.deepEqual(player.hand.map(card => card.id), [deal[index]]); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const beforeSpiceOrder = structuredClone(game); + const from = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(from >= 0); + const land = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(land); + const spiceOrder = [{ position: 0, from, card: structuredClone(land) }]; + reach(state => state.turn === 1 && state.phase === 2 && truthMovementNativeEarlyClean(state)); + assert.equal(game.status, 'playing'); assert.equal(game.advanced, false); + assert.deepEqual(game.ready, []); assert.equal(game.auction ?? null, null); + assert.equal(game.richeseBidding ?? null, null); assert.equal(game.richeseAuction ?? null, null); + assert.equal(game.truthtrance ?? null, null); assert.equal(game.movementPromises, undefined); + const emperor = truthMovementNativeEarlyPlayer(game, target); + assert.equal(emperor.spice, 10); assert.equal(emperor.reserves, 20); assert.equal(emperor.tanks, 0); + assert.deepEqual(emperor.forces, {}); assert.equal(emperor.shipped, false); assert.equal(emperor.moved, 0); + assert.ok(truthMovementNativeEarlyPlayer(game, asker).hand.some(card => card.id === truth.id)); + assert.deepEqual(truthMovementNativeEarlyInventory(game), originalCards); + assert.deepEqual([...game.spiceDeck, ...game.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(), originalSpice); + const claim = { kind: 'movement' as const, target, territory: 'carthag', minimum: 8 }; + return { + game, source: structuredClone(game), roster, expansions, asker, target, native, + richese: roster === 'richese-choam' ? 'richese' : null, + choam: roster === 'richese-choam' ? 'choam' : null, + ecaz: roster === 'ecaz-moritani' ? 'ecaz' : null, + moritani: roster === 'ecaz-moritani' ? 'moritani' : null, + ixians: roster === 'ixians-tleilaxu' ? 'ixians' : null, + tleilaxu: roster === 'ixians-tleilaxu' ? 'tleilaxu' : null, + lobby, lobbyActionCount, setup, setupActionCount, beforeStartingDeal, afterSetup, afterSetupActionCount, + beforeSpiceOrder, spiceOrder, deal, dealOrder, startingDealSelection: dealOrder, + originalMainDeck, originalCache, originalCards, originalTreachery: originalCards, originalSpice, + actions, sourceActionCount: actions.length, + sourceCounts: { lobby: lobbyActionCount, setup: setupActionCount, storm: afterSetupActionCount, charity: actions.length }, + balances: Object.fromEntries(game.players.map(player => [player.id, player.spice])), + card: truth.id, truth: truth.id, claim, question: claim, + declarationStep: { actor: asker, action: { type: 'card', card: truth.id } } satisfies TruthMovementNativeEarlyStep, + askStep: { actor: asker, action: { type: 'truthAsk', question: claim } } satisfies TruthMovementNativeEarlyStep, + bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action, + payment: { type: 'decision', karama: false } satisfies Action, + purchaseCost: 2, + shipment: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 8, elite: 0, allyPayment: 0 } satisfies Action, + shipmentCost: 8, + move: { type: 'move', from: 'arrakeen:10', territory: 'carthag', sector: 11, amount: 8 } satisfies Action, + endMovement: { type: 'endMovement' } satisfies Action, + staging: [ + `Original public bare Basic Atreides/Emperor/${native.join('/')} setup uses four original unopened deal positions, one ordinary unopened Spice position and native scalar lotteries. Actual setup deals Truthtrance to Atreides and Baliset to Emperor, and actual Storm/Spice controls reach clean turn-1 Charity in exactly ${actions.length} recorded actions. Emperor retains its printed ten spice, twenty reserves, zero board forces and unused movement. ${roster === 'richese-choam' ? 'The original 35-card main deck and ten-card cache are conserved; native first/last Once Around/Silent auctions remain future controls.' : roster === 'ecaz-moritani' ? 'Ecaz actually places six in Imperial Basin and Moritani actually places six in empty Oh Gap; neither spends Emperor resources.' : 'Ixians actually inspect the four original starting cards and choose the original Shield; the remaining Truthtrance/Baliset/Snooper are natively shuffled and dealt to Atreides/Emperor/Tleilaxu. The native mobile-stronghold placement retains its original six-counter garrison, and Tleilaxu receives its actual original Face Dancers.'} The constructor stops before any Truthtrance declaration, answer, auction declaration, bid or payment. Future purchase2, city shipment8, ground movement8 and ending movement remain caller actions; no dealt hand, wallet, force, clock, answer, promise or completed receipt is installed.`, + ], + }; +} +export type TruthMovementNativeEarlyFixture = ReturnType; diff --git a/tests/movement-promises.test.ts b/tests/movement-promises.test.ts index c271cf08..8c477539 100644 --- a/tests/movement-promises.test.ts +++ b/tests/movement-promises.test.ts @@ -130,12 +130,12 @@ void test('Basic native selected families qualify, while optional modules and un } }); -void test('earlier current-round movement questions support classic Basic while native early invoices remain unfinished', () => { +void test('earlier current-round movement question profiles include declared Basic native families', () => { for (const phase of [0, 1, 2, 3, 4]) { const classic = movementBoundary(); classic.phase = phase; classic.movementRemaining = []; assert.equal(movementPromiseTargetAvailable(classic, 'target'), true); const native = structuredClone(classic); native.players[0].faction = 'ixians'; native.expansions = ['ix']; - assert.equal(movementPromiseTargetAvailable(native, 'target'), false); + assert.equal(movementPromiseTargetAvailable(native, 'target'), true); classic.advanced = true; assert.equal(movementPromiseTargetAvailable(classic, 'target'), false); } for (const phase of [-1, 6, 8]) { diff --git a/tests/truth-movement-native-early-controls.test.tsx b/tests/truth-movement-native-early-controls.test.tsx new file mode 100644 index 00000000..505f5ebc --- /dev/null +++ b/tests/truth-movement-native-early-controls.test.tsx @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MovementPromises } from '../components/movement-promises'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { + createTruthMovementNativeEarlyFixture, actTruthMovementNativeEarly as act, + advanceTruthMovementNativeEarly as advance, truthMovementNativeEarlyClean as clean, + type TruthMovementNativeEarlyFixture, +} from './fixture-truth-movement-native-early'; + +type Props = Parameters[0]; +type Control = { children?: unknown; disabled?: boolean; onClick: () => void }; +function controls(node: unknown): Control[] { + if (Array.isArray(node)) return node.flatMap(controls); + if (!node || typeof node !== 'object' || !('props' in node)) return []; + const props = (node as ReactElement<{ children?: unknown; disabled?: boolean; onClick?: () => void }>).props; + return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...controls(props.children)]; +} +const markup = (props: Props) => renderToStaticMarkup(createElement(MovementPromises, props)); + +/** Real source controls bind the answer and decline the original discard + * reaction. No promise, native auction, submitted bid or policy is installed. */ +function bind(f: TruthMovementNativeEarlyFixture): Game { + let game = act(f.game, f.asker, f.declarationStep.action); + while (game.truthtrance?.stage === 'priority') { + const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player); + game = act(game, player.id, { type: 'truthPass' }); + } + game = act(game, f.asker, f.askStep.action); + assert.ok(viewGame(game, f.target).truthMovementAnswers?.includes('yes')); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); + while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { + const pending = game.pendingTreacheryDiscard, reaction = pending.reaction; + assert.ok(reaction?.stage === 'offer'); + const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player); + game = act(game, player.id, { type: 'semutaPass', event: pending.batch.event }); + } + assert.ok(clean(game), 'Finish the original Truthtrance and Semuta controls before the native auction'); + assert.equal(game.phase, 2); + return game; +} + +function nativeControl(method: 'onceAround' | 'silent') { + const f = createTruthMovementNativeEarlyFixture(); + const game = advance(bind(f), state => state.phase === 3 && clean(state) && !!state.richeseAuction && + !state.richeseAuction.outcome && (method === 'silent' + ? !Object.hasOwn(state.richeseAuction.sealed, f.target) + : state.richeseAuction.active === f.target), [], { method, card: 'richese-nullentropy-box' }); + const lot = game.richeseAuction!; + assert.equal(lot.method, method); assert.equal(lot.source, 'cache'); + const expected: Action = { type: 'richeseBid', event: lot.event, + amount: method === 'silent' ? 0 : null, allyPayment: 0 }; + const view = viewGame(game, f.target); + assert.deepEqual(view.movementPromisePolicy, [expected]); + const sent: Action[] = [], props = { game: view, busy: false, act: (action: Action) => { sent.push(action); } }; + const html = markup(props); + assert.match(html, /Your Movement turn is still ahead/); + assert.match(html, /movement controls appear on your turn/); + assert.match(html, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/); + assert.match(html, /a shipment can prepare that move but does not fulfill it/); + assert.doesNotMatch(html, />Ship \d|>Move \d|>Use Ornithopter and move|>Finish movement while honoring No/); + const button = controls(MovementPromises(props)); + assert.equal(button.length, 1); assert.equal(button[0].disabled, false); + assert.equal(button[0].children, method === 'silent' + ? 'Submit zero in this Silent auction and preserve later movement' + : 'Pass this Richese bid and preserve later movement'); + const busy = controls(MovementPromises({ ...props, busy: true })); + assert.equal(busy.length, 1); assert.equal(busy[0].disabled, true); + busy[0].onClick(); assert.deepEqual(sent, []); + assert.match(markup({ ...props, busy: true }), /Your Movement turn is still ahead/); + button[0].onClick(); assert.deepEqual(sent, [expected]); + assert.equal(sent[0], view.movementPromisePolicy![0], 'Forward the actual event-bound private policy action'); + const next = act(game, f.target, sent[0]); + assert.equal(next.movementPromises![0].released, undefined); + assert.equal(next.movementPromises![0].fulfilled, undefined); + if (method === 'silent') assert.equal(next.richeseAuction!.sealed[f.target], 0); + else assert.ok(next.richeseAuction!.passed.includes(f.target)); + const waiting = { ...props, game: viewGame(next, f.target) }; + assert.deepEqual(waiting.game.movementPromisePolicy, []); + assert.equal(controls(MovementPromises(waiting)).length, 0); + const after = markup(waiting); + assert.match(after, /Your ground-movement promises/); + assert.match(after, /Your Movement turn is still ahead/); + assert.match(after, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/); + assert.doesNotMatch(after, /