From 9f4de647ce6c1245bacfd897ad9067c0718ca1ef Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:43:00 +1000 Subject: [PATCH] Connect compound Truthtrance shipment promises Shipment-only AND/OR questions now evaluate one eventual reserve shipment or skip as a whole. Yes to AND requires every leaf, Yes to OR requires one, and No negates the complete expression. Separate answered questions remain jointly binding. The completion search considers all destination branches and owned preparation paths. Release and fulfillment apply once to the aggregate promise, and legacy flat records retain their shape. The two-condition browser builder, grouped question/history and respondent guidance, all four AI answer policies and saved continuation are connected. Nested expressions have explicit software size limits. Existing active-unused-shipment, Basic/no-Guild Advanced and module gates remain; mixed present/future predicates and unresolved rule questions are unchanged. Independent source, privacy and persistence review found no blocker. Types, lint, 4815 offline tests, build and 43 HTTP tests pass with stable source. The first typecheck caught an omitted flat-only guidance consumer and a legacy test requiring type narrowing; grouped guidance and an actual promised-state regression fix them. SQLite Basic/Advanced tests verify private reauthentication, second-OR-branch fulfillment, zero-write rejected actions, legal No skip and duplicate CAS with one result. Two fixed genuine Basic/Advanced mixed-profile games complete in 1534 accepted actions, 41 periodic private JSON restores and 0 rejected candidates. Each uses one scenario-directed compound question from an actual held Truthtrance at a real legal opportunity; AI supplies all responses and other actions. Exact card, force, typed-elite and post-setup traitor custody are checked. Initial private harness runs needed correct immutable setup configuration and full-game auction/Fedaykin inventory accounting; original failed traces remain private, and the final same-seed runs pass. The full sample tree is reconstructed exactly by replacing only the three later UI/test edits; all 219 game source files match the samples. No game logic changed after those runs. Browser QA used genuine base initialization before conserved turn-two staging. The human asked twenty to Carthag OR four to Arrakeen; the AI answered Yes and shipped four to the second destination. Public grouped history survived refresh. All 6 accepted actions replay exactly, with one discarded Truthtrance, one fulfilled aggregate promise, private JSON views and original seat custody intact. This remains bounded prototype evidence, not complete Truthtrance or mode certification. All 1004 opening games and original seat/recovery records are preserved (1038 rooms after checks), including all 1005 rooms present immediately before checks. SQLite integrity and foreign-key checks pass. The healthy server was reused without restart, reset or recurring automation. Verified source tree: 1b24372fbe34e24db56e8f095c25744eeefb27581f2bc6cced2fb752f5bdcb94 --- components/shipment-claim-fields.tsx | 45 ++ components/shipment-promises.tsx | 6 +- components/truthtrance.tsx | 59 +-- docs/COMPOUND_SHIPMENT_PROMISES.md | 68 +++ docs/CURRENT_STATUS.md | 25 +- docs/IMPLEMENTATION_STATUS.md | 10 + docs/RULE_DECISIONS.md | 6 + docs/TRUTHTRANCE_SHIPMENT_PROMISES.md | 2 +- game/bots.ts | 25 +- game/engine.ts | 17 +- game/reference.ts | 14 +- game/shipment-promises.ts | 126 ++++- game/truthtrance.ts | 35 +- tests/compound-shipment-bots.test.ts | 62 +++ tests/compound-shipment-controls.test.tsx | 85 ++++ tests/compound-shipment-promises.test.ts | 435 ++++++++++++++++++ tests/compound-shipment-recovery.test.ts | 257 +++++++++++ tests/fixture-compound-shipment.ts | 141 ++++++ .../truthtrance-discard-continuations.test.ts | 5 +- 19 files changed, 1306 insertions(+), 117 deletions(-) create mode 100644 components/shipment-claim-fields.tsx create mode 100644 docs/COMPOUND_SHIPMENT_PROMISES.md create mode 100644 tests/compound-shipment-bots.test.ts create mode 100644 tests/compound-shipment-controls.test.tsx create mode 100644 tests/compound-shipment-promises.test.ts create mode 100644 tests/compound-shipment-recovery.test.ts create mode 100644 tests/fixture-compound-shipment.ts diff --git a/components/shipment-claim-fields.tsx b/components/shipment-claim-fields.tsx new file mode 100644 index 00000000..972eac05 --- /dev/null +++ b/components/shipment-claim-fields.tsx @@ -0,0 +1,45 @@ +'use client'; +import { TERRITORIES } from '@/game/board'; +import { Input } from '@/components/ui/input'; + +export type ShipmentClauseInput = { territory: string; minimum: number }; +export function invalidShipmentClause(claim: ShipmentClauseInput) { + return !Number.isSafeInteger(claim.minimum) || claim.minimum < 1 || claim.minimum > 20; +} + +export function ShipmentClaimFields({ id, clauses, join, onClauses, onJoin }: { + id: string; + clauses: ShipmentClauseInput[]; + join: 'single' | 'and' | 'or'; + onClauses: (clauses: ShipmentClauseInput[]) => void; + onJoin: (join: 'single' | 'and' | 'or') => void; +}) { + return <> + + + {clauses.slice(0, join === 'single' ? 1 : 2).map((claim, index) => { + const update = (next: ShipmentClauseInput) => onClauses(clauses.map((previous, i) => i === index ? next : previous)); + return
+ Shipment condition {index + 1} + + + + update({ ...claim, minimum: event.currentTarget.valueAsNumber })} /> +
; + })} +

+ All conditions describe the same shipment from reserves. AND requires both; + OR requires at least one. A No answer requires the whole statement to be false. + Skipping shipment makes every shipment condition false. +

+ ; +} diff --git a/components/shipment-promises.tsx b/components/shipment-promises.tsx index 64086901..50a1651c 100644 --- a/components/shipment-promises.tsx +++ b/components/shipment-promises.tsx @@ -1,6 +1,6 @@ 'use client'; import type { Action, GameView } from '@/game/engine'; -import { liveShipmentPromises } from '@/game/shipment-promises'; +import { liveShipmentPromises, shipmentClaimText } from '@/game/shipment-promises'; import { territory } from '@/game/board'; import { reserveShipmentCost, guildShipmentCost } from '@/game/shipment-price'; import { Button } from './ui/button'; @@ -64,8 +64,8 @@ export function ShipmentPromises({ diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index e014201d..32d9c01d 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,5 +1,5 @@ 'use client'; -import { TERRITORIES } from '@/game/board'; +import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput } from './shipment-claim-fields'; import { BattleClaimFields } from './battle-promises'; import { CardCountFields } from './truthtrance-card-count'; import { KnowledgeFactFields } from './truthtrance-knowledge'; @@ -52,12 +52,12 @@ export function Truthtrance({ const [kind, setKind] = useState< 'fact' | 'freeform' | 'battlePlan' | 'shipment' >('fact'); - const [shipmentTerritory, setShipmentTerritory] = useState('carthag'); - const [shipmentMinimum, setShipmentMinimum] = useState(6); - const invalidShipment = - !Number.isSafeInteger(shipmentMinimum) || - shipmentMinimum < 1 || - shipmentMinimum > 20; + const [shipmentJoin, setShipmentJoin] = useState<'single' | 'and' | 'or'>('single'); + const [shipmentClauses, setShipmentClauses] = useState([ + { territory: 'carthag', minimum: 6 }, + { territory: 'arrakeen', minimum: 4 }, + ]); + const invalidShipment = shipmentClauses.slice(0, shipmentJoin === 'single' ? 1 : 2).some(invalidShipmentClause); const shipmentAvailable = shipmentPromiseModeSupported(g) && g.phase === 5 && @@ -112,8 +112,7 @@ export function Truthtrance({ ? { kind, target, - territory: shipmentTerritory, - minimum: shipmentMinimum, + ...(shipmentJoin === 'single' ? shipmentClauses[0] : { claim: { op: shipmentJoin, terms: shipmentClauses } }), } : { kind, target, text, scope }; const button = (label: string, action: Action, disabled = false) => ( @@ -195,8 +194,10 @@ export function Truthtrance({ A definite answer binds your shipment from reserves this turn. Available preparation includes your Ghola, Karama and recoverable allied funding. Yes leaves the exact - count, sector and payment yours to choose; No allows a - different shipment or none. + count, sector and payment yours to choose within the + complete statement. No requires that statement to be false; + for AND, at least one condition must be false; for OR, + every condition must be false.

{(g.truthShipmentAnswers ?? []).map((answer) => (
@@ -329,39 +330,9 @@ export function Truthtrance({ {kind === 'shipment' ? ( <> - - -

+ +

Includes Fremen reinforcements and Guild transport from southern reserves. Ground movement and transport of forces already on the board do not count. diff --git a/docs/COMPOUND_SHIPMENT_PROMISES.md b/docs/COMPOUND_SHIPMENT_PROMISES.md new file mode 100644 index 00000000..9cd85d66 --- /dev/null +++ b/docs/COMPOUND_SHIPMENT_PROMISES.md @@ -0,0 +1,68 @@ +# Truthtrance: compound shipment promises + +14 September 2026. This extends the [reserve-shipment prototype](TRUTHTRANCE_SHIPMENT_PROMISES.md) +with shipment-only AND/OR questions. Public mode and publication gates remain +unchanged. + +## Meaning and authority + +The [GF9 November 2020 FAQ, printed page 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) +permits AND/OR questions and binds definite answers while compliance remains +possible. Each condition here describes the same eventual reserve shipment in +the active opportunity, using a destination and minimum physical force count. +This does not promise two shipments or combine present facts with future actions. + +AND is true only if every condition matches. OR is true if any condition matches. +A No answer negates the whole expression: No to AND requires at least one false +condition; No to OR requires every condition to be false. Skipping shipment makes +all these positive shipment conditions false. Separately answered questions must +all be honored together. + +For example, Yes to “at least six to Carthag OR at least four to Arrakeen” permits +either branch. No to that same question permits fewer than six to Carthag, fewer +than four to Arrakeen, another destination or no shipment. AND with two different +destinations cannot be fulfilled by a single shipment, so Yes is unavailable. + +## Connected prototype + +The question form adds a second destination/count condition and an AND/OR choice. +The shared engine expression supports grouping within software limits of sixteen leaves and four nested groups; the first visual builder exposes two conditions. Existing flat questions +and saved promises retain their meaning. Public question/history text preserves +grouping, while answer feasibility and suggested preparations stay private to +the respondent. + +The completion search considers every relevant destination, physical count and +legal sector, with the same controlled preparations and funding as single +promises. It evaluates the entire expression and all earlier promises together. +Actual shipment, skipping, voluntary spending and opposing-effect release use +the same evaluator. A fulfilled promise records the shipment event and survives +later movement or casualties. + +All four AI profiles rank the projected feasible answers using only public +destination value and their own reserves. Strategic preference never determines +whether an answer is possible. The existing complete continuation remains the +fallback when a strategic shipment shortlist misses a legal route. + +## Evidence and remaining scope + +Focused engine, controls, AI and authenticated SQLite cases cover whole-expression +truth, alternative destinations, old saves, joint commitments, custody, private +restoration and duplicate actions. Final results and source fingerprints belong +to the private checkpoint report and Git message. + +The isolated browser used genuine base setup before conserved turn-two staging. +The human asked “twenty to Carthag OR four to Arrakeen.” The respondent's legal +AI policy answered Yes, and the public grouped question/answer survived refresh. +Its next AI action shipped four to Arrakeen, spent four spice and fulfilled the +aggregate promise once. All six accepted actions replayed exactly; all private +views, card/force custody and the original seat row were preserved. This is a +targeted browser scenario, not a complete human game. + +The existing active-unused-shipment scope remains: base Basic, and supported +base Advanced games without Guild or optional modules. Earlier timing, mixed +fact/future predicates, expansion shipment combinations and arbitrary prose +remain unfinished. This prototype does not settle pending material rulings or +certify complete Truthtrance. + +The opening backup preserves 1,004 local rooms and their seat/recovery records. +Reuse the healthy server and keep all subsequent games. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index eec5c4e4..43c4391b 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,13 +6,14 @@ gated.** Prototypes do not open completion or publication gates. ## Current checkpoint and work -Latest verified checkpoint: `0450156`, [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md). -Types, lint, **4,776 offline tests**, build and **43 HTTP tests** passed. -Eight fixed Basic/Advanced six-seat samples finished in 9,956 actions with no -rejected candidates, three Harass withdrawals and 266 periodic save/restore -checks. Browser controls, private restoration and the final engine replay passed. -All 937 games present before verification and their original seat/recovery -records were preserved, with 970 rooms after HTTP checks. +Latest verified checkpoint: `cb691e1`, [Sapho Battle-phase order](SAPHO_BATTLE_ORDER.md). +Types, lint, **4,792 offline tests**, build and **43 HTTP tests** passed. +Six fixed genuine Basic/Advanced faction samples finished in 3,672 accepted actions +with 97 periodic save/restore checks. They retained 82 rejected candidates at +existing guarded arrival combinations and completed through legal fallbacks. +Browser battle ordering, tie preservation and private refresh/replay passed. +All 970 opening games and their original seat/recovery records were preserved, +with 1,004 rooms after checks. Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md). The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either @@ -30,12 +31,10 @@ and foreign-key checks passed, and refresh restored the test seat. No restart, database reset or recurring automation was needed. Exact verification, source fingerprints and delivery are recorded in the private report and Git message. -Current work: [Sapho Battle-phase order](SAPHO_BATTLE_ORDER.md) connects first/last -remaining battle choices without rewriting storm order or tie roles. The browser completed a reordered -battle and restored its next choice; independent review and an actual Basic/Advanced -Face Dance continuation passed. Reinforcements -remains disabled while its reserve-cost question is pending; see the -[latest source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review). +Current work: [compound shipment promises](COMPOUND_SHIPMENT_PROMISES.md) connects +shipment-only AND/OR questions, whole-expression enforcement, usable controls, +legal AI answers and saved continuation. Reinforcements and the other recorded +material rule questions remain pending. ## Authoritative navigation diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index dcd604ee..347525c1 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,15 @@ # Dune implementation status +## 14 September 2026 — Compound shipment promises + +[Shipment-only AND/OR](COMPOUND_SHIPMENT_PROMISES.md) extends the existing Truthtrance +shipment question with whole-expression truth, complete destination search, +controls and private AI answers. Earlier separate promises remain jointly binding, +and legacy flat records retain their meaning. The existing timing/module gates +and unresolved material questions stay in place. Source-bound final checks, +browser continuation, private preservation and verified Git delivery are recorded +in the checkpoint report and commit message. The opening baseline is 1,004 rooms. + ## 14 September 2026 — Sapho remaining battle-choice order [The Sapho battle-order prototype](SAPHO_BATTLE_ORDER.md) adds first/last remaining diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index cf85986a..26756ea8 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -37,6 +37,12 @@ Updated 14 September 2026. This is the authoritative navigation index for existi | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | | Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) | +Shipment-only AND/OR now follows the FAQ's whole-question truth: Yes to AND +requires all conditions, Yes to OR requires one, and No negates the whole +expression. One eventual shipment or skip is evaluated against every separate +promise. See [compound shipment promises](COMPOUND_SHIPMENT_PROMISES.md); mixed +present/future predicates and existing timing/module gates remain unfinished. + ## Pending interpretations: preserve existing questions These are unresolved in the cited records. Do not infer an answer from elapsed time, a fixture, a related card or this index. Check subsequent recorded user/source decisions before proceeding; do not send duplicate questions. diff --git a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md index e88a2b12..d824a817 100644 --- a/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md +++ b/docs/TRUTHTRANCE_SHIPMENT_PROMISES.md @@ -1,6 +1,6 @@ # Truthtrance reserve-shipment promises -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. +Original checkpoint, 7 September 2026; Advanced follow-up, 13 September 2026. The [14 September compound follow-up](COMPOUND_SHIPMENT_PROMISES.md) adds shipment-only AND/OR while retaining the boundaries below. 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 diff --git a/game/bots.ts b/game/bots.ts index 663656f2..da7ad06d 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1129,19 +1129,18 @@ function policyActions(g: GameView): Action[] { if (w.question.kind === 'shipment') { const question = w.question; const choices = g.truthShipmentAnswers ?? []; - const target = gameTerritories(g).find( - (t) => t.id === question.territory, - ); - const spice = Object.entries(g.spice) - .filter( - ([key]) => splitLocation(key).territory === question.territory, - ) - .reduce((sum, [, amount]) => sum + amount, 0); - // Destination value and own reserve commitment guide strategy. The - // server's complete answer set, never sampled moves, decides feasibility. - const preferYes = - (target?.type === 'stronghold' || spice >= question.minimum) && - question.minimum <= Math.max(0, me.reserves - [0, 1, 2, 3][level]); + const reserveBudget = Math.min(20, Math.max(0, me.reserves - [0, 1, 2, 3][level])); + // This only ranks the server's feasible answers. It never proves + // impossibility or assumes a hidden hand, future ally funds or a branch. + const preferYes = gameTerritories(g).some(destination => { + const spice = Object.entries(g.spice) + .filter(([key]) => splitLocation(key).territory === destination.id) + .reduce((sum, [, amount]) => sum + amount, 0); + for (let amount = 1; amount <= reserveBudget; amount++) + if ((destination.type === 'stronghold' || spice >= amount) && + matchesShipment(question, { territory: destination.id, amount })) return true; + return false; + }); const preferred = preferYes ? 'yes' : 'no'; const answer = choices.includes(preferred) ? preferred diff --git a/game/engine.ts b/game/engine.ts index 59f0d8c8..8171cec5 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -309,6 +309,9 @@ import { } from './ordered-opportunity'; import { matchesShipment, + parseShipmentClaim, + validShipmentClaim, + shipmentClaimDestinations, liveShipmentPromises, shipmentPromiseModeSupported, type ShipmentClaim, @@ -14601,10 +14604,7 @@ function shipmentPromiseIntegrity(g: Game) { g.players.some( (p) => p.id === promise.asker && p.id !== promise.player, ) && - TERRITORIES.some((t) => t.id === promise.territory) && - Number.isSafeInteger(promise.minimum) && - promise.minimum >= 1 && - promise.minimum <= 20 && + validShipmentClaim(promise) && typeof promise.answer === 'boolean' && (promise.released === undefined || typeof promise.released === 'boolean') && @@ -14652,7 +14652,7 @@ function findShipmentCompletion( !shipmentAvailable(state,owner) ) return null; - if (!promises.some((p) => p.answer)) + if (promises.every(p => matchesShipment(p, null) === p.answer)) return { actions: [{ type: 'endMovement' }] }; const initial = structuredClone(state); initial.truthtrance = null; @@ -14667,6 +14667,7 @@ function findShipmentCompletion( { game: initial, actions: [] }, ]; const visited = new Set(); + const destinations = [...new Set(promises.flatMap(shipmentClaimDestinations))]; for (let index = 0; index < queue.length; index++) { const { game: g, actions } = queue[index]; const p = getPlayer(g, owner.id); @@ -14682,8 +14683,7 @@ function findShipmentCompletion( ]); if (visited.has(signature)) continue; visited.add(signature); - const destination = promises.find((p) => p.answer)!.territory; - for (let amount = 1; amount <= p.reserves; amount++) { + for (const destination of destinations) for (let amount = 1; amount <= p.reserves; amount++) { if ( !promises.every( (p) => @@ -14833,8 +14833,7 @@ function bindShipmentTruth( turn: g.turn, player: p.id, asker, - territory: claim.territory, - minimum: claim.minimum, + ...parseShipmentClaim(claim), answer, }); } diff --git a/game/reference.ts b/game/reference.ts index b285f127..e14b442f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2249,8 +2249,8 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: '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 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.', + 'Declare Truthtrance, select the active player and choose Bind a shipment from reserves. Choose a printed destination and minimum of one to twenty forces, or combine two shipment conditions using AND or OR. Each describes the same shipment. 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 the complete statement to match while possible: all AND conditions or at least one OR condition. No requires the whole statement to be false: at least one AND condition false, or every OR condition false. A smaller count, different destination, transport of forces already on the board or no shipment may satisfy No. 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.', @@ -2264,13 +2264,13 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - '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.', + 'Authoritative base Basic and no-Guild Advanced reserve-origin commitments, shipment-only AND/OR, joint feasibility with owned preparation across all destination branches, actual shipment completion, voluntary-spend guards and opposing release. Broader timing and expansion routes remain unfinished.', }, { area: 'Player controls', status: 'Implemented', detail: - 'Destination/count question, target-private feasible answers, visible accepted promises and private next-step guidance with costs.', + 'Single or AND/OR destination/count questions, target-private feasible answers, visible accepted promises and private next-step guidance with costs.', }, { area: 'AI', @@ -2293,6 +2293,10 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/shipment-promises.test.ts', 'tests/shipment-promises-bots.test.ts', 'tests/shipment-promises-recovery.test.ts', + 'tests/compound-shipment-promises.test.ts', + 'tests/compound-shipment-bots.test.ts', + 'tests/compound-shipment-controls.test.tsx', + 'tests/compound-shipment-recovery.test.ts', ], }, ], @@ -2303,7 +2307,7 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Cards', coverage: 'Partial', summary: - 'Ask another player a public yes/no question. Verified facts, current-battle plan commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', + 'Ask another player a public yes/no question. Verified facts, supported shipment promises, current-battle plan commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', steps: [ 'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.', 'Ask a game-related yes/no question. Named-card, selected-traitor and current personal-spice questions are checked by the server. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.', diff --git a/game/shipment-promises.ts b/game/shipment-promises.ts index 251b409d..b926edf1 100644 --- a/game/shipment-promises.ts +++ b/game/shipment-promises.ts @@ -1,5 +1,113 @@ -/** A current-turn promise about the player's ordinary physical shipment from reserves. */ -export type ShipmentClaim = { territory: string; minimum: number }; +import { TERRITORIES, territory } from './board'; + +/** Every leaf describes the same eventual ordinary reserve shipment, or its absence. */ +export type ShipmentLeaf = { territory: string; minimum: number }; +export type ShipmentExpression = + | ShipmentLeaf + | { op: 'and' | 'or'; terms: ShipmentExpression[] }; +/** Old flat questions and saved promises remain valid without migration. */ +export type ShipmentClaim = + | (ShipmentLeaf & { claim?: never }) + | { claim: ShipmentExpression; territory?: never; minimum?: never }; +export const SHIPMENT_EXPRESSION_MAX_DEPTH = 4; +export const SHIPMENT_EXPRESSION_MAX_LEAVES = 16; +export class ShipmentClaimError extends Error {} +function check(value: unknown, message: string): asserts value { + if (!value) throw new ShipmentClaimError(message); +} +function object(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} +function parseLeaf(value: Record): ShipmentLeaf { + check( + typeof value.territory === 'string' && + TERRITORIES.some((t) => t.id === value.territory), + 'Choose a printed destination territory.', + ); + check( + typeof value.minimum === 'number' && + Number.isSafeInteger(value.minimum) && + value.minimum >= 1 && + value.minimum <= 20, + 'Choose a minimum of one to twenty physical forces.', + ); + return { territory: value.territory, minimum: value.minimum }; +} +/** Bounded software input, not an additional printed limit on asking questions. */ +export function parseShipmentExpression(value: unknown): ShipmentExpression { + let leaves = 0; + const parse = (value: unknown, depth: number): ShipmentExpression => { + check( + object(value) && depth <= SHIPMENT_EXPRESSION_MAX_DEPTH, + 'Use a bounded shipment-only expression with at most four nested groups.', + ); + if (Object.hasOwn(value, 'op')) { + check( + Object.keys(value).sort().join(',') === 'op,terms' && + (value.op === 'and' || value.op === 'or') && + Array.isArray(value.terms) && + value.terms.length >= 2 && + value.terms.length <= SHIPMENT_EXPRESSION_MAX_LEAVES, + 'Join two or more shipment statements with AND or OR.', + ); + return { + op: value.op, + terms: value.terms.map((term) => parse(term, depth + 1)), + }; + } + check( + Object.keys(value).sort().join(',') === 'minimum,territory' && + ++leaves <= SHIPMENT_EXPRESSION_MAX_LEAVES, + 'Use at most sixteen reserve-shipment statements; other fact or action types cannot be mixed in.', + ); + return parseLeaf(value); + }; + return parse(value, 0); +} +/** Extract only claim fields; surrounding question and lifecycle metadata stays separate. */ +export function parseShipmentClaim(value: unknown): ShipmentClaim { + check(object(value), 'Choose a valid shipment statement.'); + if (Object.hasOwn(value, 'claim')) { + check( + !Object.hasOwn(value, 'territory') && !Object.hasOwn(value, 'minimum'), + 'Choose either a legacy shipment statement or an expression, not both.', + ); + return { claim: parseShipmentExpression(value.claim) }; + } + check( + !Object.hasOwn(value, 'op') && !Object.hasOwn(value, 'terms'), + 'Place the shipment expression in its claim field.', + ); + return parseLeaf(value); +} +export function validShipmentClaim(value: unknown): value is ShipmentClaim { + try { + parseShipmentClaim(value); + return true; + } catch (error) { + if (error instanceof ShipmentClaimError) return false; + throw error; + } +} +export function shipmentExpressionOf(claim: ShipmentClaim): ShipmentExpression { + return ( + claim.claim ?? { territory: claim.territory!, minimum: claim.minimum! } + ); +} +export function shipmentClaimDestinations(claim: ShipmentClaim): string[] { + const collect = (expression: ShipmentExpression): string[] => + 'op' in expression + ? expression.terms.flatMap(collect) + : [expression.territory]; + return [...new Set(collect(shipmentExpressionOf(claim)))]; +} +export function shipmentClaimText(claim: ShipmentClaim): string { + const text = (expression: ShipmentExpression): string => + 'op' in expression + ? `(${expression.terms.map(text).join(expression.op === 'and' ? ' AND ' : ' OR ')})` + : `ship at least ${expression.minimum} physical forces from your reserves to ${territory(expression.territory).name}`; + return text(shipmentExpressionOf(claim)); +} /** Software readiness boundary; this is not a printed restriction on Truthtrance. */ export function shipmentPromiseModeSupported(game: { advanced: boolean; @@ -32,11 +140,15 @@ export function matchesShipment( claim: ShipmentClaim, shipment: { territory: string; amount: number } | null, ): boolean { - return ( - !!shipment && - shipment.territory === claim.territory && - shipment.amount >= claim.minimum - ); + const matches = (expression: ShipmentExpression): boolean => + 'op' in expression + ? expression.op === 'and' + ? expression.terms.every(matches) + : expression.terms.some(matches) + : !!shipment && + shipment.territory === expression.territory && + shipment.amount >= expression.minimum; + return matches(shipmentExpressionOf(claim)); } export function liveShipmentPromises( promises: readonly ShipmentPromise[], diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 27995ce2..61af62c8 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,7 +1,11 @@ import { shipmentAvailable } from './shipment-opportunity'; import type { ShipmentClaim } from './shipment-promises'; -import { shipmentPromiseModeSupported } from './shipment-promises'; -import { TERRITORIES } from './board'; +import { + shipmentPromiseModeSupported, + parseShipmentClaim, + shipmentClaimText, + ShipmentClaimError, +} from './shipment-promises'; import { parseCardCountFact, cardCountFactMatches, @@ -178,24 +182,13 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { !g.pendingShipment, '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' && - TERRITORIES.some((t) => t.id === v.territory), - 'Choose a printed destination territory.', - ); - check( - typeof v.minimum === 'number' && - Number.isSafeInteger(v.minimum) && - v.minimum >= 1 && - v.minimum <= 20, - 'Choose a minimum of one to twenty physical forces.', - ); - return { - kind: 'shipment', - target: v.target, - territory: v.territory, - minimum: v.minimum, - }; + try { + return { kind: 'shipment', target: v.target, ...parseShipmentClaim(v) }; + } catch (error) { + if (error instanceof ShipmentClaimError) + throw new TruthError(error.message); + throw error; + } } if (v.kind === 'battlePlan') { check( @@ -354,7 +347,7 @@ export function truthQuestionText( ): string { if (q.kind === 'freeform') return q.text; if (q.kind === 'shipment') - return `Will you ship at least ${q.minimum} physical forces from your reserves to ${territory(q.territory).name} this turn?`; + return `Will you ${shipmentClaimText(q)} this turn?`; if (q.kind === 'battlePlan') return `In this battle in ${territory(q.territory).name}, will it be true that ${planClaimText(q.claim, leaderName)}?`; const clause = (f: TruthFact): string => { diff --git a/tests/compound-shipment-bots.test.ts b/tests/compound-shipment-bots.test.ts new file mode 100644 index 00000000..7d302365 --- /dev/null +++ b/tests/compound-shipment-bots.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { compoundShipmentGame, askCompoundShipment, compoundShipmentCustody } from './fixture-compound-shipment'; +import { matchesShipment } from '../game/shipment-promises'; + +function pending(advanced: boolean) { + const g = compoundShipmentGame(advanced); + Object.assign(g.players[0], { reserves: 10, forces: { 'carthag:11': 10 }, spice: 4 }); + return askCompoundShipment(g, { op: 'or', terms: [ + { territory: 'carthag', minimum: 20 }, { territory: 'arrakeen', minimum: 4 }, + ] }); +} +void test('all four profiles answer and complete the later OR branch when the first branch is impossible', () => { + for (const advanced of [false, true]) for (const level of DIFFICULTIES) { + let g = pending(advanced); + g.players[0].bot = level; + const answer = botActions(viewGame(g, 'p'))[0]; + assert.deepEqual(answer, { type: 'truthAnswer', answer: 'yes' }); + g = applyAction(g, 'p', answer); + const candidates = botActions(viewGame(g, 'p')); + const ship = candidates.find(action => action.type === 'ship'); + assert.ok(ship, `${level} must retain an executable completion beyond its strategic shortlist`); + for (const action of candidates) assert.doesNotThrow(() => applyAction(g, 'p', action)); + const next = applyAction(g, 'p', ship); + assert.equal(next.players[0].spice, 0); + assert.equal(next.players[0].reserves, 6); + const promise = next.shipmentPromises?.[0]; + assert.ok(promise); + assert.equal(promise.fulfilled, true); + assert.equal(matchesShipment(promise, { territory: 'arrakeen', amount: 4 }), true); + compoundShipmentCustody(next); + for (const player of next.players) + assert.deepEqual(JSON.parse(JSON.stringify(viewGame(next, player.id))), + JSON.parse(JSON.stringify(viewGame(JSON.parse(JSON.stringify(next)) as Game, player.id)))); + } +}); + +void test('impossible different-destination AND never makes an AI publish Yes', () => { + for (const level of DIFFICULTIES) { + const g = askCompoundShipment(compoundShipmentGame(), { op: 'and', terms: [ + { territory: 'carthag', minimum: 1 }, { territory: 'arrakeen', minimum: 1 }, + ] }); + g.players[0].bot = level; + assert.deepEqual(botActions(viewGame(g, 'p'))[0], { type: 'truthAnswer', answer: 'no' }); + } +}); + +void test('compound answer ranking does not inspect rival private fields or mutate its supplied view', () => { + const g = pending(true); + g.players[0].bot = 'Hard'; + const original = viewGame(g, 'p'); + const expected = botActions(original); + const view = viewGame(g, 'p'); + for (const rival of view.players.filter(player => player.id !== 'p')) + for (const field of ['hand', 'spice', 'traitors', 'faceDancers']) + Object.defineProperty(rival, field, { get() { throw new Error(`Unentitled ${field} access`); } }); + assert.deepEqual(botActions(view), expected); + assert.deepEqual(viewGame(g, 'p'), original); +}); diff --git a/tests/compound-shipment-controls.test.tsx b/tests/compound-shipment-controls.test.tsx new file mode 100644 index 00000000..4f4ae756 --- /dev/null +++ b/tests/compound-shipment-controls.test.tsx @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { registerHooks } from 'node:module'; +import { ShipmentClaimFields, invalidShipmentClause } from '../components/shipment-claim-fields'; +import { truthQuestionText, type TruthQuestion } from '../game/truthtrance'; +import { applyAction, viewGame } from '../game/engine'; +import { ShipmentPromises } from '../components/shipment-promises'; +import { compoundShipmentGame, askCompoundShipment } from './fixture-compound-shipment'; + +const aliases = registerHooks({ resolve(specifier, context, next) { + return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); +} }); +const { Truthtrance } = await import('../components/truthtrance'); +aliases.deregister(); + +const clauses = [{ territory: 'carthag', minimum: 6 }, { territory: 'arrakeen', minimum: 4 }]; +function markup(join: 'single' | 'and' | 'or', minimum = 4) { + return renderToStaticMarkup(createElement(ShipmentClaimFields, { + id: 'shipment-test', join, clauses: [clauses[0], { ...clauses[1], minimum }], + onJoin() {}, onClauses() {}, + })); +} +void test('compound shipment fields distinguish one shipment from two and retain accessible per-clause labels', () => { + assert.equal((markup('single').match(/

id); + assert.match(text, /Carthag/); + assert.match(text, /Arrakeen/); + assert.match(text, new RegExp(join.toUpperCase())); + } +}); + +void test('empty, fractional and out-of-range compound counts stay invalid rather than coercing to a different promise', () => { + for (const minimum of [NaN, 0, 2.5, 21]) { + assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), true); + assert.match(markup('or', minimum), /id="shipment-test-minimum-1"[^>]*aria-invalid="true"/); + } + for (const minimum of [1, 20]) assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), false); +}); + +void test('the real answer panel explains whole-expression No and offers answers only to the respondent', () => { + const g = askCompoundShipment(compoundShipmentGame(true), { op: 'or', terms: clauses }); + const render = (me: string, busy = false) => renderToStaticMarkup(createElement(Truthtrance, { + game: viewGame(g, me), act() {}, busy, + })); + const own = render('p'); + assert.match(own, /Answer Yes/); + assert.match(own, /Answer No/); + assert.match(own, /for AND, at least one condition must be false/); + assert.match(own, /every condition must be false/); + assert.match(render('p', true), /]*disabled=""[^>]*>Answer Yes/); + for (const other of ['a', 'o']) { + assert.doesNotMatch(render(other), />Answer (Yes|No) { + for (const op of ['and', 'or'] as const) for (const answer of ['yes', 'no'] as const) { + const terms = op === 'and' ? [clauses[0], { territory: 'carthag', minimum: 4 }] : clauses; + const asked = askCompoundShipment(compoundShipmentGame(true), { op, terms }); + const promised = applyAction(asked, 'p', { type: 'truthAnswer', answer }); + const view = viewGame(promised, 'p'); + const html = renderToStaticMarkup(createElement(ShipmentPromises, { game: view, act() {}, busy: false })); + assert.match(html, new RegExp(`You answered ${answer === 'yes' ? 'Yes' : 'No'}`)); + assert.match(html, new RegExp(op.toUpperCase())); + assert.match(html, /Carthag/); + assert.doesNotMatch(html, /undefined/); + if (answer === 'yes') assert.match(html, /Suggested next step/); + for (const rival of ['a', 'o']) assert.equal(renderToStaticMarkup(createElement(ShipmentPromises, { + game: viewGame(promised, rival), act() {}, busy: false, + })), ''); + } +}); diff --git a/tests/compound-shipment-promises.test.ts b/tests/compound-shipment-promises.test.ts new file mode 100644 index 00000000..1febb95c --- /dev/null +++ b/tests/compound-shipment-promises.test.ts @@ -0,0 +1,435 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyAction, + normalizeAutomaticGame, + viewGame, + type Action, + type Game, +} from '../game/engine'; +import { + matchesShipment, + parseShipmentClaim, + parseShipmentExpression, + shipmentClaimDestinations, + shipmentClaimText, + type ShipmentExpression, +} from '../game/shipment-promises'; +import { + compoundShipmentGame, + openCompoundShipmentQuestion, + askCompoundShipment, + compoundShipmentCustody, + holdCompoundShipmentCard, +} from './fixture-compound-shipment'; + +const leaf = (territory: string, minimum: number): ShipmentExpression => ({ + territory, + minimum, +}); +const and = (...terms: ShipmentExpression[]): ShipmentExpression => ({ + op: 'and', + terms, +}); +const or = (...terms: ShipmentExpression[]): ShipmentExpression => ({ + op: 'or', + terms, +}); +const alternative = or(leaf('carthag', 6), leaf('arrakeen', 4)); +const reload = (g: Game): Game => JSON.parse(JSON.stringify(g)); +const answer = (g: Game, answer: 'yes' | 'no') => + applyAction(reload(g), 'p', { type: 'truthAnswer', answer }); +const bind = (g: Game, claim = alternative, value: 'yes' | 'no' = 'yes') => + answer(askCompoundShipment(g, claim), value); +const ship = (g: Game, territory: string, amount: number) => + applyAction(reload(g), 'p', { + type: 'ship', + territory, + sector: territory === 'carthag' ? 11 : 10, + amount, + }); +function reject(g: Game, actor: string, action: Action): void { + const before = structuredClone(g); + assert.throws(() => applyAction(g, actor, action)); + assert.deepEqual(g, before); +} +function restored(g: Game): Game { + const saved = reload(g); + for (const p of g.players) + assert.deepEqual(viewGame(saved, p.id), viewGame(g, p.id)); + compoundShipmentCustody(saved); + return saved; +} +function nextShipment(g: Game): Action { + const next = viewGame(g, 'p').shipmentCompletion?.actions[0]; + assert.ok(next); + for (const id of ['a', 'o']) + assert.equal(viewGame(g, id).shipmentCompletion, null); + return next; +} + +void test('shipment trees evaluate one physical event with aggregate AND/OR truth, bounded shipment-only input and legacy leaves', () => { + const a = leaf('carthag', 4), + b = leaf('arrakeen', 3); + for (const [claim, outcomes] of [ + [and(a, b), [false, false, false, false]], + [or(a, b), [false, true, true, false]], + [and(a, or(leaf('carthag', 6), b)), [false, false, false, false]], + [or(and(a, leaf('carthag', 5)), b), [false, false, true, false]], + ] as const) { + const parsed = parseShipmentClaim({ claim }); + assert.deepEqual( + [ + null, + { territory: 'carthag', amount: 4 }, + { territory: 'arrakeen', amount: 3 }, + { territory: 'carthag', amount: 3 }, + ].map((event) => matchesShipment(parsed, event)), + outcomes, + ); + assert.deepEqual(shipmentClaimDestinations(parsed), [ + 'carthag', + 'arrakeen', + ]); + assert.match(shipmentClaimText(parsed), /ship at least/); + } + assert.deepEqual(parseShipmentClaim({ territory: 'carthag', minimum: 4 }), a); + for (const claim of [ + { op: 'xor', terms: [a, b] }, + { op: 'or', terms: [] }, + { op: 'and', terms: [a] }, + { op: 'or', terms: [a, { kind: 'spice', minimum: 2 }] }, + { op: 'or', terms: [a, { ...b, target: 'o' }] }, + { op: 'and', terms: Array.from({ length: 17 }, () => a) }, + leaf('arrakeen', 0), + leaf('missing', 4), + ]) + assert.throws(() => parseShipmentExpression(claim)); + let tooDeep = a; + for (let n = 0; n < 5; n++) tooDeep = or(a, tooDeep); + assert.throws(() => parseShipmentExpression(tooDeep)); + assert.throws(() => + parseShipmentClaim({ territory: 'carthag', minimum: 4, claim: a }), + ); +}); + +void test('Basic and Advanced OR Yes remain one obligation and can choose a legal nonfirst branch after JSON restoration', () => { + for (const advanced of [false, true]) { + const initial = compoundShipmentGame(advanced); + initial.storm = 11; // Public storm blocks only the first branch. + const asked = askCompoundShipment(initial, alternative); + assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['yes', 'no']); + for (const id of ['a', 'o']) + assert.equal(viewGame(asked, id).truthShipmentAnswers, null); + reject(asked, 'a', { type: 'truthAnswer', answer: 'yes' }); + const promised = restored(answer(asked, 'yes')); + assert.deepEqual(promised.shipmentPromises, [ + { turn: 2, player: 'p', asker: 'a', claim: alternative, answer: true }, + ]); + const next = nextShipment(promised); + assert.equal(next.type, 'ship'); + assert.equal(next.territory, 'arrakeen'); + assert.equal(next.amount, 4); + reject(promised, 'p', { type: 'endMovement' }); + const done = restored(applyAction(promised, 'p', next)); + assert.equal(done.players[0].forces['arrakeen:10'], 4); + assert.equal(done.players[0].reserves, 16); + assert.equal(done.shipmentPromises?.length, 1); + assert.equal(done.shipmentPromises?.[0].fulfilled, true); + assert.equal( + done.discard.filter((c) => c.effect === 'truthtrance').length, + 1, + ); + assert.deepEqual(normalizeAutomaticGame(done), done); + } +}); + +void test('different-destination AND cannot be Yes; No allows either individual branch without flattening', () => { + for (const advanced of [false, true]) { + const asked = askCompoundShipment( + compoundShipmentGame(advanced), + and(leaf('carthag', 6), leaf('arrakeen', 4)), + ); + assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['no']); + reject(asked, 'p', { type: 'truthAnswer', answer: 'yes' }); + const promised = answer(asked, 'no'); + for (const [territory, amount] of [ + ['carthag', 6], + ['arrakeen', 4], + ] as const) { + const done = restored(ship(promised, territory, amount)); + assert.equal(done.shipmentPromises?.[0].fulfilled, true); + } + assert.equal( + applyAction(promised, 'p', { type: 'endMovement' }).shipmentPromises?.[0] + .fulfilled, + true, + ); + } +}); + +void test('nested same-destination thresholds bind the aggregate result, including AND No', () => { + const expression = and( + leaf('carthag', 4), + or(leaf('carthag', 6), leaf('arrakeen', 3)), + ); + for (const advanced of [false, true]) { + const asked = askCompoundShipment( + compoundShipmentGame(advanced), + expression, + ); + assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['yes', 'no']); + const positive = answer(asked, 'yes'); + assert.equal(nextShipment(positive).amount, 6); + reject(positive, 'p', { + type: 'ship', + territory: 'carthag', + sector: 11, + amount: 5, + }); + reject(positive, 'p', { + type: 'ship', + territory: 'arrakeen', + sector: 10, + amount: 3, + }); + assert.equal( + restored(ship(positive, 'carthag', 6)).shipmentPromises?.[0].fulfilled, + true, + ); + const negative = answer(asked, 'no'); + for (const amount of [4, 5]) + assert.equal( + ship(negative, 'carthag', amount).shipmentPromises?.[0].fulfilled, + true, + ); + reject(negative, 'p', { + type: 'ship', + territory: 'carthag', + sector: 11, + amount: 6, + }); + } +}); + +void test('OR No forbids each matching branch and permits fewer forces, another destination or no shipment', () => { + for (const advanced of [false, true]) { + const promised = bind(compoundShipmentGame(advanced), alternative, 'no'); + for (const [territory, amount] of [ + ['carthag', 6], + ['arrakeen', 4], + ] as const) + reject(promised, 'p', { + type: 'ship', + territory, + sector: territory === 'carthag' ? 11 : 10, + amount, + }); + for (const [territory, amount] of [ + ['carthag', 5], + ['arrakeen', 3], + ['imperial_basin', 1], + ] as const) + assert.equal( + restored(ship(promised, territory, amount)).shipmentPromises?.[0] + .fulfilled, + true, + ); + const done = restored( + applyAction(restored(promised), 'p', { type: 'endMovement' }), + ); + assert.equal(done.shipmentPromises?.[0].fulfilled, true); + assert.equal(done.players[0].reserves, 20); + assert.equal(done.players[0].spice, 20); + } +}); + +void test('separate compound and legacy answers remain jointly binding without rewriting old saved leaf shape', () => { + for (const advanced of [false, true]) { + let g = bind(compoundShipmentGame(advanced)); + g = applyAction(openCompoundShipmentQuestion(g), 'a', { + type: 'truthAsk', + question: { + kind: 'shipment', + target: 'p', + territory: 'carthag', + minimum: 1, + }, + }); + g = restored(answer(g, 'no')); + assert.deepEqual(g.shipmentPromises?.[1], { + turn: 2, + player: 'p', + asker: 'a', + territory: 'carthag', + minimum: 1, + answer: false, + }); + assert.equal(Object.hasOwn(g.shipmentPromises![1], 'claim'), false); + assert.equal(nextShipment(g).territory, 'arrakeen'); + reject(g, 'p', { + type: 'ship', + territory: 'carthag', + sector: 11, + amount: 6, + }); + const done = restored(ship(g, 'arrakeen', 4)); + assert.ok(done.shipmentPromises?.every((p) => p.fulfilled)); + assert.equal( + done.discard.filter((c) => c.effect === 'truthtrance').length, + 2, + ); + } +}); + +void test('joint expressions select the only remaining threshold interval, not a first positive destination', () => { + const g = bind( + bind(compoundShipmentGame(), or(leaf('carthag', 6), leaf('arrakeen', 4))), + or(leaf('carthag', 1), leaf('arrakeen', 6)), + 'no', + ); + assert.equal(nextShipment(g).territory, 'arrakeen'); + assert.equal(nextShipment(g).amount, 4); + for (const amount of [4, 5]) + assert.ok( + ship(g, 'arrakeen', amount).shipmentPromises?.every((p) => p.fulfilled), + ); + for (const amount of [3, 6]) + reject(g, 'p', { type: 'ship', territory: 'arrakeen', sector: 10, amount }); +}); + +void test('a later OR branch can depend on real Ghola, retained Karama and recovered outgoing aid preparation', () => { + for (const advanced of [false, true]) { + let g = compoundShipmentGame(advanced); + const p = g.players[0]; + Object.assign(p, { + reserves: 1, + tanks: 5, + forces: { 'arrakeen:10': 14 }, + spice: 3, + ally: 'o', + }); + if (p.elites) + p.elites = { + reserves: 0, + tanks: 0, + forces: { 'arrakeen:10': 5 }, + revived: 0, + }; + g.players[2].ally = 'p'; + g.storm = 11; + const ghola = holdCompoundShipmentCard(g, 'p', 'ghola'); + const karama = holdCompoundShipmentCard(g, 'p', 'karama'); + g = applyAction(g, 'p', { type: 'pledgeAid', amount: 2 }); + g = restored(bind(g, or(leaf('carthag', 6), leaf('arrakeen', 6)))); + const actions: Action[] = []; + for (let n = 0; !g.players[0].shipped && n < 8; n++) { + const next = nextShipment(g); + actions.push(next); + g = restored(applyAction(g, 'p', next)); + } + assert.equal(g.players[0].shipped, true); + assert.equal(g.players[0].forces['arrakeen:10'], 20); + assert.equal(g.players[0].spice, 0); + assert.equal(g.players[0].tanks, 0); + assert.equal(g.aid.p.amount, 0); + assert.ok(actions.some((a) => a.type === 'pledgeAid' && a.amount === 0)); + assert.ok(actions.some((a) => a.card === ghola)); + assert.ok(actions.some((a) => a.card === karama)); + assert.equal(g.shipmentPromises?.[0].fulfilled, true); + } +}); + +void test('loss of one OR alternative preserves the whole obligation and voluntary destruction of its surviving branch rejects', () => { + let g = compoundShipmentGame(); + g.players[0].spice = 2; + g.players[0].ally = 'o'; + g.players[2].ally = 'p'; + g = applyAction(g, 'o', { type: 'pledgeAid', amount: 6 }); + g = bind(g, or(leaf('carthag', 6), leaf('arrakeen', 2))); + assert.equal(nextShipment(g).territory, 'carthag'); + g = restored(applyAction(g, 'o', { type: 'pledgeAid', amount: 0 })); + assert.equal(g.shipmentPromises?.[0].released, undefined); + assert.equal(nextShipment(g).territory, 'arrakeen'); + reject(g, 'p', { type: 'bribe', target: 'a', amount: 1 }); + assert.equal(ship(g, 'arrakeen', 2).shipmentPromises?.[0].fulfilled, true); +}); + +void test('hidden rival wealth and card identities do not change the target-private feasible answer set or continuation shape', () => { + const initial = compoundShipmentGame(true); + initial.players[0].spice = 3; + const asked = askCompoundShipment(initial, alternative); + const changed = reload(asked); + changed.players[2].spice = 1000; + holdCompoundShipmentCard(changed, 'o', 'karama'); + assert.deepEqual(viewGame(asked, 'p').truthShipmentAnswers, ['no']); + assert.deepEqual( + viewGame(changed, 'p').truthShipmentAnswers, + viewGame(asked, 'p').truthShipmentAnswers, + ); + for (const g of [asked, changed]) { + for (const actor of ['a', 'o']) { + assert.equal(viewGame(g, actor).truthShipmentAnswers, null); + assert.equal(viewGame(g, actor).shipmentCompletion, null); + } + const done = answer(g, 'no'); + assert.equal( + done.log.length - g.log.length, + answer(asked, 'no').log.length - asked.log.length, + ); + assert.equal(done.truthtrance, null); + assert.equal(done.response, null); + assert.equal(done.decision, null); + } +}); + +void test('an opponent making every OR branch impossible releases one aggregate obligation exactly once', () => { + let g = compoundShipmentGame(true); + g.players[0].spice = 0; + g.players[0].ally = 'o'; + g.players[2].ally = 'p'; + g = applyAction(g, 'o', { type: 'pledgeAid', amount: 6 }); + g = bind(g); + g = restored(applyAction(g, 'o', { type: 'pledgeAid', amount: 0 })); + assert.equal(g.shipmentPromises?.length, 1); + assert.equal(g.shipmentPromises?.[0].released, true); + assert.equal(g.shipmentPromises?.[0].fulfilled, undefined); + const before = g.log.length; + assert.deepEqual(normalizeAutomaticGame(reload(g)), g); + const done = restored(applyAction(g, 'p', { type: 'endMovement' })); + assert.equal(done.shipmentPromises?.[0].released, true); + assert.equal(done.shipmentPromises?.[0].fulfilled, undefined); + assert.equal( + done.log + .slice(before) + .filter((entry) => JSON.stringify(entry).includes('no longer binding')) + .length, + 0, + ); +}); + +void test('malformed compound questions and saved promises reject before view, action, normalization or autopilot can mutate them', () => { + const ready = openCompoundShipmentQuestion(compoundShipmentGame()); + const invalid = { + op: 'or', + terms: [leaf('carthag', 6), { kind: 'spice', min: 2 }], + }; + reject(ready, 'a', { + type: 'truthAsk', + question: { kind: 'shipment', target: 'p', claim: invalid }, + }); + const pending = askCompoundShipment(compoundShipmentGame(), alternative); + const promised = answer(pending, 'yes'); + for (const source of [pending, promised]) { + const g = reload(source); + if (g.truthtrance?.question) + Object.assign(g.truthtrance.question, { claim: invalid }); + else Object.assign(g.shipmentPromises![0], { claim: invalid }); + const before = structuredClone(g); + for (const p of g.players) assert.throws(() => viewGame(g, p.id)); + assert.throws(() => normalizeAutomaticGame(g)); + reject(g, 'p', { type: 'endMovement' }); + reject(g, 'p', { type: 'setAutopilot', enabled: true }); + assert.deepEqual(g, before); + } +}); diff --git a/tests/compound-shipment-recovery.test.ts b/tests/compound-shipment-recovery.test.ts new file mode 100644 index 00000000..ebcf1e7e --- /dev/null +++ b/tests/compound-shipment-recovery.test.ts @@ -0,0 +1,257 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { DatabaseSync } from "node:sqlite"; +import type { RoomsClock } from "../db/rooms"; +import { viewGame, type Action, type Game } from "../game/engine"; +import type { ShipmentExpression } from "../game/shipment-promises"; +import { + askCompoundShipment, + compoundShipmentCustody, + compoundShipmentGame, +} from "./fixture-compound-shipment"; +import { unitStore } from "./fixture-nexus-room-store"; + +const clock: RoomsClock = { now: () => 10_000, sleep: async () => {} }; +const expression: ShipmentExpression = { + op: "or", + terms: [ + { territory: "carthag", minimum: 6 }, + { territory: "arrakeen", minimum: 4 }, + ], +}; +const qualifying: Action = { + type: "ship", + territory: "arrakeen", + sector: 10, + amount: 4, +}; +const contradictsYes: Action = { + type: "ship", + territory: "arrakeen", + sector: 10, + amount: 3, +}; +const contradictsNo: Action = { + type: "ship", + territory: "carthag", + sector: 11, + amount: 6, +}; + +async function persistedQuestion(advanced: boolean) { + const store = unitStore(); + const sqlite: DatabaseSync = store.sqlite; + const made = await store.rooms.createRoom( + "Compound shipment SQL", + "emperor", + advanced, + [], + ); + const code = made.view.code; + const joined = [ + await store.rooms.joinRoom(code, "Atreides asker", "atreides"), + await store.rooms.joinRoom(code, "Harkonnen observer", "harkonnen"), + ]; + const tokens = [made.token, ...joined.map((seat) => seat.token!)]; + const auths = await Promise.all( + tokens.map((token) => store.rooms.authenticate(code, token)), + ); + const ids = auths.map((auth) => auth.playerId) as [string, string, string]; + const lobby = await store.rooms.readRoom(code); + const question = askCompoundShipment( + compoundShipmentGame(advanced, ids), + expression, + ); + question.code = code; + question.version = lobby.version; + sqlite + .prepare("UPDATE rooms SET state = ?, version = ? WHERE code = ?") + .run(JSON.stringify(question), question.version, code); + store.writes.length = 0; + return { ...store, code, auths, ids, tokens, question }; +} + +async function rejectsWithoutWrite( + fixture: Awaited>, + action: Action, + pattern: RegExp, +) { + const before = await fixture.restart().readRoom(fixture.code); + const writes = fixture.writes.length; + await assert.rejects( + fixture + .restart() + .act(fixture.code, fixture.auths[0], before.version, action, clock), + pattern, + ); + assert.deepEqual(await fixture.restart().readRoom(fixture.code), before); + assert.equal(fixture.writes.length, writes); +} + +function assertSingleCompoundPromise(g: Game, answer: boolean) { + assert.equal(g.shipmentPromises?.length, 1); + assert.deepEqual(g.shipmentPromises![0].claim, expression); + assert.equal(g.shipmentPromises![0].answer, answer); + assert.equal(Object.hasOwn(g.shipmentPromises![0], "territory"), false); + assert.equal(Object.hasOwn(g.shipmentPromises![0], "minimum"), false); + assert.equal(g.truthHistory?.length, 1); + assert.deepEqual(g.truthHistory![0].question, { + kind: "shipment", + target: g.players[0].id, + claim: expression, + }); +} + +async function assertPrivateRestore( + fixture: Awaited>, + answers: boolean, + completion: boolean, +) { + const state = await fixture.restart().readRoom(fixture.code); + for (const index of fixture.auths.keys()) { + const rooms = fixture.restart(); + const restored = await rooms.authenticate( + fixture.code, + fixture.tokens[index], + ); + const view = await rooms.readSeatView(fixture.code, restored); + assert.deepEqual(view, viewGame(state, fixture.ids[index])); + assert.deepEqual( + view.truthShipmentAnswers, + index === 0 && answers ? ["yes", "no"] : null, + ); + assert.equal(!!view.shipmentCompletion, index === 0 && completion); + assert.equal( + JSON.stringify(view).includes("truthShipmentCompletion"), + false, + ); + for (const player of view.players) + if (player.id !== fixture.ids[index]) + assert.equal("hand" in player, false); + } +} + +function concurrentGate() { + let arrivals = 0; + let release!: () => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + return { + wait: async () => { + if (++arrivals === 2) release(); + await waiting; + }, + release, + arrivals: () => arrivals, + }; +} + +for (const advanced of [false, true]) + void test(`${advanced ? "Advanced" : "Basic"} restored compound Yes honors the second OR branch and duplicate shipment commits once`, async () => { + const f = await persistedQuestion(advanced); + try { + await assertPrivateRestore(f, true, false); + const pending = await f.restart().readRoom(f.code); + await f + .restart() + .act( + f.code, + f.auths[0], + pending.version, + { type: "truthAnswer", answer: "yes" }, + clock, + ); + const accepted = await f.restart().readRoom(f.code); + assertSingleCompoundPromise(accepted, true); + assert.equal(accepted.shipmentPromises![0].fulfilled, undefined); + await assertPrivateRestore(f, false, true); + await rejectsWithoutWrite(f, contradictsYes, /Truthtrance|promise/i); + + f.writes.length = 0; + const gate = concurrentGate(); + f.hooks.beforeWrite = gate.wait; + const timer = setTimeout(gate.release, 2_000); + let outcomes: PromiseSettledResult[]; + try { + outcomes = await Promise.allSettled( + [qualifying, qualifying].map((action) => + f + .restart() + .act(f.code, f.auths[0], accepted.version, action, clock), + ), + ); + } finally { + clearTimeout(timer); + delete f.hooks.beforeWrite; + } + assert.equal(gate.arrivals(), 2); + assert.equal( + outcomes.filter((outcome) => outcome.status === "fulfilled").length, + 1, + ); + assert.deepEqual( + f.writes.map((write) => write.changes).sort((a, b) => a - b), + [0, 1], + ); + const done = await f.restart().readRoom(f.code); + assert.equal(done.version, accepted.version + 1); + assertSingleCompoundPromise(done, true); + assert.equal(done.shipmentPromises![0].fulfilled, true); + assert.equal(done.players[0].forces["arrakeen:10"], 4); + assert.equal(done.players[0].reserves, 16); + assert.equal(done.players[0].spice, 16); + assert.equal(done.players[0].shipped, true); + assert.equal( + done.discard.filter((card) => card.effect === "truthtrance").length, + 1, + ); + compoundShipmentCustody(done); + await assertPrivateRestore(f, false, false); + } finally { + delete f.hooks.beforeWrite; + f.sqlite.close(); + } + }); + +for (const advanced of [false, true]) + void test(`${advanced ? "Advanced" : "Basic"} restored compound No rejects a matching shipment and permits a shipment skip`, async () => { + const f = await persistedQuestion(advanced); + try { + const pending = await f.restart().readRoom(f.code); + await f + .restart() + .act( + f.code, + f.auths[0], + pending.version, + { type: "truthAnswer", answer: "no" }, + clock, + ); + const accepted = await f.restart().readRoom(f.code); + assertSingleCompoundPromise(accepted, false); + await assertPrivateRestore(f, false, false); + await rejectsWithoutWrite(f, contradictsNo, /Truthtrance|promise/i); + const beforeSkip = await f.restart().readRoom(f.code); + await f + .restart() + .act( + f.code, + f.auths[0], + beforeSkip.version, + { type: "endMovement" }, + clock, + ); + const skipped = await f.restart().readRoom(f.code); + assertSingleCompoundPromise(skipped, false); + assert.equal(skipped.shipmentPromises![0].fulfilled, true); + assert.equal(skipped.players[0].shipped, false); + assert.deepEqual(skipped.players[0].forces, beforeSkip.players[0].forces); + assert.equal(skipped.players[0].reserves, beforeSkip.players[0].reserves); + assert.equal(skipped.players[0].spice, beforeSkip.players[0].spice); + compoundShipmentCustody(skipped); + await assertPrivateRestore(f, false, false); + } finally { + f.sqlite.close(); + } + }); diff --git a/tests/fixture-compound-shipment.ts b/tests/fixture-compound-shipment.ts new file mode 100644 index 00000000..9952e314 --- /dev/null +++ b/tests/fixture-compound-shipment.ts @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { + applyAction, + createGame, + initializeBaseGameForAudit, + joinGame, + newPlayer, + viewGame, + type Game, +} from '../game/engine'; +import { botActions } from '../game/bots'; +import { baseDeck } from '../game/cards'; +import type { ShipmentExpression } from '../game/shipment-promises'; + +/** Genuine base setup; then explicitly conserved hands, force pools and clean turn-two movement topology. */ +export function compoundShipmentGame( + advanced = false, + seatIds: readonly [string, string, string] = ['p', 'a', 'o'], +): Game { + const factions = ['emperor', 'atreides', 'harkonnen'] as const; + let g = createGame( + 'COMPOUNDSHIP', + newPlayer(seatIds[0], 'Shipper', factions[0]), + advanced, + ); + for (let i = 1; i < factions.length; i++) + joinGame(g, newPlayer(seatIds[i], factions[i], factions[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 < 100; n++) { + let progressed = 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); + progressed = true; + break; + } + assert.ok(progressed, 'Genuine setup has an entitled actor.'); + } + assert.equal(g.status, 'playing'); + assert.equal(g.setupStage, undefined); + Object.assign(g, { + phase: 5, + turn: 2, + active: seatIds[0], + storm: 18, + order: [...seatIds], + movementRemaining: [...seatIds], + response: null, + decision: null, + phaseOpening: null, + }); + for (const p of g.players) { + g.deck.push(...p.hand); + p.hand = []; + Object.assign(p, { + forces: {}, + reserves: 20, + tanks: 0, + spice: 20, + shipped: false, + moved: 0, + revived: 0, + }); + if (p.elites) p.elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 }; + } + compoundShipmentCustody(g); + return g; +} + +/** Scenario preparation only: relocate an existing physical card, never fabricate it. */ +export function holdCompoundShipmentCard( + g: Game, + owner: string, + effect: string, +): string { + const at = g.deck.findIndex((c) => c.effect === effect || c.kind === effect); + assert.ok(at >= 0, `Physical ${effect} remains in the deck.`); + const card = g.deck.splice(at, 1)[0]; + g.players.find((p) => p.id === owner)!.hand.push(card); + return card.id; +} + +/** Clone and perform the real card/priority actions; never mutate the input. */ +export function openCompoundShipmentQuestion(state: Game): Game { + let g: Game = JSON.parse(JSON.stringify(state)); + const asker = g.players[1].id; + const card = holdCompoundShipmentCard(g, asker, 'truthtrance'); + g = applyAction(g, asker, { type: 'card', card }); + for (let n = 0; g.truthtrance?.stage === 'priority' && n < 10; n++) { + const actor = g.players.find((p) => !g.truthtrance!.passed.includes(p.id)); + assert.ok(actor); + g = applyAction(g, actor.id, { type: 'truthPass' }); + } + assert.equal(g.truthtrance?.stage, 'ask'); + compoundShipmentCustody(g); + return g; +} + +/** Start a new real question and return the target-owned answer window. */ +export function askCompoundShipment( + g: Game, + expression: ShipmentExpression, +): Game { + return applyAction(openCompoundShipmentQuestion(g), g.players[1].id, { + type: 'truthAsk', + question: { kind: 'shipment', target: g.players[0].id, claim: expression }, + }); +} + +export function compoundShipmentCustody(g: Game): void { + 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), + 5, + ); + assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks); + for (const [where, amount] of Object.entries(p.elites.forces)) + assert.ok(amount <= (p.forces[where] ?? 0)); + } + } + const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]; + const ids = cards.map((c) => c.id).sort(); + assert.equal(new Set(ids).size, ids.length); + assert.deepEqual( + ids, + baseDeck() + .map((c) => c.id) + .sort(), + ); +} diff --git a/tests/truthtrance-discard-continuations.test.ts b/tests/truthtrance-discard-continuations.test.ts index cb3f43c1..5b7f4873 100644 --- a/tests/truthtrance-discard-continuations.test.ts +++ b/tests/truthtrance-discard-continuations.test.ts @@ -899,7 +899,10 @@ void test('the already-bound shipment promise cannot be deleted, replaced, relea g.shipmentPromises![0].fulfilled = true; }, (g) => { - g.shipmentPromises![0].minimum++; + const promise = g.shipmentPromises![0]; + assert.equal(promise.claim, undefined); + assert.ok(typeof promise.minimum === 'number'); + promise.minimum++; }, (g) => { g.shipmentPromises![0].player = 'p';