Support mixed current-fact and shipment Truthtrance questions
This commit is contained in:
1 parent
0ceb28bd31
commit
cd985c7c34
20 files changed
+982
-326
No files matched your search
+71
-20
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user