From 32791ed5e2a70c6d84e2fad762891359c5b00195 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:08:29 +1000 Subject: [PATCH] Support typed Advanced Truthtrance shipment commitments Extend the existing reserve-shipment promise pipeline to base Advanced games without Guild or optional modules. Share scope between parser, save validation and UI. Reuse canonical Ghola normal/elite revival eligibility and retain live BG conversion responses while proving conditional private feasibility. Hide future-price guidance until pending responses finish. Show exact Sardaukar/Fedaykin allocation in private executable steps. Validation: types, lint and all 4,192 offline tests pass; production build and all 40 HTTP tests pass. 41 focused shipment tests include 15 new cases covering genuine Advanced setup, all five non-Guild factions and four bot profiles, typed Ghola quota, BG allowance/cancellation/alternatives, private views, saved continuations and production SQLite duplicate races. Independent review found and resolved optional-module scope and pending price-guidance issues. Browser rooms EUZZ8D4M and GWNCVCAP verified an AI No route and human Yes with four-force/one-Sardaukar Ghola then six-force shipment. Phone settlement controls and refresh passed. Both existing browser room hashes/versions preserved through HTTP tests; 30 rooms added. Full source-bound report: 20260913-advanced-shipment-final; fingerprint 6c2f29d9b3e61bf6b9544ee3a95c47538abfb05e1c3b2ae9d0a603ef0cef38dd Broad checks ran once after focused fixes/review. No full-game AI study or complete Advanced mode acceptance is claimed; all existing release gates and pending Guild settlement questions remain intact. --- components/shipment-promises.tsx | 9 +- components/truthtrance.tsx | 8 +- docs/CURRENT_STATUS.md | 13 +- docs/EFFICIENCY_PILOT.md | 4 +- docs/IMPLEMENTATION_STATUS.md | 2 + docs/RULE_DECISIONS.md | 2 +- docs/TRUTHTRANCE_SHIPMENT_PROMISES.md | 20 +- game/engine.ts | 33 +- game/reference.ts | 10 +- game/shipment-promises.ts | 20 + game/truthtrance.ts | 6 +- tests/shipment-promises-recovery.test.ts | 179 +++++- tests/shipment-promises.test.ts | 38 +- tests/truthtrance-advanced-shipment.test.ts | 596 ++++++++++++++++++++ 14 files changed, 902 insertions(+), 38 deletions(-) create mode 100644 tests/truthtrance-advanced-shipment.test.ts diff --git a/components/shipment-promises.tsx b/components/shipment-promises.tsx index cf4ed4bc..64086901 100644 --- a/components/shipment-promises.tsx +++ b/components/shipment-promises.tsx @@ -42,12 +42,17 @@ export function ShipmentPromises({ const share = shipping ? Number(next.allyPayment ?? Math.max(0, cost! - (me.spice ?? 0))) : 0; + const elite = Number(next?.elite ?? 0); + const allocation = + elite > 0 + ? `, including ${elite} ${me.faction === 'emperor' ? 'Sardaukar' : me.faction === 'fremen' ? 'Fedaykin' : 'elite force' + (elite === 1 ? '' : 's')}` + : ''; const description = next ? shipping - ? `Ship ${Number(next.amount)} forces to ${territory(String(next.territory)).name}, sector ${Number(next.sector)}` + ? `Ship ${Number(next.amount)} forces${allocation} to ${territory(String(next.territory)).name}, sector ${Number(next.sector)}` : next.type === 'pledgeAid' ? 'Reclaim your unused ally pledge' - : `Play ${me.hand?.find((c) => c.id === next.card)?.name ?? 'your preparation card'}${next.amount ? ` to revive ${Number(next.amount)} forces` : ' for your shipment discount'}` + : `Play ${me.hand?.find((c) => c.id === next.card)?.name ?? 'your preparation card'}${next.amount ? ` to revive ${Number(next.amount)} forces${allocation}` : ' for your shipment discount'}` : ''; return (
20; const shipmentAvailable = - !g.advanced && - !g.expansions.length && + shipmentPromiseModeSupported(g) && g.phase === 5 && g.active === target && !g.players.find((p) => p.id === target)?.shipped && @@ -356,8 +356,8 @@ export function Truthtrance({ {!shipmentAvailable && (

Automatic enforcement currently supports the active - player’s unused shipment in the Basic game without - expansions. Select that player and finish pending + player’s unused shipment in base Basic games and base + Advanced games without Guild or optional modules. Select that player and finish pending decisions first. Earlier questions and other rules configurations remain unfinished.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 5bd983ee..9b379ebb 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -5,7 +5,7 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid ## Evidence and source of truth - [Rules implementation checklist](../game/reference.ts): `RULE_TOPICS`, topic `implementation-checklist`, and each feature's implementation, controls, AI, documentation and verification entries. Coverage is per stated boundary, not an implicit whole-game certificate. -- [Latest historical checkpoint](IMPLEMENTATION_STATUS.md): Guild Nexus Secret Ally, 10 September 2026 (checkpoint `06d0a66`), records types, lint, **4,162 offline tests**, production build and **40 HTTP/session tests** passing, two browser rooms and preserved opening saved games. These are recorded results, not checks rerun for this dashboard. +- [Latest gameplay checkpoint before this batch](IMPLEMENTATION_STATUS.md): Guild Nexus Secret Ally, 10 September 2026 (checkpoint `06d0a66`), records types, lint, **4,162 offline tests**, production build and **40 HTTP/session tests** passing, two browser rooms and preserved opening saved games. These are recorded results, not checks rerun for this dashboard. - [Development guide](DEVELOPMENT.md) defines focused verification; [rule decisions](RULE_DECISIONS.md) locates settled contracts and pending interpretations. Read the newest checkpoint and relevant feature document before consulting older log entries. The user authorized a one-time local reset on 13 September: the 3,644 historical @@ -13,11 +13,16 @@ rooms were archived privately and cleared. [Fresh checkpoint and preservation policy](VERIFICATION_WORKFLOW.md#authorized-reset-13-september-2026) now apply to new games; historical room IDs are no longer active local fixtures. +The efficiency setup was verified and pushed as `6e26ba5`: types, lint and 4,177 +offline tests passed. See [pilot measurements](EFFICIENCY_PILOT.md). The current +Advanced shipment batch extends typed preparation and commitments without Guild +or optional modules; its validation is recorded in the checkpoint commit. + ## Remaining readiness | Area | Established evidence | Remaining gate | | --- | --- | --- | -| Basic core | Playable loop; shared setup, Fremen movement cancellation, structured Truthtrance spice/card facts and bounded reserve-shipment promises. | Ordinary Guild rate/transport cancellation; wider truthful questions/action commitments; integrated rules and human/AI acceptance. | +| Basic core | Playable loop; shared setup, Fremen movement cancellation, structured Truthtrance facts and bounded Basic/no-Guild Advanced reserve-shipment promises. | Ordinary Guild rate/transport cancellation; wider truthful questions/action commitments; integrated rules and human/AI acceptance. | | Six-faction Advanced | Genuine offline setup across 456 configurations; four-profile full-game simulations and JSON continuations. | Base gaps plus provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. Public starts remain disabled. | | Twelve factions and optional modules | Many bounded faction, card, Homeworld and Nexus slices have controls, bots and recovery evidence. | Every unfinished effect and interaction, occupation entitlement, Discoveries and complete combined games. Inventory or fixture coverage does not enable a module. | | AI difficulty | Four profiles consume private legal views; recorded Basic and Advanced studies complete. | Demonstrate a consistent Medium → Hard → Brutal strength ordering, improve strategic gaps, and repeat targeted studies after relevant policy/rule changes. | @@ -26,12 +31,12 @@ new games; historical room IDs are no longer active local fixtures. The [base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md) includes historical missing-feature rows. Its follow-ups supersede the Fremen cancellation and BG setup findings; [genuine setup](ADVANCED_SETUP_TEST_SEAM.md) and the [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md) supersede the old Basic-then-flip testing concern. They do not settle the remaining rules. -Likewise, the [Truthtrance readiness audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) predates [spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card-count facts](TRUTHTRANCE_CARD_COUNT.md) and [Basic shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md). Current `TruthFact` already includes these facts; do not rebuild them. Broader facts, earlier-phase promises, Advanced/expansion commitment scope and freeform interpretation remain incomplete. +Likewise, the [Truthtrance readiness audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) predates [spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card-count facts](TRUTHTRANCE_CARD_COUNT.md) and [Basic shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md). Current `TruthFact` already includes these facts; do not rebuild them. Typed preparation and BG conversion now support the active reserve-shipment scope in base Advanced without Guild or optional modules. Broader facts, earlier-phase promises, remaining Advanced/expansion commitment scope and freeform interpretation remain incomplete. ## Next milestone: core readiness 1. Reconcile the remaining base-only gaps against current helpers, consumers and tests, using the existing [Guild cancellation contract](GUILD_RATE_KARAMA_RULES.md) and [special-Karama findings](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations). Keep already-asked interpretations pending; tests are not rulings. -2. First gameplay batch: ordinary Guild cancellation around an exact saved shipment intent. Keep discount, cross/return permission and income separate; preserve contributors, independent Karama rates and original typed forces. Establish the unresolved affordability/settlement contract before implementing those outcomes. Verify sufficient-funds paths, nested responses, rejected-action immutability, private controls, all four bots and saved continuations together. +2. Ordinary Guild cancellation remains the next dependent gameplay batch around an exact saved shipment intent. Keep discount, cross/return permission and income separate; preserve contributors, independent Karama rates and original typed forces. Establish the unresolved affordability/settlement contract before implementing those outcomes. Verify sufficient-funds paths, nested responses, rejected-action immutability, private controls, all four bots and saved continuations together. 3. Close remaining core Truthtrance and Advanced timing gaps, then run genuine Basic/Advanced setup-to-finish multiplayer and browser acceptance before reconsidering mode gates. Expansion completion remains a subsequent part of the same overall goal. Consult [AI calibration](AI_CALIBRATION.md), [multiplayer audit](MULTIPLAYER_AUDIT.md), [autopilot](AUTOPILOT.md), [component inventory](COMPONENT_INVENTORY.md) and [visual playtest](VISUAL_PLAYTEST.md) for their measured scope and follow-ups. Use current code and later checkpoints to interpret historical results. Publication still requires the completion and integrated verification gates in [README](../README.md). diff --git a/docs/EFFICIENCY_PILOT.md b/docs/EFFICIENCY_PILOT.md index ab3d3e54..f7974f3f 100644 --- a/docs/EFFICIENCY_PILOT.md +++ b/docs/EFFICIENCY_PILOT.md @@ -8,8 +8,8 @@ The removed recurring automation stays removed. | Milestone | Deliverable | Measurement | | --- | --- | --- | -| 1: setup | Current status, decision index, reusable checks, saved-game snapshots/backups and HTTP seat restoration | Start aggregate goal counter: 57,957,554 tokens. Latest observed pre-continuation counter: 57,986,221; partial delta 28,667 includes planning/context and is not a finished-milestone cost. Two bounded helpers: documentation and independent tooling review. Final checkpoint records actual checks. | -| 2: core readiness batch | Remaining Basic/Advanced capabilities with rules, controls, bots and recovery together | Record start/end aggregate counters, accepted capability, focused/broad runs, reviewer effort and reopened defects. Pending material rulings remain explicit. | +| 1: setup | Current status, decision index, reusable checks, saved-game snapshots/backups and HTTP seat restoration | Accepted checkpoint `6e26ba5`. Aggregate counters 57,957,554 → 58,124,416: 166,862 tokens including planning, coordination and the user-requested save reset. Two bounded helpers. Fifteen added regressions; 4,177 offline tests passed. Three broad attempts: two stopped before tests on type/lint errors; final check passed in 130.147s. One cancellation defect found and fixed in review. | +| 2: core readiness batch | Remaining Basic/Advanced capabilities with rules, controls, bots and recovery together | Start aggregate counter: 58,124,416. Batch: typed Advanced reserve-shipment commitments without Guild or optional modules. Two bounded helpers: regression implementation and independent rules/privacy review. Final counts belong to the verified checkpoint; pending Guild settlement remains explicit. | | 3: next dependency batch | Next core or connected expansion capability selected from current evidence | Repeat measurements; compare cost per accepted capability and investigate repeated work. | The aggregate counter may update only between turns. Record a confirmed endpoint diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 465fc1fb..352d90f8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,7 @@ # Dune implementation status +**Typed Advanced shipment-promise checkpoint, 13 September 2026:** [Shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) now support base Advanced games without Guild or optional modules through the existing authoritative commitment, controls, private guidance, bots and recovery. Ghola preparation reuses canonical eligible normal/elite limits; pending BG conversion preserves feasible promises while retaining real responses and hiding conditional shipment pricing. Cancellation releases only when owned alternatives fail. Genuine Advanced scenarios and production SQLite races cover physical custody and saved continuations. Full validation and browser outcomes are recorded in the checkpoint commit and source-bound verification report. Ordinary Guild settlement, broader commitments, combined modules and complete mode acceptance remain unfinished; no release gate is opened. + **Efficiency setup and fresh local save checkpoint, 13 September 2026:** [Current status](CURRENT_STATUS.md) and the [rule decision index](RULE_DECISIONS.md) consolidate existing evidence without changing mode gates. [Reusable verification](VERIFICATION_WORKFLOW.md) adds private source-bound check logs, read-only saved-game snapshots/online backups and bounded HTTP seat-restoration checks. Focused regressions cover child failure/cancellation, stale source, output protection, WAL backup and seat privacy; the required full-check result is recorded in this checkpoint’s Git commit and external verification report. The user explicitly authorized clearing old local saves: a verified private backup preceded removal of 3,644 rooms and related records, preserving schema/migrations and creating a fresh empty baseline. Future games remain protected. [Pilot measurements](EFFICIENCY_PILOT.md) retain selective subagents, mandatory verified Git pushes and the full game scope; no gameplay or release gate is enabled. Browser setup remains feature-specific, and no live browser acceptance is claimed for this tooling change. **Guild Nexus Secret Ally checkpoint, 10 September 2026:** [NEXUS_GUILD_SECRET_ALLY_RUNTIME.md](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) records one explicitly selected Guild-priced shipment for an unallied holder with Guild absent. Reserve, physical cross/abstract-return and independently legal world-to-world routes preserve actual faction identity, typed custody, ordinary movement and bank payment. Fremen retain their free reinforcement choice; the separate paid southern route reaches beyond its range without storm privileges or off-planet triggers. Private controls and all four bot profiles share source-aware price/permission; binding shipment promises can rely on the card. Exact committed records and an independent outcome marker preserve arrival alliances and later movement without inventing native Guild responses. **Final types, lint, all 4,162 offline tests, production build and 40 HTTP/session tests pass.** Twenty-six new cases include real Moritani arrival alliances and production SQLite races, corruption rejection and private recovery. Review also repaired an older Cunning fixture's post-auction card-availability assumption; ten repeated runs passed 190 cases. Two three-seat browser rooms passed paid Fremen shipment/movement, mixed Emperor Homeworld shipment, desktop/phone controls and six private refreshes. The 03:46 UTC backed-up restart preserved all **3,612** opening games and restored twelve earlier private seats; final comparison preserves every opening version/hash, with two browser and thirty HTTP rooms added. Homeworld-return interpretation was asked of the user; that route, concealed transport, Betrayal and complete module/mode acceptance remain gated. Automation remains removed. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 41040bd3..6ff56580 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -9,7 +9,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi | Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) | | Setup and Fremen movement | Preserve source-ordered setup and the distinct declared-movement cancellation; do not recreate fixed historical gaps. | [Setup contract](ADVANCED_SETUP_TEST_SEAM.md), [Fremen Karama](FREMEN_MOVEMENT_KARAMA_RULES.md) | | Guild payment | Rate, transport permission and income are distinct; power owner, shipper, contributors and independent rate-card beneficiary remain distinct. | [Rate/cancellation sources](GUILD_RATE_KARAMA_RULES.md), [implemented payment routing](GUILD_SHIPMENT_PAYMENTS.md) | -| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic active-opportunity shipment promises bind within their documented scope; prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) | +| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic and base Advanced active-opportunity shipment promises bind within their documented scope (Advanced excludes Guild and optional modules); prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) | | Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) | | Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) | | Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) | diff --git a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md index a02337c2..e88a2b12 100644 --- a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md +++ b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md @@ -1,6 +1,6 @@ # Truthtrance reserve-shipment promises -Implementation checkpoint, 7 September 2026. This completes a bounded Basic-game future-action predicate. It does not certify complete Truthtrance, Advanced play or expansion interactions, and opens no new mode gate. +Original checkpoint, 7 September 2026; Advanced follow-up, 13 September 2026. This implements a bounded reserve-shipment future-action predicate. It does not certify complete Truthtrance, Advanced play or expansion interactions, and opens no new mode gate. ## Authority and supported meaning @@ -8,13 +8,27 @@ The [GF9 November 2020 FAQ, printed p8](https://www.gf9games.com/dune/wp-content The new question is explicit: will the target ship at least N **physical forces from reserves** to a named territory this turn? Ordinary Guild shipping, Fremen reinforcement and Guild-allied Fremen transport from southern reserves qualify. Guild board-to-board/board-to-reserves transport, ground movement, existing garrisons and free BG accompaniment do not. Quantity does not mean combat strength. -Automatic enforcement presently accepts this structured question only in the Basic game without expansions, during the active target's unused shipment opportunity, with pending decisions resolved. This is an unfinished software boundary, not a restriction printed on Truthtrance. Earlier-phase questions, Advanced Guild cancellation, concealed and allied No-Fields, expansion arrival chains, compound shipment/fact predicates and arbitrary prose remain unimplemented. Previously pending material interpretation questions remain pending. +Automatic enforcement accepts this structured question in the Basic game without faction expansions and in base Advanced games without Guild or optional modules, during the active target's unused shipment opportunity, with pending decisions resolved. This is an unfinished software boundary, not a restriction printed on Truthtrance. Earlier-phase questions, Advanced Guild cancellation, concealed and allied No-Fields, expansion arrival chains, compound shipment/fact predicates and arbitrary prose remain unimplemented. Previously pending material interpretation questions remain pending. + +## Advanced follow-up: typed preparation and conversion + +The shared mode guard now agrees across question entry, saved-ledger validation and controls. The new Advanced boundary excludes seated Guild, faction expansions and separately enabled Tech, Stronghold, Nexus and Homeworld modules. Existing Basic behavior is preserved. This is a readiness boundary, not a printed Truthtrance prohibition, and the public Advanced start gate stays closed. + +Reachability uses the same Ghola quote as actual card controls. Three ordinary plus two elite forces in the Tanks permit four returns when the elite allowance is unused; an exhausted elite allowance permits only the ordinary forces. Proposed actions carry exact physical elite allocation, shown as Sardaukar or Fedaykin in private guidance. + +An owned Worthless card can provide a prospective BG shipment discount. While its conversion awaits responses, the private feasibility copy considers successful conversion so the existing promise is retained. The real table keeps the response, spent physical card and pending effect. Actual cancellation releases the promise only when no owned alternative remains; allowance activates the discount once. Pending conversions project no executable shipment or current-price guidance. Fremen reinforcement, joining BG advisors and later free accompaniment preserve the distinct original shipment event. + +The 13 September regressions use genuine Advanced setup followed by conserved scenarios, with all five non-Guild factions, four bot profiles, mixed and elite-only Tanks, exhausted revival quota, Yes/No enforcement and private-state perturbations. Production SQLite cases restore pending questions/conversions and race duplicate Ghola, cancellation and shipment actions. Exact run results are recorded in the checkpoint commit and verification report. Earlier figures below remain historical. + +### Browser follow-up, 13 September + +Two newly created isolated rooms followed genuine Advanced initialization before scenario staging; this is feature acceptance, not a full Advanced game. In `EUZZ8D4M`, the actual question form submitted a six-force commitment to Hard Emperor, whose public No was followed by a legal nonmatching shipment. In `GWNCVCAP`, the human Emperor answered Yes, saw an exact four-force Ghola preparation including one Sardaukar, executed it, then shipped six including that Sardaukar to Carthag for six spice. Conflicting shipment, movement and finish controls were disabled until fulfillment. The faction-colored Ghola notice appeared; the resulting state retained one force in the Tanks, zero reserves and zero personal spice. Refresh and phone-width settlement controls were checked separately; no full mobile setup-to-finish game is claimed. Private backups precede each staged room, outside the checkout. ## Implementation `game/shipment-promises.ts` defines the normalized destination/minimum and turn-scoped boolean obligation. `game/truthtrance.ts` parses one to twenty physical forces and a printed territory, generates the public sentence, offers target-private answers through the engine, and binds/history/discards atomically through the existing overlay. A definite answer consumes exactly one physical Truthtrance. It does not select the final shipment sector, quantity or payment. -`findShipmentCompletion` in `game/engine.ts` searches canonical shipment quotes for all relevant physical quantities and destination sectors, honoring the full set of prior promises jointly. It tries deterministic preparations controlled by the respondent: their retained Karama, one Ghola's maximum available five-force revival, and withdrawal of their own recoverable outgoing pledge. Existing incoming escrow and already-active rate benefits are usable; unpledged rival spice, unknown cards and future cooperation are not assumed. These preparation operations preserve smaller-quantity choices, so maximal Basic Ghola revival is sufficient for destination/minimum predicates. Owned preparations may be combined. The Guild-allied Fremen southern-reserve route uses its actual authoritative transport action and paid tariff. +`findShipmentCompletion` in `game/engine.ts` searches canonical shipment quotes for all relevant physical quantities and destination sectors, honoring the full set of prior promises jointly. It tries deterministic preparations controlled by the respondent: their retained Karama, Ghola's canonical eligible force revival, including the one-elite-per-turn limit, and withdrawal of their own recoverable outgoing pledge. Existing incoming escrow and already-active rate benefits are usable; unpledged rival spice, unknown cards and future cooperation are not assumed. These preparation operations preserve smaller-quantity choices, so maximal Basic Ghola revival is sufficient for destination/minimum predicates. Owned preparations may be combined. The Guild-allied Fremen southern-reserve route uses its actual authoritative transport action and paid tariff. The solver never uses the AI's ranked move shortlist to declare an answer impossible. The independent probe showed those lists can omit both six-force quantities and otherwise legal destinations. Preparations run through the internal non-reconciling action seam on cloned state; they do not recursively invoke `applyAction`, change the real table, draw random cards, or reveal a witness publicly. diff --git a/game/engine.ts b/game/engine.ts index 76718034..29535f49 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -273,6 +273,7 @@ import { import { matchesShipment, liveShipmentPromises, + shipmentPromiseModeSupported, type ShipmentClaim, type ShipmentPromise, } from './shipment-promises'; @@ -12486,16 +12487,26 @@ function shipmentPromiseIntegrity(g: Game) { ); if (promise.turn === g.turn && !promise.released && !promise.fulfilled) requireRule( - !g.advanced && - !g.expansions.length && + shipmentPromiseModeSupported(g) && g.status === 'playing' && g.phase === 5 && g.active === promise.player && shipmentAvailable(g,getPlayer(g,promise.player)), - 'The saved shipment promise does not belong to this unused Basic shipment opportunity.', + 'The saved shipment promise does not belong to a supported unused shipment opportunity.', ); } } +/** A pending conversion may succeed. Resolve only its shipment effect in the + * private reachability copy; the live table retains every opponent response. */ +function projectPendingShipmentKarama(g: Game, player: string) { + const pending = g.pendingKarama; + if (g.response?.kind !== 'worthlessKarama' || !pending || + g.response.owner !== pending.owner || pending.use.kind !== 'shipment' || + pending.use.recipient !== player) return; + g.response = null; + g.pendingKarama = null; + completeKarama(g, getPlayer(g, pending.owner), pending.use); +} /** Search owned preparation and canonical shipment quotes, never the AI's scored shortlist. */ function findShipmentCompletion( state: Game, @@ -12517,6 +12528,7 @@ function findShipmentCompletion( return { actions: [{ type: 'endMovement' }] }; const initial = structuredClone(state); initial.truthtrance = null; + projectPendingShipmentKarama(initial, owner.id); // Test choices after the interrupt, while leaving the real continuation intact. // Every actual action still revalidates its timing, quote and physical custody. initial.response = null; @@ -12628,8 +12640,8 @@ function findShipmentCompletion( } } } - // These Basic preparations are deterministic and use only the respondent's - // own cards and recoverable escrow. More reserves/lower cost cannot remove + // Preparations use only owned cards, recoverable escrow and already-pending + // conversion effects. More reserves/lower cost cannot remove // a previously available choice of physical shipment count. const preparations: Action[] = []; if (g.aid[p.id]?.amount > 0) @@ -12642,16 +12654,18 @@ function findShipmentCompletion( mode: 'shipment', target: p.id, }); - const ghola = p.hand.find((c) => c.effect === 'ghola'); - if (ghola && p.tanks > 0) + const ghola = gholaOptions(g, p); + if (ghola.available && ghola.maxForces > 0) preparations.push({ type: 'card', - card: ghola.id, - amount: Math.min(5, p.tanks), + card: ghola.cards[0], + amount: ghola.maxForces, + elite: Math.max(0, ghola.maxForces - (p.tanks - (p.elites?.tanks ?? 0))), }); for (const action of preparations) { try { const next = applyActionInner(g, p.id, action, 'shipmentPreparation'); + projectPendingShipmentKarama(next, p.id); queue.push({ game: next, actions: [...actions, action] }); } catch (error) { if (!(error instanceof RuleError)) throw error; @@ -21194,6 +21208,7 @@ export function viewGame(state: Game, id: string) { shipmentPromises: g.shipmentPromises ?? [], shipmentCompletion: !g.pendingTreacheryDiscard && + !g.response && !g.pendingKarama && liveShipmentPromises(g.shipmentPromises ?? [], id, g.turn).some( (p) => p.answer, ) diff --git a/game/reference.ts b/game/reference.ts index d1855622..77019993 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2120,14 +2120,14 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Cards', coverage: 'Partial', summary: - 'Bind a shipment from reserves to a named territory with a minimum physical force count during the active Basic shipment opportunity.', + 'Bind a shipment from reserves to a named territory with a minimum physical force count during the active supported shipment opportunity.', steps: [ - 'Declare Truthtrance, select the active player and choose Bind a shipment from reserves. Choose a printed destination and minimum of one to twenty forces. Automatic enforcement currently supports the Basic game without expansions, before that player ships and after pending decisions finish.', + 'Declare Truthtrance, select the active player and choose Bind a shipment from reserves. Choose a printed destination and minimum of one to twenty forces. Automatic enforcement supports base Basic games and base Advanced games without Guild or optional modules, before that player ships and after pending decisions finish.', 'Yes requires a matching shipment while possible. No prohibits only that matching shipment: a smaller count, different destination, transport of forces already on the board or no shipment remains available. Forces already in the destination and ground movement do not satisfy the question. Fremen reinforcements and Guild transport from southern reserves do count.', 'The target privately sees feasible answers based on their own resources and earlier promises. Available Ghola revival, Karama discounts, incoming pledged aid and reclaiming their own unused pledge are included. Other players’ unplayed cards and future voluntary gifts are not assumed.', 'Accepted answers are public and constrain the actual shipment, including any earlier shipment promises. Yes leaves count, sector and legal funding choices open. The shipper cannot move first, finish movement or voluntarily spend away their last way to comply. A completed opposing effect that makes the answer impossible releases it with a public notice.', 'Your shipment promises remain visible during Movement. Open Suggested next step for a private executable preparation or funded shipment example. It is optional guidance; any legal action sequence honoring the answer is allowed. Shipment fulfillment survives later movement or losses because it records what was shipped.', - 'All four AI profiles answer supported questions and retain a complete legal preparation route when their normal strategic shortlist misses the required count or destination. Earlier-phase questions, Advanced and expansion shipment promises, combined shipment/fact logic and arbitrary freeform promises remain unfinished.', + 'All four AI profiles answer supported questions and retain a complete legal preparation route when their normal strategic shortlist misses the required count or destination. Earlier-phase questions, Advanced games with Guild or optional modules, expansion shipment promises, combined shipment/fact logic and arbitrary freeform promises remain unfinished.', ], example: 'Yes to at least six forces to Carthag and No to at least eight there leaves a shipment of six or seven. Moving existing forces into Carthag does not fulfill either shipment event.', @@ -2137,7 +2137,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Authoritative Basic reserve-origin commitments, joint feasibility with owned preparation, actual shipment completion, voluntary-spend guards and opposing release. Broader timing and expansion routes remain unfinished.', + 'Authoritative base Basic and no-Guild Advanced reserve-origin commitments, joint feasibility with owned preparation, actual shipment completion, voluntary-spend guards and opposing release. Broader timing and expansion routes remain unfinished.', }, { area: 'Player controls', @@ -2184,7 +2184,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 are enforced for the active unused Basic shipment without expansions; see the shipment promise guide.', + '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 are enforced for the active unused shipment in base Basic and no-Guild Advanced games; see the shipment promise guide.', '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.', '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.', 'Holding both copies lets you declare one or both. Each question resolves separately, and each card is discarded only after its definite answer. Full timing and card-face audits remain pending.', diff --git a/game/shipment-promises.ts b/game/shipment-promises.ts index 9aa86e3e..251b409d 100644 --- a/game/shipment-promises.ts +++ b/game/shipment-promises.ts @@ -1,5 +1,25 @@ /** A current-turn promise about the player's ordinary physical shipment from reserves. */ export type ShipmentClaim = { territory: string; minimum: number }; +/** Software readiness boundary; this is not a printed restriction on Truthtrance. */ +export function shipmentPromiseModeSupported(game: { + advanced: boolean; + expansions: readonly unknown[]; + players: readonly { faction: string }[]; + techTokens?: unknown; + strongholdCards?: unknown; + nexusCards?: unknown; + homeworlds?: unknown; +}): boolean { + return ( + !game.expansions.length && + (!game.advanced || + (!game.players.some((player) => player.faction === 'guild') && + !game.techTokens && + !game.strongholdCards && + !game.nexusCards && + !game.homeworlds)) + ); +} export type ShipmentPromise = ShipmentClaim & { turn: number; player: string; diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 965fb707..b991ba5f 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,5 +1,6 @@ import { shipmentAvailable } from './shipment-opportunity'; import type { ShipmentClaim } from './shipment-promises'; +import { shipmentPromiseModeSupported } from './shipment-promises'; import { TERRITORIES } from './board'; import { parseCardCountFact, @@ -141,8 +142,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { ); if (v.kind === 'shipment') { check( - !g.advanced && - !g.expansions.length && + shipmentPromiseModeSupported(g) && g.status === 'playing' && g.phase === 5 && g.active === v.target && @@ -151,7 +151,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { !g.decision && !g.phaseOpening && !g.pendingShipment, - 'Automatic shipment promises currently support the active unused shipment in the Basic game without expansions. Finish any pending decision first.', + 'Structured shipment promises currently support base Basic games and base Advanced games without Guild or optional modules, during the active unused shipment. Finish any pending decision first.', ); check( typeof v.territory === 'string' && diff --git a/tests/shipment-promises-recovery.test.ts b/tests/shipment-promises-recovery.test.ts index 6ea97f9c..09dd8cc4 100644 --- a/tests/shipment-promises-recovery.test.ts +++ b/tests/shipment-promises-recovery.test.ts @@ -91,18 +91,38 @@ function unitStore(runBots = bots.runBots) { const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} }; -async function fixture() { +async function fixture( + advanced = false, + faction: 'emperor' | 'beneGesserit' = 'emperor', +) { const store = unitStore(); const made = await store.rooms.createRoom('Asker', 'atreides', false, []); const code = made.view.code; - const target = await store.rooms.joinRoom(code, 'Shipper', 'emperor'); + const target = await store.rooms.joinRoom(code, 'Shipper', faction); const observer = await store.rooms.joinRoom(code, 'Observer', 'harkonnen'); const seats = await Promise.all( [made.token, target.token!, observer.token!].map((token) => store.rooms.authenticate(code, token), ), ); - const initial = await store.rooms.readRoom(code); + let initial = await store.rooms.readRoom(code); + if (advanced) { + initial.advanced = true; // Set the requested mode on the still-uninitialized lobby. + initial.players.forEach((p) => { + p.ready = true; + }); + initial = engine.initializeBaseGameForAudit(initial); + for (let n = 0; initial.status === 'setup' && n < 50; n++) { + const id = engine.viewGame(initial, initial.host).setupPending[0]; + assert.ok(id, 'Genuine Advanced setup retains a decision owner.'); + const view = engine.viewGame(initial, id); + view.players.find((p) => p.id === id)!.bot = 'Hard'; + const action = bots.botActions(view)[0]; + assert.ok(action, 'Genuine Advanced setup has a legal next decision.'); + initial = engine.applyAction(initial, id, action); + } + assert.equal(initial.status, 'playing'); + } Object.assign(initial, { status: 'playing', phase: 5, @@ -118,15 +138,24 @@ async function fixture() { deck: baseDeck(), response: null, decision: null, + phaseOpening: null, }); for (const p of initial.players) { p.hand = []; p.spice = 10; p.forces = {}; p.reserves = 20; + p.tanks = 0; + if (p.elites) { + p.elites.forces = {}; + p.elites.reserves = p.faction === 'emperor' ? 5 : 3; + p.elites.tanks = 0; + p.elites.revived = 0; + } + if (p.advisors) p.advisors = {}; p.shipped = false; p.moved = 0; - p.traitors = [p.leaders[0].id]; + if (!advanced) p.traitors = [p.leaders[0].id]; p.traitorChoices = []; } initial.players[1].forces = { 'polar_sink:0': 2 }; @@ -514,6 +543,7 @@ void test('corrupt persisted promise records and pending shipment questions reje }, (g) => { g.advanced = true; + g.players[2].faction = 'guild'; }, (g) => { g.expansions = ['ix']; @@ -655,3 +685,144 @@ void test('older-turn and fulfilled shipment promises remain readable without re f.sqlite.close(); } }); + +void test('Advanced typed Ghola preparation and its shipment survive room reload and duplicate submissions', async () => { + const f = await fixture(true); + try { + const staged = await f.restart().readRoom(f.code), + p = staged.players[1]; + p.reserves = 2; + p.tanks = 5; + p.forces = { 'polar_sink:0': 13 }; + p.spice = 6; + p.elites = { + reserves: 0, + tanks: 2, + forces: { 'polar_sink:0': 3 }, + revived: 0, + }; + const at = staged.deck.findIndex((c) => c.effect === 'ghola'); + assert.ok(at >= 0); + const ghola = staged.deck.splice(at, 1)[0]; + p.hand.push(ghola); + f.sqlite + .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') + .run(JSON.stringify(staged), staged.version, f.code); + await ask(f); + await restored(f); + const accepted = await act(f, 1, { type: 'truthAnswer', answer: 'yes' }); + const preparation = engine.viewGame(accepted, p.id).shipmentCompletion! + .actions[0]; + assert.equal(preparation.card, ghola.id); + assert.equal(preparation.amount, 4); + assert.equal(preparation.elite, 1); + await race(f, 1, accepted.version, [preparation, preparation]); + const revived = await restored(f); + assert.equal(revived.players[1].reserves, 6); + assert.equal(revived.players[1].elites!.revived, 1); + assert.equal(revived.discard.filter((c) => c.id === ghola.id).length, 1); + const shipment = engine.viewGame(revived, p.id).shipmentCompletion! + .actions[0]; + assert.equal(shipment.type, 'ship'); + assert.equal(shipment.elite, 1); + await race(f, 1, revived.version, [shipment, shipment]); + const arrived = await restored(f); + assert.equal(arrived.players[1].spice, 0); + assert.equal(arrived.players[1].reserves, 0); + assert.equal( + arrived.players[1].forces[`carthag:${Number(shipment.sector)}`], + 6, + ); + assert.equal( + arrived.players[1].elites!.forces[`carthag:${Number(shipment.sector)}`], + 1, + ); + assert.equal(arrived.shipmentPromises![0].fulfilled, true); + assert.deepEqual(inventory(arrived), inventory(staged)); + for (const other of [0, 2]) { + const view = engine.viewGame(arrived, f.seats[other].playerId); + assert.equal(view.shipmentCompletion, null); + assert.equal('hand' in view.players[1], false); + } + } finally { + f.sqlite.close(); + } +}); + +void test('Advanced BG pending conversion keeps its promise through restart and commits or releases once', async () => { + for (const canceled of [false, true]) { + const f = await fixture(true, 'beneGesserit'); + try { + const staged = await f.restart().readRoom(f.code), + p = staged.players[1]; + p.spice = 3; + p.forces = { 'carthag:11': 1 }; + p.reserves = 19; + p.advisors = { carthag: {} }; + const hold = (seat: number, effect: 'worthless' | 'karama') => { + const at = staged.deck.findIndex((c) => + effect === 'worthless' ? c.kind === 'worthless' : c.effect === effect, + ); + assert.ok(at >= 0); + const card = staged.deck.splice(at, 1)[0]; + staged.players[seat].hand.push(card); + return card.id; + }; + const conversion = hold(1, 'worthless'), + cancel = hold(0, 'karama'); + staged.players[2].forces = { 'carthag:11': 2 }; + staged.players[2].reserves = 18; + f.sqlite + .prepare('UPDATE rooms SET state=?,version=? WHERE code=?') + .run(JSON.stringify(staged), staged.version, f.code); + await ask(f); + const accepted = await act(f, 1, { type: 'truthAnswer', answer: 'yes' }); + await act( + f, + 1, + engine.viewGame(accepted, p.id).shipmentCompletion!.actions[0], + ); + let pending = await restored(f); + assert.equal(pending.response?.kind, 'worthlessKarama'); + assert.equal(pending.shipmentPromises![0].released, undefined); + assert.equal( + engine.viewGame(pending, p.id).shipmentCompletion, + null, + 'A pending discounted route is not yet an executable shipment or a current price.', + ); + await rejectsWithoutWrite(f, 1, { type: 'endMovement' }); + if (canceled) { + const action = { type: 'card', card: cancel, mode: 'cancel' }; + await race(f, 0, pending.version, [action, action]); + pending = await restored(f); + assert.equal(pending.shipmentPromises![0].released, true); + assert.equal(pending.players[1].spice, 3); + assert.equal(pending.players[1].shipped, false); + } else { + for (let n = 0; pending.response && n < 10; n++) { + const seat = pending.players.findIndex( + (player) => !pending.response!.passed.includes(player.id), + ); + assert.ok(seat >= 0); + pending = await act(f, seat, { type: 'passResponse' }); + } + assert.equal(pending.response, null); + await restored(f); + const shipment = engine.viewGame(pending, p.id).shipmentCompletion! + .actions[0]; + await race(f, 1, pending.version, [shipment, shipment]); + pending = await restored(f); + assert.equal(pending.shipmentPromises![0].fulfilled, true); + assert.equal(pending.players[1].spice, 0); + assert.ok(pending.players[1].advisors!.carthag); + } + assert.equal( + pending.discard.filter((c) => c.id === conversion).length, + 1, + ); + assert.deepEqual(inventory(pending), inventory(staged)); + } finally { + f.sqlite.close(); + } + } +}); diff --git a/tests/shipment-promises.test.ts b/tests/shipment-promises.test.ts index 508e0601..7776987e 100644 --- a/tests/shipment-promises.test.ts +++ b/tests/shipment-promises.test.ts @@ -1,5 +1,37 @@ import test from 'node:test'; +import { shipmentPromiseModeSupported } from '../game/shipment-promises'; import assert from 'node:assert/strict'; + +void test('Advanced shipment scope excludes separately enabled optional modules and preserves existing Basic tech scope', () => { + const base = { + advanced: true, + expansions: [], + players: [{ faction: 'emperor' }, { faction: 'atreides' }], + }; + assert.equal(shipmentPromiseModeSupported(base), true); + assert.equal( + shipmentPromiseModeSupported({ + ...base, + players: [...base.players, { faction: 'guild' }], + }), + false, + ); + for (const option of [ + 'techTokens', + 'strongholdCards', + 'nexusCards', + 'homeworlds', + ]) + assert.equal( + shipmentPromiseModeSupported({ ...base, [option]: {} }), + false, + option, + ); + assert.equal( + shipmentPromiseModeSupported({ ...base, advanced: false, techTokens: {} }), + true, + ); +}); import { applyAction, createGame, @@ -441,7 +473,10 @@ void test('malformed questions and unsupported public scopes reject without disc 'territory', ]) { const bad = structuredClone(g); - if (change === 'advanced') bad.advanced = true; + if (change === 'advanced') { + bad.advanced = true; + bad.players[2].faction = 'guild'; + } if (change === 'expansion') bad.expansions = ['ix']; if (change === 'phase') bad.phase = 4; if (change === 'spent') bad.players[1].shipped = true; @@ -511,6 +546,7 @@ void test('saved malformed promise bindings reject before action or normalizatio }, (g) => { g.advanced = true; + g.players[2].faction = 'guild'; }, (g) => { g.phase = 4; diff --git a/tests/truthtrance-advanced-shipment.test.ts b/tests/truthtrance-advanced-shipment.test.ts new file mode 100644 index 00000000..03bb16e1 --- /dev/null +++ b/tests/truthtrance-advanced-shipment.test.ts @@ -0,0 +1,596 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + initializeBaseGameForAudit, + joinGame, + newPlayer, + normalizeAutomaticGame, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; +import type { FactionId } from '../game/catalog'; +import { isAdvisor } from '../game/advisors'; + +const BASE: FactionId[] = [ + 'emperor', + 'atreides', + 'harkonnen', + 'fremen', + 'beneGesserit', +]; +const player = (g: Game, id = 'p') => g.players.find((p) => p.id === id)!; +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); + +/** Real Advanced initialization and setup, followed by conserved scenario staging. */ +function fixture(faction: FactionId = 'emperor', extra: FactionId[] = []) { + const roster = [...new Set([faction, ...extra, ...BASE])].slice( + 0, + Math.max(3, 1 + extra.length), + ); + const ids = ['p', 'a', 'o', 'x', 'y', 'z']; + let g = createGame( + 'ADVANCEDSHIPTRUTH', + newPlayer('p', faction, faction), + true, + ); + for (let i = 1; i < roster.length; i++) + joinGame(g, newPlayer(ids[i], roster[i], roster[i])); + for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); + g = initializeBaseGameForAudit(g); + for (let n = 0; g.status === 'setup' && n < 30; n++) { + let advanced = false; + for (const p of g.players) { + const view = viewGame(g, p.id); + view.players.find((other) => other.id === p.id)!.bot = 'Hard'; + const action = botActions(view)[0]; + if (!action) continue; + g = applyAction(g, p.id, action); + advanced = true; + break; + } + assert.ok(advanced, 'genuine Advanced setup has an entitled next actor'); + } + assert.equal(g.status, 'playing'); + assert.equal(g.advanced, true); + assert.equal(g.setupStage, undefined); + Object.assign(g, { + phase: 5, + turn: 2, + active: 'p', + storm: 18, + order: g.players.map((p) => p.id), + movementRemaining: g.players.map((p) => p.id), + response: null, + decision: null, + phaseOpening: null, + }); + for (const p of g.players) { + g.deck.push(...p.hand); + p.hand = []; + p.forces = {}; + p.reserves = 20; + p.tanks = 0; + p.spice = 20; + p.shipped = false; + p.moved = 0; + p.revived = 0; + if (p.advisors) p.advisors = {}; + if (p.elites) { + p.elites.reserves = p.faction === 'emperor' ? 5 : 3; + p.elites.tanks = 0; + p.elites.forces = {}; + p.elites.revived = 0; + } + } + return g; +} + +function hold(g: Game, id: string, effectOrKind: string) { + const index = g.deck.findIndex( + (c) => c.effect === effectOrKind || c.kind === effectOrKind, + ); + assert.ok(index >= 0, `available physical ${effectOrKind}`); + const [card] = g.deck.splice(index, 1); + player(g, id).hand.push(card); + return card.id; +} + +function pool( + g: Game, + reserves: number, + tanks = 0, + eliteReserves = 0, + eliteTanks = 0, + revived = 0, +) { + const p = player(g), + onBoard = 20 - reserves - tanks; + p.reserves = reserves; + p.tanks = tanks; + p.forces = onBoard ? { 'arrakeen:10': onBoard } : {}; + if (p.elites) { + const total = p.faction === 'emperor' ? 5 : 3; + const boardElites = total - eliteReserves - eliteTanks; + p.elites = { + reserves: eliteReserves, + tanks: eliteTanks, + revived, + forces: boardElites ? { 'arrakeen:10': boardElites } : {}, + }; + } + assertCustody(g); +} + +function assertCustody(g: Game) { + for (const p of g.players) { + assert.equal( + p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), + 20, + ); + if (p.elites) + assert.equal( + p.elites.reserves + + p.elites.tanks + + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), + p.faction === 'emperor' ? 5 : 3, + ); + } + const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]; + assert.equal(new Set(cards.map((c) => c.id)).size, cards.length); +} + +function openQuestion(state: Game) { + const g = reload(state), + card = hold(g, 'a', 'truthtrance'); + let next = applyAction(g, 'a', { type: 'card', card }); + for (let n = 0; next.truthtrance?.stage === 'priority' && n < 10; n++) { + const actor = next.players.find( + (p) => !next.truthtrance!.passed.includes(p.id), + )!; + next = applyAction(reload(next), actor.id, { type: 'truthPass' }); + } + assert.equal(next.truthtrance?.stage, 'ask'); + return next; +} + +function ask(g: Game, minimum = 6, territory = 'carthag') { + return applyAction(openQuestion(g), 'a', { + type: 'truthAsk', + question: { kind: 'shipment', target: 'p', territory, minimum }, + }); +} + +function bind(g: Game, minimum = 6, territory = 'carthag', answer = 'yes') { + return applyAction(reload(ask(g, minimum, territory)), 'p', { + type: 'truthAnswer', + answer, + }); +} + +function reject(g: Game, id: string, action: Action) { + const before = structuredClone(g); + assert.throws(() => applyAction(g, id, action)); + assert.deepEqual(g, before); +} + +function allow(state: Game) { + let g = reload(state); + for (let n = 0; g.response && n < 20; n++) { + const actor = g.players.find((p) => !g.response!.passed.includes(p.id)); + assert.ok(actor); + g = applyAction(reload(g), actor.id, { type: 'passResponse' }); + } + assert.equal(g.response, null); + return g; +} + +function witness(g: Game) { + const projected = viewGame(g, 'p'); + const next = projected.shipmentCompletion?.actions[0]; + assert.ok( + next, + 'a feasible positive answer supplies an executable next step', + ); + for (const other of g.players.filter((p) => p.id !== 'p')) + assert.equal(viewGame(g, other.id).shipmentCompletion, null); + return next; +} + +function finish(state: Game, difficulty?: Difficulty) { + let g = reload(state); + for (let n = 0; !player(g).shipped && n < 12; n++) { + if (g.response) { + g = allow(g); + continue; + } + const next = witness(g); + if (difficulty) { + const view = viewGame(g, 'p'); + view.players.find((p) => p.id === 'p')!.bot = difficulty; + const before = structuredClone(view), + proposals = botActions(view); + assert.deepEqual(view, before); + assert.deepEqual( + proposals[0], + next, + `${difficulty} uses the authoritative first step`, + ); + for (const action of proposals) + assert.doesNotThrow( + () => applyAction(g, 'p', action), + JSON.stringify(action), + ); + } + g = applyAction(reload(g), 'p', next); + assertCustody(g); + } + assert.equal(player(g).shipped, true); + assert.equal(g.shipmentPromises?.[0].fulfilled, true); + assert.equal(g.shipmentPromises?.[0].released, undefined); + return g; +} + +void test('Advanced mixed Ghola returns honor the one-elite cap and retain a feasible smaller physical shipment', () => { + for (const revived of [0, 1]) { + const g = fixture(); + pool(g, 0, 5, 0, 2, revived); + const ghola = hold(g, 'p', 'ghola'), + maximum = revived ? 3 : 4; + assert.deepEqual(viewGame(ask(g, maximum + 1), 'p').truthShipmentAnswers, [ + 'no', + ]); + const promised = bind(g, maximum), + next = witness(promised); + assert.equal(next.card, ghola); + assert.equal(next.amount, maximum); + assert.equal(next.elite, revived ? 0 : 1); + const done = finish(promised); + assert.equal(player(done).forces['carthag:11'], maximum); + assert.equal( + player(done).elites!.forces['carthag:11'] ?? 0, + revived ? 0 : 1, + ); + assert.equal(player(done).elites!.revived, 1); + assert.equal(player(done).tanks, 5 - maximum); + assert.equal(done.discard.filter((c) => c.id === ghola).length, 1); + } +}); + +void test('Advanced elite-only Tanks permit exactly one Ghola return, or none after the shared quota is spent', () => { + for (const faction of ['emperor', 'fremen'] as const) { + const g = fixture(faction); + pool(g, 0, 2, 0, 2); + hold(g, 'p', 'ghola'); + const destination = faction === 'fremen' ? 'the_great_flat' : 'carthag'; + assert.deepEqual( + viewGame(ask(g, 2, destination), 'p').truthShipmentAnswers, + ['no'], + ); + const promised = bind(g, 1, destination), + next = witness(promised); + assert.equal(next.amount, 1); + assert.equal(next.elite, 1); + const done = finish(promised); + assert.equal(player(done).tanks, 1); + assert.equal(player(done).elites!.tanks, 1); + assert.equal(player(done).elites!.revived, 1); + const exhausted = reload(g); + player(exhausted).elites!.revived = 1; + assert.deepEqual( + viewGame(ask(exhausted, 1, destination), 'p').truthShipmentAnswers, + ['no'], + ); + } +}); + +void test('Advanced Fremen promise counts physical Fedaykin once and fulfills free reserve reinforcement', () => { + const g = fixture('fremen'); + pool(g, 6, 0, 3); + player(g).spice = 0; + const promised = bind(g, 6, 'the_great_flat'); + reject(promised, 'p', { + type: 'ship', + territory: 'the_great_flat', + sector: 15, + amount: 3, + elite: 3, + }); + const done = applyAction(reload(promised), 'p', { + type: 'ship', + territory: 'the_great_flat', + sector: 15, + amount: 6, + elite: 3, + }); + assert.equal(player(done).forces['the_great_flat:15'], 6); + assert.equal(player(done).elites!.forces['the_great_flat:15'], 3); + assert.equal(player(done).spice, 0); + assert.equal(done.shipmentPromises?.[0].fulfilled, true); + assertCustody(done); +}); + +void test('Advanced BG joins existing advisors with a qualifying direct shipment and retains its stance', () => { + const g = fixture('beneGesserit'); + pool(g, 19); + player(g).forces = { 'carthag:11': 1 }; + player(g).advisors = { carthag: {} }; + player(g, 'a').forces = { 'carthag:11': 1 }; + player(g, 'a').reserves = 19; + const done = finish(bind(g, 6)); + assert.equal(player(done).forces['carthag:11'], 7); + assert.equal(isAdvisor(player(done), 'carthag'), true); + assertCustody(done); +}); + +void test('a fulfilled Advanced promise survives a saved BG accompaniment choice without counting the free advisor', () => { + const g = fixture('emperor', ['beneGesserit']); + const pending = finish(bind(g)); + assert.equal(pending.decision?.kind, 'advisor'); + assert.equal(pending.decision?.player, 'a'); + const promised = structuredClone(pending.shipmentPromises); + for (const accept of [true, false]) { + const continued = allow( + applyAction(reload(pending), 'a', { + type: 'decision', + accept, + accompany: true, + sector: 11, + }), + ); + assert.deepEqual(continued.shipmentPromises, promised); + assert.equal(player(continued).forces['carthag:11'], 6); + assert.equal( + player(continued, 'a').forces['carthag:11'] ?? 0, + accept ? 1 : 0, + ); + assert.equal(isAdvisor(player(continued, 'a'), 'carthag'), accept); + assert.equal(continued.active, 'p'); + assert.equal( + continued.discard.filter((c) => c.effect === 'truthtrance').length, + 1, + ); + assertCustody(continued); + } +}); + +void test('BG Worthless shipment preparation remains binding while a real response waits and completes once after allowance', () => { + const g = fixture('beneGesserit'); + player(g).spice = 3; + const worthless = hold(g, 'p', 'worthless'); + hold(g, 'a', 'karama'); + const asked = ask(g); + assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['yes', 'no']); + for (const id of ['a', 'o']) + assert.equal(viewGame(asked, id).truthShipmentAnswers, null); + const promised = applyAction(asked, 'p', { + type: 'truthAnswer', + answer: 'yes', + }); + const next = witness(promised); + assert.equal(next.card, worthless); + assert.equal(next.mode, 'shipment'); + const pending = applyAction(reload(promised), 'p', next); + assert.equal(pending.response?.kind, 'worthlessKarama'); + assert.equal(pending.karamaShipping, null); + assert.equal(pending.shipmentPromises?.[0].released, undefined); + assert.equal(pending.shipmentPromises?.[0].fulfilled, undefined); + assert.equal(pending.discard.filter((c) => c.id === worthless).length, 1); + const continued = normalizeAutomaticGame(reload(pending)); + assert.equal(continued.response?.kind, 'worthlessKarama'); + const granted = allow(continued); + assert.equal(granted.karamaShipping?.card, worthless); + const done = finish(granted); + assert.equal(player(done).forces['carthag:11'], 6); + assert.equal(player(done).spice, 0); + assert.equal(done.discard.filter((c) => c.id === worthless).length, 1); + assert.equal( + done.discard.filter((c) => c.effect === 'truthtrance').length, + 1, + ); +}); + +void test('canceling BG conversion releases an impossible promise once without spending its shipment or spice', () => { + const g = fixture('beneGesserit'); + player(g).spice = 3; + const worthless = hold(g, 'p', 'worthless'), + karama = hold(g, 'a', 'karama'); + const promised = bind(g), + pending = applyAction(promised, 'p', witness(promised)); + assert.equal(pending.response?.kind, 'worthlessKarama'); + const denied = applyAction(reload(pending), 'a', { + type: 'card', + card: karama, + mode: 'cancel', + }); + assert.equal(denied.shipmentPromises?.[0].released, true); + assert.equal(denied.shipmentPromises?.[0].fulfilled, undefined); + assert.equal(player(denied).shipped, false); + assert.equal(player(denied).spice, 3); + assert.equal(denied.pendingKarama, null); + assert.equal(denied.karamaShipping, null); + for (const card of [worthless, karama]) + assert.equal(denied.discard.filter((c) => c.id === card).length, 1); + const continued = normalizeAutomaticGame(reload(denied)); + assert.equal( + continued.log.filter((l) => + l.text.includes('no longer fulfill a Truthtrance shipment'), + ).length, + 1, + ); + assert.doesNotThrow(() => + applyAction(continued, 'p', { type: 'endMovement' }), + ); + reject(continued, 'a', { type: 'card', card: karama, mode: 'cancel' }); + assertCustody(continued); +}); + +void test('an opposing BG conversion cancellation preserves a promise when an owned physical Karama still fulfills it', () => { + const g = fixture('beneGesserit'); + player(g).spice = 3; + const worthless = hold(g, 'p', 'worthless'); + const retained = hold(g, 'p', 'karama'), + counter = hold(g, 'a', 'karama'); + const promised = bind(g); + assert.equal(witness(promised).card, worthless); + const pending = applyAction(promised, 'p', witness(promised)); + const denied = applyAction(reload(pending), 'a', { + type: 'card', + card: counter, + mode: 'cancel', + }); + assert.equal(denied.shipmentPromises?.[0].released, undefined); + assert.equal(witness(denied).card, retained); + const done = finish(denied); + assert.equal(player(done).forces['carthag:11'], 6); + assert.equal(player(done).spice, 0); + for (const card of [worthless, retained, counter]) + assert.equal(done.discard.filter((c) => c.id === card).length, 1); +}); + +void test('Advanced Yes rejects movement, skip and voluntary evasion; No permits every nonmatching choice', () => { + const g = fixture(); + pool(g, 6, 0, 2); + player(g).spice = 6; + const movement: Action = { + type: 'move', + from: 'arrakeen:10', + territory: 'imperial_basin', + sector: 10, + amount: 1, + elite: 0, + }; + const positive = bind(g); + for (const action of [ + movement, + { type: 'endMovement' }, + { type: 'bribe', target: 'a', amount: 1 }, + { type: 'ship', territory: 'carthag', sector: 11, amount: 5, elite: 1 }, + ]) + reject(positive, 'p', action); + const done = finish(positive); + assert.doesNotThrow(() => + applyAction(reload(done), 'p', { type: 'endMovement' }), + ); + const negative = bind(g, 6, 'carthag', 'no'); + reject(negative, 'p', { + type: 'ship', + territory: 'carthag', + sector: 11, + amount: 6, + elite: 2, + }); + for (const action of [ + movement, + { type: 'endMovement' }, + { type: 'ship', territory: 'carthag', sector: 11, amount: 5, elite: 1 }, + ]) { + const allowed = applyAction(reload(negative), 'p', action); + assert.equal(allowed.shipmentPromises?.[0].fulfilled, true); + assertCustody(allowed); + } +}); + +void test('all four profiles execute private typed Ghola and BG conversion witnesses through saved continuations', () => { + for (const difficulty of DIFFICULTIES) + for (const faction of ['emperor', 'beneGesserit'] as const) { + const g = fixture(faction); + if (faction === 'emperor') { + pool(g, 2, 5, 0, 2); + hold(g, 'p', 'ghola'); + player(g).spice = 6; + } else { + player(g).spice = 3; + hold(g, 'p', 'worthless'); + hold(g, 'a', 'karama'); + } + player(g).bot = difficulty; + const asked = ask(g), + view = viewGame(asked, 'p'); + const answers = botActions(view); + assert.equal(answers.length, 1); + assert.equal(answers[0].type, 'truthAnswer'); + assert.ok( + view.truthShipmentAnswers!.some( + (answer) => answer === answers[0].answer, + ), + ); + assert.doesNotThrow(() => applyAction(reload(asked), 'p', answers[0])); + // The profile may strategically prefer No; a legally selected Yes must + // still be fulfilled through the profile's authoritative preparation path. + const promised = applyAction(reload(asked), 'p', { + type: 'truthAnswer', + answer: 'yes', + }); + const done = finish(promised, difficulty); + assert.equal(player(done).forces['carthag:11'], 6); + assert.equal(player(done).spice, 0); + assert.equal( + done.discard.filter((c) => c.effect === 'truthtrance').length, + 1, + ); + } +}); + +void test('unrelated opponent cards and wealth cannot change Advanced answer or completion projections', () => { + const g = fixture(); + pool(g, 2, 5, 0, 2); + hold(g, 'p', 'ghola'); + hold(g, 'o', 'harvester'); + const asked = ask(g), + altered = reload(asked); + const rival = player(altered, 'o'); + altered.deck.push(...rival.hand); + rival.hand = []; + hold(altered, 'o', 'hajr'); + rival.spice = 999; + assert.deepEqual(viewGame(altered, 'p'), viewGame(asked, 'p')); + const original = applyAction(asked, 'p', { + type: 'truthAnswer', + answer: 'yes', + }); + const changed = applyAction(altered, 'p', { + type: 'truthAnswer', + answer: 'yes', + }); + assert.deepEqual(viewGame(changed, 'p'), viewGame(original, 'p')); + assert.deepEqual(witness(changed), witness(original)); + for (const difficulty of DIFFICULTIES) { + const left = viewGame(original, 'p'), + right = viewGame(changed, 'p'); + left.players.find((p) => p.id === 'p')!.bot = difficulty; + right.players.find((p) => p.id === 'p')!.bot = difficulty; + assert.deepEqual(botActions(left), botActions(right)); + } +}); + +void test('Advanced Guild and selected expansions reject unsupported questions immutably before binding or discard', () => { + for (const scope of ['guild', 'expansion'] as const) { + const g = fixture('emperor', scope === 'guild' ? ['guild'] : []); + if (scope === 'expansion') g.expansions = ['ix']; + const pending = openQuestion(g); + reject(pending, 'a', { + type: 'truthAsk', + question: { + kind: 'shipment', + target: 'p', + territory: 'carthag', + minimum: 6, + }, + }); + assert.equal(pending.shipmentPromises?.length ?? 0, 0); + assert.equal( + pending.discard.filter((c) => c.effect === 'truthtrance').length, + 0, + ); + assert.equal( + player(pending, 'a').hand.filter((c) => c.effect === 'truthtrance') + .length, + 1, + ); + } +});