+ 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({
{promises.map((p, index) => (
- {p.answer ? 'Ship' : 'Do not ship'} at least {p.minimum} physical
- forces from reserves to {territory(p.territory).name} this turn.
+ You answered {p.answer ? 'Yes' : 'No'} to: Will you{' '}
+ {shipmentClaimText(p)} this turn?
))}
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) => (
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(/