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
+8
-2
@@ -1168,6 +1168,11 @@ function policyActions(g: GameView): Action[] {
|
||||
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
|
||||
if (w.stage === 'answer') {
|
||||
if (w.question?.target !== me.id) return [];
|
||||
if (w.question.kind === 'mixedShipment') {
|
||||
const choices = g.truthShipmentAnswers ?? [];
|
||||
const answer = choices.find(choice => choice === 'yes' || choice === 'no') ?? choices[0];
|
||||
return answer ? [{type:'truthAnswer',answer}] : [];
|
||||
}
|
||||
if (w.question.kind === 'shipment') {
|
||||
const question = w.question;
|
||||
const choices = g.truthShipmentAnswers ?? [];
|
||||
@@ -4070,7 +4075,8 @@ export function botActions(g: GameView): Action[] {
|
||||
);
|
||||
});
|
||||
const witness = g.shipmentCompletion?.actions[0];
|
||||
if (promises.some((promise) => promise.answer) && witness) {
|
||||
const mustShip = promises.some(promise => matchesShipment(promise,null) !== promise.answer);
|
||||
if (mustShip && witness) {
|
||||
// A completion is a sequence, not interchangeable alternatives. Reproject
|
||||
// after its first preparation before offering the later funded shipment.
|
||||
return [
|
||||
@@ -4080,7 +4086,7 @@ export function botActions(g: GameView): Action[] {
|
||||
),
|
||||
];
|
||||
}
|
||||
return filtered.length || promises.some((promise) => promise.answer)
|
||||
return filtered.length || mustShip
|
||||
? filtered
|
||||
: [{ type: 'endMovement' }];
|
||||
}
|
||||
|
||||
+20
-11
@@ -498,12 +498,14 @@ import {
|
||||
validateTruthQuestionReceipt,
|
||||
queuedTruthCardMatches,
|
||||
truthFactAnswer,
|
||||
truthShipmentClaim,
|
||||
TruthError,
|
||||
type TruthWindow,
|
||||
type TruthQueueEntry,
|
||||
type TruthRecord,
|
||||
type TruthAnswer,
|
||||
} from './truthtrance';
|
||||
import type { MixedShipmentExpression } from './mixed-shipment-question';
|
||||
import { controlsLeader, nativeAvailable } from './leader-control';
|
||||
import {
|
||||
newRevivalRules,
|
||||
@@ -5261,7 +5263,7 @@ function finishTruthtranceAnswer(g: Game) {
|
||||
const record = g.truthHistory![historyIndex];
|
||||
const card = discard(g, getPlayer(g, consumed.player), consumed.card);
|
||||
let promise: TruthDiscardContinuation['promise'] = null;
|
||||
if (record.question.kind === 'shipment')
|
||||
if (record.question.kind === 'shipment' || record.question.kind === 'mixedShipment')
|
||||
promise = {
|
||||
kind: 'shipment',
|
||||
index: g.shipmentPromises!.length - 1,
|
||||
@@ -5998,7 +6000,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
}
|
||||
const q = record.question,
|
||||
promise = c.promise;
|
||||
const expectedShipment = q.kind === 'shipment';
|
||||
const expectedShipment = q.kind === 'shipment' || q.kind === 'mixedShipment';
|
||||
const expectedBattle =
|
||||
q.kind === 'battlePlan' &&
|
||||
g.battle &&
|
||||
@@ -6027,11 +6029,14 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
);
|
||||
if (promise.kind === 'shipment')
|
||||
requireRule(
|
||||
q.kind === 'shipment' &&
|
||||
(q.kind === 'shipment' || q.kind === 'mixedShipment') &&
|
||||
promise.value.turn === g.turn &&
|
||||
!promise.value.fulfilled &&
|
||||
promise.value.territory === q.territory &&
|
||||
promise.value.minimum === q.minimum,
|
||||
(q.kind === 'mixedShipment'
|
||||
? JSON.stringify(promise.value.mixed) === JSON.stringify(q.mixed)
|
||||
: promise.value.mixed === undefined &&
|
||||
promise.value.territory === q.territory &&
|
||||
promise.value.minimum === q.minimum),
|
||||
'The completed Truthtrance shipment promise differs from its answer.',
|
||||
);
|
||||
else
|
||||
@@ -19289,7 +19294,7 @@ function shipmentPromiseIntegrity(g: Game) {
|
||||
g.players.some(
|
||||
(p) => p.id === promise.asker && p.id !== promise.player,
|
||||
) &&
|
||||
validShipmentClaim(promise) &&
|
||||
validShipmentClaim(promise, {allowConstants: promise.mixed !== undefined}) &&
|
||||
typeof promise.answer === 'boolean' &&
|
||||
(promise.released === undefined ||
|
||||
typeof promise.released === 'boolean') &&
|
||||
@@ -19513,12 +19518,14 @@ function bindShipmentTruth(
|
||||
claim: ShipmentClaim,
|
||||
answer: boolean,
|
||||
asker: string,
|
||||
mixed?: MixedShipmentExpression,
|
||||
) {
|
||||
(g.shipmentPromises ??= []).push({
|
||||
turn: g.turn,
|
||||
player: p.id,
|
||||
asker,
|
||||
...parseShipmentClaim(claim),
|
||||
...parseShipmentClaim(claim, {allowConstants: mixed !== undefined}),
|
||||
...(mixed ? {mixed} : {}),
|
||||
answer,
|
||||
});
|
||||
}
|
||||
@@ -30511,15 +30518,17 @@ export function viewGame(state: Game, id: string) {
|
||||
truthShipmentAnswers:
|
||||
g.truthtrance?.stage === 'answer' &&
|
||||
g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'shipment'
|
||||
? shipmentTruthAnswers(g, me, g.truthtrance.question)
|
||||
(g.truthtrance.question.kind === 'shipment' || g.truthtrance.question.kind === 'mixedShipment')
|
||||
? shipmentTruthAnswers(g, me, truthShipmentClaim(g,me,g.truthtrance.question))
|
||||
: null,
|
||||
shipmentPromises: g.shipmentPromises ?? [],
|
||||
// Mixed compiled claims contain private past-clause answers. The complete
|
||||
// original question and whole answer remain public through truthHistory.
|
||||
shipmentPromises: (g.shipmentPromises ?? []).filter(p => !p.mixed || p.player === id),
|
||||
shipmentCompletion:
|
||||
!g.pendingTreacheryDiscard &&
|
||||
!g.response && !g.pendingKarama &&
|
||||
liveShipmentPromises(g.shipmentPromises ?? [], id, g.turn).some(
|
||||
(p) => p.answer,
|
||||
(p) => matchesShipment(p,null) !== p.answer,
|
||||
)
|
||||
? findShipmentCompletion(g, me)
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
parseShipmentExpression,
|
||||
shipmentClaimText,
|
||||
ShipmentClaimError,
|
||||
SHIPMENT_EXPRESSION_MAX_DEPTH,
|
||||
SHIPMENT_EXPRESSION_MAX_LEAVES,
|
||||
type ShipmentExpression,
|
||||
} from './shipment-promises';
|
||||
import type { TruthAnswer, TruthFact } from './truthtrance';
|
||||
|
||||
/** Public question structure: no frozen answers or other future actions. */
|
||||
export type MixedShipmentExpression =
|
||||
| { kind: 'fact'; fact: TruthFact }
|
||||
| { kind: 'shipment'; territory: string; minimum: number }
|
||||
| { kind: 'and' | 'or'; terms: MixedShipmentExpression[] };
|
||||
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new ShipmentClaimError(message);
|
||||
}
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
check(
|
||||
value && typeof value === 'object' && !Array.isArray(value),
|
||||
'Choose a current fact, reserve shipment, or AND/OR group.',
|
||||
);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Reuse the authoritative fact decoder, then enforce one budget across both trees. */
|
||||
export function parseMixedShipmentExpression(
|
||||
value: unknown,
|
||||
parseFact: (value: unknown) => TruthFact,
|
||||
): MixedShipmentExpression {
|
||||
let leaves = 0;
|
||||
let hasFact = false;
|
||||
let hasShipment = false;
|
||||
const count = (depth: number, leaf: boolean): void => {
|
||||
check(
|
||||
depth <= SHIPMENT_EXPRESSION_MAX_DEPTH &&
|
||||
(!leaf || ++leaves <= SHIPMENT_EXPRESSION_MAX_LEAVES),
|
||||
'Truthtrance supports at most 16 logical clauses and four levels of grouping.',
|
||||
);
|
||||
};
|
||||
const countFact = (fact: TruthFact, depth: number): void => {
|
||||
count(depth, fact.kind !== 'and' && fact.kind !== 'or');
|
||||
if (fact.kind === 'and' || fact.kind === 'or')
|
||||
for (const term of fact.terms) countFact(term, depth + 1);
|
||||
};
|
||||
const parse = (input: unknown, depth: number): MixedShipmentExpression => {
|
||||
const v = record(input);
|
||||
if (v.kind === 'fact') {
|
||||
check(
|
||||
Object.keys(v).sort().join(',') === 'fact,kind',
|
||||
'Supply only the original current fact, not a frozen answer.',
|
||||
);
|
||||
const fact = parseFact(v.fact);
|
||||
countFact(fact, depth);
|
||||
hasFact = true;
|
||||
return { kind: 'fact', fact };
|
||||
}
|
||||
count(depth, v.kind === 'shipment');
|
||||
if (v.kind === 'shipment') {
|
||||
check(
|
||||
Object.keys(v).sort().join(',') === 'kind,minimum,territory',
|
||||
'Supply a printed reserve-shipment destination and minimum.',
|
||||
);
|
||||
const leaf = parseShipmentExpression({
|
||||
territory: v.territory,
|
||||
minimum: v.minimum,
|
||||
});
|
||||
check('territory' in leaf, 'Choose a reserve-shipment statement.');
|
||||
hasShipment = true;
|
||||
return { kind: 'shipment', ...leaf };
|
||||
}
|
||||
check(
|
||||
Object.keys(v).sort().join(',') === 'kind,terms' &&
|
||||
(v.kind === 'and' || v.kind === 'or') &&
|
||||
Array.isArray(v.terms) &&
|
||||
v.terms.length >= 2 &&
|
||||
v.terms.length <= SHIPMENT_EXPRESSION_MAX_LEAVES,
|
||||
'Join two or more current facts or shipment statements with AND or OR.',
|
||||
);
|
||||
return { kind: v.kind, terms: v.terms.map((term) => parse(term, depth + 1)) };
|
||||
};
|
||||
const mixed = parse(value, 0);
|
||||
check(
|
||||
hasFact && hasShipment,
|
||||
'A mixed question must include a current fact and a reserve-shipment statement.',
|
||||
);
|
||||
return mixed;
|
||||
}
|
||||
|
||||
/** Freeze facts at answer time; later custody changes cannot rewrite the obligation. */
|
||||
export function compileMixedShipmentExpression(
|
||||
mixed: MixedShipmentExpression,
|
||||
answerFact: (fact: TruthFact) => TruthAnswer,
|
||||
): ShipmentExpression {
|
||||
if (mixed.kind === 'fact') return { constant: answerFact(mixed.fact) };
|
||||
if (mixed.kind === 'shipment')
|
||||
return { territory: mixed.territory, minimum: mixed.minimum };
|
||||
return {
|
||||
op: mixed.kind,
|
||||
terms: mixed.terms.map((term) => compileMixedShipmentExpression(term, answerFact)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Public text is derived solely from the original question, never compiled answers. */
|
||||
export function mixedShipmentExpressionText(
|
||||
mixed: MixedShipmentExpression,
|
||||
factText: (fact: TruthFact) => string,
|
||||
): string {
|
||||
if (mixed.kind === 'fact') return factText(mixed.fact);
|
||||
if (mixed.kind === 'shipment')
|
||||
return `you ${shipmentClaimText({ territory: mixed.territory, minimum: mixed.minimum })} this turn`;
|
||||
return `(${mixed.terms.map((term) => mixedShipmentExpressionText(term, factText))
|
||||
.join(mixed.kind === 'and' ? ' AND ' : ' OR ')})`;
|
||||
}
|
||||
+7
-5
@@ -2687,9 +2687,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'All four AI profiles answer supported questions and retain a complete legal preparation route when their normal strategic shortlist misses the required count or destination. Earlier-phase questions, Advanced games with Guild or optional modules, expansion shipment promises, combined shipment/fact logic and arbitrary freeform promises remain unfinished.',
|
||||
'Accepted whole answers are public and constrain the event together with earlier promises. When an answer requires a shipment, honor it before ground movement or finishing and do not voluntarily spend away the last legal completion. A completed opposing effect that makes compliance impossible releases it publicly.',
|
||||
'Choose Mixed current fact and shipment for nested AND/OR groups of supported current facts and this-turn reserve shipments. Current facts freeze privately when answered: Yes OR a true current fact can allow skipping even after later losing that holding; No AND a false current fact can allow shipping even after that fact changes. Unknown is not false. Only the whole question and answer are public.',
|
||||
'Your own promises show the original grouped question, not private compiled clause results. Optional private next-step guidance includes preparation, funding or shipment. All four legal policies use server-feasible choices and the same complete route. Earlier phases, Advanced Guild/other modules, expansion shipment modes, other future actions and arbitrary prose enforcement remain unfinished.',
|
||||
],
|
||||
example:
|
||||
'Yes to at least six forces to Carthag and No to at least eight there leaves a shipment of six or seven. Moving existing forces into Carthag does not fulfill either shipment event.',
|
||||
@@ -2699,13 +2699,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'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.',
|
||||
'Authoritative supported reserve-origin commitments, shipment and mixed current-fact AND/OR, frozen private clause values, joint legal preparation, actual shipment/skip, voluntary-spend guards and opposing release. Broader timing and expansion routes remain unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Implemented',
|
||||
detail:
|
||||
'Single or AND/OR destination/count questions, target-private feasible answers, visible accepted promises and private next-step guidance with costs.',
|
||||
'Single shipment questions and complete bounded nested mixed authoring, grouped public preview, target-private feasible answers, original-question promise text and optional private next steps.',
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
@@ -2732,6 +2732,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/compound-shipment-bots.test.ts',
|
||||
'tests/compound-shipment-controls.test.tsx',
|
||||
'tests/compound-shipment-recovery.test.ts',
|
||||
'tests/mixed-shipment-question.test.ts',
|
||||
'tests/mixed-shipment-engine.test.ts',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+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(
|
||||
|
||||
+35
-6
@@ -6,6 +6,12 @@ import {
|
||||
shipmentClaimText,
|
||||
ShipmentClaimError,
|
||||
} from './shipment-promises';
|
||||
import {
|
||||
parseMixedShipmentExpression,
|
||||
compileMixedShipmentExpression,
|
||||
mixedShipmentExpressionText,
|
||||
type MixedShipmentExpression,
|
||||
} from './mixed-shipment-question';
|
||||
import {
|
||||
parseCardCountFact,
|
||||
cardCountFactMatches,
|
||||
@@ -44,6 +50,7 @@ export type TruthFact =
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type TruthQuestion =
|
||||
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
|
||||
| { kind: 'mixedShipment'; target: string; mixed: MixedShipmentExpression }
|
||||
| { kind: 'battlePlan'; target: string; territory: string; claim: PlanClaim }
|
||||
| { kind: 'fact'; target: string; fact: TruthFact }
|
||||
| {
|
||||
@@ -180,7 +187,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
g.players.some((p) => p.id === v.target),
|
||||
'Ask one other player.',
|
||||
);
|
||||
if (v.kind === 'shipment') {
|
||||
if (v.kind === 'shipment' || v.kind === 'mixedShipment') {
|
||||
check(
|
||||
shipmentPromiseModeSupported(g) &&
|
||||
g.status === 'playing' &&
|
||||
@@ -197,6 +204,13 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
'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.',
|
||||
);
|
||||
try {
|
||||
if (v.kind === 'mixedShipment') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,mixed,target',
|
||||
'Supply only the mixed question and its respondent.');
|
||||
const respondent = g.players.find(p => p.id === v.target)!;
|
||||
return {kind:'mixedShipment',target:v.target,mixed:parseMixedShipmentExpression(v.mixed,
|
||||
fact => parseFact(g,fact,respondent))};
|
||||
}
|
||||
return { kind: 'shipment', target: v.target, ...parseShipmentClaim(v) };
|
||||
} catch (error) {
|
||||
if (error instanceof ShipmentClaimError)
|
||||
@@ -288,7 +302,7 @@ export function validateSavedTruthtrance(g: Game) {
|
||||
'The saved fact question no longer matches its recorded form.');
|
||||
return;
|
||||
}
|
||||
if (window?.question?.kind !== 'shipment') return;
|
||||
if (window?.question?.kind !== 'shipment' && window?.question?.kind !== 'mixedShipment') return;
|
||||
check(
|
||||
(window.stage === 'answer' || window.stage === 'unknown') &&
|
||||
Array.isArray(window.queue) &&
|
||||
@@ -364,6 +378,17 @@ export function truthFactAnswer(
|
||||
? 'unknown'
|
||||
: 'no';
|
||||
}
|
||||
/** Resolve current clauses only for the respondent's private feasibility or definite answer. */
|
||||
export function truthShipmentClaim(
|
||||
g: Game,
|
||||
p: Player,
|
||||
question: Extract<TruthQuestion,{kind:'shipment'|'mixedShipment'}>,
|
||||
): ShipmentClaim {
|
||||
return question.kind === 'shipment' ? question : {
|
||||
claim: compileMixedShipmentExpression(question.mixed,
|
||||
fact => truthFactAnswer(p,fact,truthKnowledgeOf(g,p))),
|
||||
};
|
||||
}
|
||||
export function truthQuestionText(
|
||||
q: TruthQuestion,
|
||||
leaderName: (id: string) => string,
|
||||
@@ -384,7 +409,8 @@ export function truthQuestionText(
|
||||
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
|
||||
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
|
||||
};
|
||||
return `Is it true that ${clause(q.fact)}?`;
|
||||
return `Is it true that ${q.kind === 'mixedShipment'
|
||||
? mixedShipmentExpressionText(q.mixed,clause) : clause(q.fact)}?`;
|
||||
}
|
||||
/** The interrupt overlays existing state. No battle/auction/response objects are moved or reconstructed. */
|
||||
export function resolveTruthAction(
|
||||
@@ -405,6 +431,7 @@ export function resolveTruthAction(
|
||||
claim: ShipmentClaim,
|
||||
answer: boolean,
|
||||
asker: string,
|
||||
mixed?: MixedShipmentExpression,
|
||||
) => void;
|
||||
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
|
||||
bindBattle: (
|
||||
@@ -554,13 +581,15 @@ export function resolveTruthAction(
|
||||
a.answer === truthFactAnswer(p, q.fact, truthKnowledgeOf(g, p)),
|
||||
'Answer this fact question truthfully.',
|
||||
);
|
||||
if (q.kind === 'shipment') {
|
||||
if (q.kind === 'shipment' || q.kind === 'mixedShipment') {
|
||||
const claim = truthShipmentClaim(g,p,q);
|
||||
check(
|
||||
effects.shipmentAnswers(g, p, q).includes(a.answer),
|
||||
effects.shipmentAnswers(g, p, claim).includes(a.answer),
|
||||
'Choose an answer consistent with your available shipment and earlier promises.',
|
||||
);
|
||||
if (a.answer !== 'unknown')
|
||||
effects.bindShipment(g, p, q, a.answer === 'yes', current.player);
|
||||
effects.bindShipment(g, p, claim, a.answer === 'yes', current.player,
|
||||
q.kind === 'mixedShipment' ? q.mixed : undefined);
|
||||
}
|
||||
if (q.kind === 'battlePlan') {
|
||||
check(
|
||||
|
||||
Reference in new issue
Block a user