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
This commit is contained in:
Quintine committed 2026-09-14 05:43:00 +10:00
1 parent cb691e1b14
commit 9f4de647ce
19 files changed
+1306 -117

No files matched your search

+12 -13
View File
@@ -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
+8 -9
View File
@@ -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<string>();
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,
});
}
+9 -5
View File
@@ -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.',
+119 -7
View File
@@ -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<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function parseLeaf(value: Record<string, unknown>): 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[],
+14 -21
View File
@@ -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 => {