Support mixed current-fact and shipment Truthtrance questions

This commit is contained in:
Quintine committed 2026-10-03 07:11:28 +10:00
1 parent 0ceb28bd31
commit cd985c7c34
20 files changed
+982 -326

No files matched your search

+71 -20
View File
@@ -1,9 +1,12 @@
import { TERRITORIES, territory } from './board';
import type { MixedShipmentExpression } from './mixed-shipment-question';
import type { TruthAnswer } from './truthtrance';
/** Every leaf describes the same eventual ordinary reserve shipment, or its absence. */
/** Every shipment leaf describes the same eventual reserve shipment, or its absence. */
export type ShipmentLeaf = { territory: string; minimum: number };
export type ShipmentExpression =
| ShipmentLeaf
| { constant: TruthAnswer }
| { op: 'and' | 'or'; terms: ShipmentExpression[] };
/** Old flat questions and saved promises remain valid without migration. */
export type ShipmentClaim =
@@ -12,6 +15,8 @@ export type ShipmentClaim =
export const SHIPMENT_EXPRESSION_MAX_DEPTH = 4;
export const SHIPMENT_EXPRESSION_MAX_LEAVES = 16;
export class ShipmentClaimError extends Error {}
/** Only trusted stored mixed claims may contain frozen current-fact answers. */
export type ShipmentParseOptions = { allowConstants?: boolean };
function check(value: unknown, message: string): asserts value {
if (!value) throw new ShipmentClaimError(message);
}
@@ -34,7 +39,10 @@ function parseLeaf(value: Record<string, unknown>): ShipmentLeaf {
return { territory: value.territory, minimum: value.minimum };
}
/** Bounded software input, not an additional printed limit on asking questions. */
export function parseShipmentExpression(value: unknown): ShipmentExpression {
export function parseShipmentExpression(
value: unknown,
options: ShipmentParseOptions = {},
): ShipmentExpression {
let leaves = 0;
const parse = (value: unknown, depth: number): ShipmentExpression => {
check(
@@ -55,6 +63,18 @@ export function parseShipmentExpression(value: unknown): ShipmentExpression {
terms: value.terms.map((term) => parse(term, depth + 1)),
};
}
if (Object.hasOwn(value, 'constant')) {
check(
options.allowConstants === true &&
Object.keys(value).join(',') === 'constant' &&
(value.constant === 'yes' ||
value.constant === 'no' ||
value.constant === 'unknown') &&
++leaves <= SHIPMENT_EXPRESSION_MAX_LEAVES,
'Frozen fact answers are reserved for internal mixed shipment claims.',
);
return { constant: value.constant };
}
check(
Object.keys(value).sort().join(',') === 'minimum,territory' &&
++leaves <= SHIPMENT_EXPRESSION_MAX_LEAVES,
@@ -65,24 +85,32 @@ export function parseShipmentExpression(value: unknown): ShipmentExpression {
return parse(value, 0);
}
/** Extract only claim fields; surrounding question and lifecycle metadata stays separate. */
export function parseShipmentClaim(value: unknown): ShipmentClaim {
export function parseShipmentClaim(
value: unknown,
options: ShipmentParseOptions = {},
): 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) };
return { claim: parseShipmentExpression(value.claim, options) };
}
check(
!Object.hasOwn(value, 'op') && !Object.hasOwn(value, 'terms'),
!Object.hasOwn(value, 'op') &&
!Object.hasOwn(value, 'terms') &&
!Object.hasOwn(value, 'constant'),
'Place the shipment expression in its claim field.',
);
return parseLeaf(value);
}
export function validShipmentClaim(value: unknown): value is ShipmentClaim {
export function validShipmentClaim(
value: unknown,
options: ShipmentParseOptions = {},
): value is ShipmentClaim {
try {
parseShipmentClaim(value);
parseShipmentClaim(value, options);
return true;
} catch (error) {
if (error instanceof ShipmentClaimError) return false;
@@ -98,14 +126,24 @@ export function shipmentClaimDestinations(claim: ShipmentClaim): string[] {
const collect = (expression: ShipmentExpression): string[] =>
'op' in expression
? expression.terms.flatMap(collect)
: [expression.territory];
: 'constant' in expression
? []
: [expression.territory];
return [...new Set(collect(shipmentExpressionOf(claim)))];
}
export function shipmentClaimText(claim: ShipmentClaim): string {
const text = (expression: ShipmentExpression): string =>
'op' in expression
export function shipmentClaimText(
claim: ShipmentClaim & { mixed?: MixedShipmentExpression },
): string {
check(!claim.mixed, 'Format a mixed promise using its original public question.');
const text = (expression: ShipmentExpression): string => {
check(
!('constant' in expression),
'Format a compiled mixed claim using its original question, not its private answers.',
);
return '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. */
@@ -133,21 +171,34 @@ export type ShipmentPromise = ShipmentClaim & {
player: string;
asker: string;
answer: boolean;
/** Public original question; the normalized claim remains private to its respondent. */
mixed?: MixedShipmentExpression;
released?: boolean;
fulfilled?: boolean;
};
export function matchesShipment(
claim: ShipmentClaim,
shipment: { territory: string; amount: number } | null,
): boolean {
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;
): boolean | null {
const matches = (expression: ShipmentExpression): boolean | null => {
if ('constant' in expression)
return expression.constant === 'unknown'
? null
: expression.constant === 'yes';
if ('op' in expression) {
let unknown = false;
for (const term of expression.terms) {
const result = matches(term);
if (expression.op === 'and' && result === false) return false;
if (expression.op === 'or' && result === true) return true;
if (result === null) unknown = true;
}
return unknown ? null : expression.op === 'and';
}
return !!shipment &&
shipment.territory === expression.territory &&
shipment.amount >= expression.minimum;
};
return matches(shipmentExpressionOf(claim));
}
export function liveShipmentPromises(