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
@@ -4523,7 +4523,7 @@ export function GameTable({
|
||||
g.shipmentPromises,
|
||||
me.id,
|
||||
g.turn,
|
||||
).some((promise) => promise.answer)
|
||||
).some(promise => matchesShipment(promise,null) !== promise.answer)
|
||||
? 'Complete the promised shipment before moving.'
|
||||
: null
|
||||
}
|
||||
@@ -4589,7 +4589,7 @@ export function GameTable({
|
||||
g.shipmentPromises,
|
||||
me.id,
|
||||
g.turn,
|
||||
).some((promise) => promise.answer)
|
||||
).some(promise => matchesShipment(promise,null) !== promise.answer)
|
||||
? 'Complete the promised shipment before moving.'
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useId, type ReactElement } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import type { MixedShipmentExpression } from '@/game/mixed-shipment-question';
|
||||
import { TRUTH_CARD_NAMES, type TruthFact } from '@/game/truthtrance';
|
||||
import { SHIPMENT_EXPRESSION_MAX_DEPTH, SHIPMENT_EXPRESSION_MAX_LEAVES } from '@/game/shipment-promises';
|
||||
import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { CardCountFields } from './truthtrance-card-count';
|
||||
import { KnowledgeFactFields } from './truthtrance-knowledge';
|
||||
import { ForceCountFields, forceCountInputError } from './truthtrance-force-count';
|
||||
import { ShipmentClauseFields, invalidShipmentClause } from './shipment-claim-fields';
|
||||
|
||||
export function invalidTruthFact(fact: TruthFact, game: GameView, target: string): boolean {
|
||||
if (fact.kind === 'and' || fact.kind === 'or') {
|
||||
return fact.terms.some(term => invalidTruthFact(term, game, target));
|
||||
}
|
||||
if (fact.kind === 'spice' || fact.kind === 'handCount' || fact.kind === 'handInventory') {
|
||||
return !Number.isSafeInteger(fact.value) || fact.value < 0;
|
||||
}
|
||||
if (isKnowledgeFact(fact)) return !!knowledgeFactInputError(fact);
|
||||
if (fact.kind === 'forceCount') {
|
||||
return !!forceCountInputError(fact, game, game.players.find(player => player.id === target)!);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The same current-fact fields serve standalone and mixed questions. */
|
||||
export function TruthFactFields({ value: fact, game, target, onChange }: {
|
||||
value: TruthFact;
|
||||
game: GameView;
|
||||
target: string;
|
||||
onChange: (fact: TruthFact) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const defaults: Record<string, TruthFact> = {
|
||||
hand: { kind: 'hand', name: 'Shield' },
|
||||
handCount: { kind: 'handCount', name: 'Shield', compare: 'gte', value: 2 },
|
||||
handInventory: { kind: 'handInventory', category: 'all', compare: 'gte', value: 2 },
|
||||
traitor: { kind: 'traitor', leader: game.allLeaders[0].id },
|
||||
predictionFaction: { kind: 'prediction', field: 'faction', faction: 'atreides' },
|
||||
predictionTurn: { kind: 'prediction', field: 'turn', compare: 'gte', value: 1 },
|
||||
stormDial: { kind: 'stormDial', compare: 'gte', value: 0 },
|
||||
stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 },
|
||||
spice: { kind: 'spice', compare: 'gte', value: 6 },
|
||||
forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 },
|
||||
};
|
||||
return <>
|
||||
<label>
|
||||
Current fact type
|
||||
<select value={fact.kind === 'prediction' ? fact.field === 'faction' ? 'predictionFaction' : 'predictionTurn' : fact.kind}
|
||||
onChange={event => onChange(defaults[event.target.value])}>
|
||||
<option value="hand">Holds a named card now</option>
|
||||
<option value="handCount">Current number of a named card</option>
|
||||
<option value="handInventory">Current hand size or primary card role</option>
|
||||
<option value="traitor">Selected a traitor</option>
|
||||
<option value="predictionFaction">Stored faction prediction</option>
|
||||
<option value="predictionTurn">Stored turn prediction</option>
|
||||
<option value="stormDial">Known storm dial</option>
|
||||
<option value="stormForecast">Known storm forecast</option>
|
||||
<option value="spice">Current personal spice</option>
|
||||
<option value="forceCount">Current physical forces</option>
|
||||
</select>
|
||||
</label>
|
||||
{fact.kind === 'hand' ? <label>
|
||||
Card name
|
||||
<select value={fact.name} onChange={event => onChange({ kind: 'hand', name: event.target.value })}>
|
||||
{TRUTH_CARD_NAMES.map(name => <option key={name}>{name}</option>)}
|
||||
</select>
|
||||
</label> : fact.kind === 'handCount' || fact.kind === 'handInventory' ?
|
||||
<CardCountFields value={fact} onChange={onChange} /> : fact.kind === 'forceCount' ?
|
||||
<ForceCountFields value={fact} game={game} player={game.players.find(player => player.id === target)!} onChange={onChange} /> :
|
||||
fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' ?
|
||||
<KnowledgeFactFields value={fact} onChange={onChange} /> : fact.kind === 'spice' ? <>
|
||||
<label>
|
||||
Compare current personal spice
|
||||
<select value={fact.compare} onChange={event => onChange({ ...fact, compare: event.target.value as typeof fact.compare })}>
|
||||
<option value="eq">Exactly</option>
|
||||
<option value="gte">At least</option>
|
||||
<option value="lte">At most</option>
|
||||
</select>
|
||||
</label>
|
||||
<label htmlFor={`${id}-spice-amount`}>Spice amount</label>
|
||||
<Input id={`${id}-spice-amount`} type="number" min={0} max={Number.MAX_SAFE_INTEGER} step={1} required
|
||||
value={Number.isNaN(fact.value) ? '' : fact.value}
|
||||
aria-invalid={invalidTruthFact(fact, game, target)}
|
||||
aria-describedby={`${id}-spice-help${invalidTruthFact(fact, game, target) ? ` ${id}-spice-error` : ''}`}
|
||||
onChange={event => onChange({ ...fact, value: event.currentTarget.valueAsNumber })} />
|
||||
<p className="fine" id={`${id}-spice-help`}>
|
||||
Counts spice the player personally holds now. Excludes pledged aid and incoming payments.
|
||||
This asks about the current balance; it does not promise future holdings or spending.
|
||||
</p>
|
||||
{invalidTruthFact(fact, game, target) && <p className="notice" role="alert" id={`${id}-spice-error`}>
|
||||
{Number.isInteger(fact.value) && fact.value > Number.MAX_SAFE_INTEGER
|
||||
? 'That spice amount is too large.' : 'Enter a whole spice amount of zero or more.'}
|
||||
</p>}
|
||||
</> : fact.kind === 'traitor' ? <label>
|
||||
Leader
|
||||
<select value={fact.leader} onChange={event => onChange({ kind: 'traitor', leader: event.target.value })}>
|
||||
{game.allLeaders.map(leader => <option key={leader.id} value={leader.id}>{leader.name}</option>)}
|
||||
<option value={CHEAP_HERO_TRAITOR}>Cheap Hero / Heroine</option>
|
||||
</select>
|
||||
</label> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
const newFact = (): MixedShipmentExpression => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } });
|
||||
const newShipment = (): MixedShipmentExpression => ({ kind: 'shipment', territory: 'carthag', minimum: 6 });
|
||||
function factSize(fact: TruthFact): { leaves: number; depth: number } {
|
||||
if (fact.kind !== 'and' && fact.kind !== 'or') return { leaves: 1, depth: 0 };
|
||||
const children = fact.terms.map(factSize);
|
||||
return { leaves: children.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(...children.map(child => child.depth)) };
|
||||
}
|
||||
function expressionSize(expression: MixedShipmentExpression): { leaves: number; depth: number } {
|
||||
if (expression.kind === 'fact') return factSize(expression.fact);
|
||||
if (expression.kind === 'shipment') return { leaves: 1, depth: 0 };
|
||||
const children = expression.terms.map(expressionSize);
|
||||
return { leaves: children.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(...children.map(child => child.depth)) };
|
||||
}
|
||||
|
||||
export function mixedShipmentInputError(expression: MixedShipmentExpression, game: GameView, target: string): string | null {
|
||||
const size = expressionSize(expression);
|
||||
if (size.leaves > SHIPMENT_EXPRESSION_MAX_LEAVES || size.depth > SHIPMENT_EXPRESSION_MAX_DEPTH) {
|
||||
return 'Use at most sixteen conditions and four nested groups.';
|
||||
}
|
||||
let facts = 0;
|
||||
let shipments = 0;
|
||||
let invalid = false;
|
||||
const visit = (node: MixedShipmentExpression) => {
|
||||
if (node.kind === 'fact') {
|
||||
facts++;
|
||||
invalid ||= invalidTruthFact(node.fact, game, target);
|
||||
} else if (node.kind === 'shipment') {
|
||||
shipments++;
|
||||
invalid ||= invalidShipmentClause(node);
|
||||
} else {
|
||||
invalid ||= node.terms.length < 2;
|
||||
node.terms.forEach(visit);
|
||||
}
|
||||
};
|
||||
visit(expression);
|
||||
if (!facts || !shipments) return 'Include both a current fact and a shipment-from-reserves condition.';
|
||||
return invalid ? 'Resolve the marked fields; shipment minima must be whole numbers from one to twenty.' : null;
|
||||
}
|
||||
|
||||
export function MixedShipmentFields({ id, value, game, target, disabled, onChange }: {
|
||||
id: string;
|
||||
value: MixedShipmentExpression;
|
||||
game: GameView;
|
||||
target: string;
|
||||
disabled: boolean;
|
||||
onChange: (expression: MixedShipmentExpression) => void;
|
||||
}) {
|
||||
const size = expressionSize(value);
|
||||
const render = (node: MixedShipmentExpression, path: string, depth: number,
|
||||
update: (next: MixedShipmentExpression) => void, remove?: () => void): ReactElement => {
|
||||
const group = node.kind === 'and' || node.kind === 'or';
|
||||
const wrapAllowed = size.leaves < SHIPMENT_EXPRESSION_MAX_LEAVES &&
|
||||
depth + expressionSize(node).depth < SHIPMENT_EXPRESSION_MAX_DEPTH;
|
||||
return <fieldset key={path} className="grid min-w-0 gap-2" style={{ padding: depth ? 6 : 10 }}>
|
||||
<legend>{group ? `Group ${path} · ${node.kind.toUpperCase()}` : `Condition ${path}`}</legend>
|
||||
{group ? <>
|
||||
<label>
|
||||
Group meaning
|
||||
<select value={node.kind} onChange={event => update({ ...node, kind: event.target.value as 'and' | 'or' })}>
|
||||
<option value="and">All conditions · AND</option>
|
||||
<option value="or">At least one condition · OR</option>
|
||||
</select>
|
||||
</label>
|
||||
{node.terms.map((term, index) => render(term, `${path}.${index + 1}`, depth + 1,
|
||||
next => update({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }),
|
||||
() => {
|
||||
const terms = node.terms.filter((_, i) => i !== index);
|
||||
update(terms.length === 1 ? terms[0] : { ...node, terms });
|
||||
}))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={disabled || size.leaves >= SHIPMENT_EXPRESSION_MAX_LEAVES}
|
||||
onClick={() => update({ ...node, terms: [...node.terms, newFact()] })}>Add current fact</Button>
|
||||
<Button disabled={disabled || size.leaves >= SHIPMENT_EXPRESSION_MAX_LEAVES}
|
||||
onClick={() => update({ ...node, terms: [...node.terms, newShipment()] })}>Add shipment condition</Button>
|
||||
<Button disabled={disabled || size.leaves + 2 > SHIPMENT_EXPRESSION_MAX_LEAVES || depth + 2 > SHIPMENT_EXPRESSION_MAX_DEPTH}
|
||||
onClick={() => update({ ...node, terms: [...node.terms, { kind: 'and', terms: [newFact(), newShipment()] }] })}>Add nested group</Button>
|
||||
</div>
|
||||
</> : <>
|
||||
<label>
|
||||
Condition timing
|
||||
<select value={node.kind} onChange={event => update(event.target.value === 'fact' ? newFact() : newShipment())}>
|
||||
<option value="fact">Current fact · at the answer</option>
|
||||
<option value="shipment">Shipment from reserves · this turn</option>
|
||||
</select>
|
||||
</label>
|
||||
{node.kind === 'fact' ? <TruthFactFields value={node.fact} game={game} target={target}
|
||||
onChange={fact => update({ kind: 'fact', fact })} /> : node.kind === 'shipment' ?
|
||||
<ShipmentClauseFields id={`${id}-${path}`} value={node}
|
||||
onChange={clause => update({ kind: 'shipment', ...clause })} /> : null}
|
||||
</>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={disabled || !wrapAllowed} onClick={() => update({ kind: 'and', terms: [node, newShipment()] })}>Group with AND</Button>
|
||||
<Button disabled={disabled || !wrapAllowed} onClick={() => update({ kind: 'or', terms: [node, newShipment()] })}>Group with OR</Button>
|
||||
{remove && <Button disabled={disabled} onClick={remove}>Remove {group ? 'group' : 'condition'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} className="grid min-w-0 gap-2 [&_select]:min-w-0 [&_select]:max-w-full [&_button]:max-w-full [&_button]:min-h-11 [&_button]:h-auto [&_button]:py-2 [&_button]:whitespace-normal">
|
||||
<legend>Mixed current-fact / this-turn shipment claim</legend>
|
||||
<p className="fine">
|
||||
Current facts are evaluated when the player answers, not after shipment. They do not require keeping
|
||||
a card, spice or forces later. Every shipment condition refers to the same shipment from reserves this turn.
|
||||
No makes the whole grouped claim false, not necessarily every condition.
|
||||
</p>
|
||||
<p className="fine">{size.leaves} / {SHIPMENT_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {SHIPMENT_EXPRESSION_MAX_DEPTH} nested groups</p>
|
||||
{render(value, '1', 0, onChange)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -7,6 +7,30 @@ export function invalidShipmentClause(claim: ShipmentClauseInput) {
|
||||
return !Number.isSafeInteger(claim.minimum) || claim.minimum < 1 || claim.minimum > 20;
|
||||
}
|
||||
|
||||
/** Destination and physical minimum shared by flat and nested question builders. */
|
||||
export function ShipmentClauseFields({ id, value, onChange }: {
|
||||
id: string;
|
||||
value: ShipmentClauseInput;
|
||||
onChange: (claim: ShipmentClauseInput) => void;
|
||||
}) {
|
||||
return <>
|
||||
<label htmlFor={`${id}-territory`}>This-turn shipment destination</label>
|
||||
<select id={`${id}-territory`} value={value.territory}
|
||||
onChange={event => onChange({ ...value, territory: event.target.value })}>
|
||||
{TERRITORIES.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-minimum`}>Minimum physical forces from reserves</label>
|
||||
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required
|
||||
value={Number.isNaN(value.minimum) ? '' : value.minimum}
|
||||
aria-invalid={invalidShipmentClause(value)} aria-describedby={`${id}-help`}
|
||||
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
|
||||
<p className="fine" id={`${id}-help`}>
|
||||
A whole minimum of one to twenty physical forces in the same shipment from reserves this turn.
|
||||
Skipping makes this shipment condition false.
|
||||
</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ShipmentClaimFields({ id, clauses, join, onClauses, onJoin }: {
|
||||
id: string;
|
||||
clauses: ShipmentClauseInput[];
|
||||
@@ -25,15 +49,7 @@ export function ShipmentClaimFields({ id, clauses, join, onClauses, onJoin }: {
|
||||
const update = (next: ShipmentClauseInput) => onClauses(clauses.map((previous, i) => i === index ? next : previous));
|
||||
return <fieldset key={index} className="grid gap-2">
|
||||
<legend>Shipment condition {index + 1}</legend>
|
||||
<label htmlFor={`${id}-territory-${index}`}>Shipment destination</label>
|
||||
<select id={`${id}-territory-${index}`} value={claim.territory} onChange={event => update({ ...claim, territory: event.target.value })}>
|
||||
{TERRITORIES.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-minimum-${index}`}>Minimum physical forces</label>
|
||||
<Input id={`${id}-minimum-${index}`} type="number" min={1} max={20} step={1} required
|
||||
value={Number.isNaN(claim.minimum) ? '' : claim.minimum}
|
||||
aria-invalid={invalidShipmentClause(claim)} aria-describedby={`${id}-help`}
|
||||
onChange={event => update({ ...claim, minimum: event.currentTarget.valueAsNumber })} />
|
||||
<ShipmentClauseFields id={`${id}-${index}`} value={claim} onChange={update} />
|
||||
</fieldset>;
|
||||
})}
|
||||
<p className="fine" id={`${id}-help`}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { liveShipmentPromises, shipmentClaimText } from '@/game/shipment-promises';
|
||||
import { liveShipmentPromises, matchesShipment, shipmentClaimText } from '@/game/shipment-promises';
|
||||
import { truthQuestionText } from '@/game/truthtrance';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { territory } from '@/game/board';
|
||||
import { reserveShipmentCost, guildShipmentCost } from '@/game/shipment-price';
|
||||
import { Button } from './ui/button';
|
||||
@@ -63,16 +65,30 @@ export function ShipmentPromises({
|
||||
<ul>
|
||||
{promises.map((p, index) => (
|
||||
<li key={index}>
|
||||
You answered {p.answer ? 'Yes' : 'No'} to: Will you{' '}
|
||||
{shipmentClaimText(p)} this turn?
|
||||
You answered {p.answer ? 'Yes' : 'No'} to:{' '}
|
||||
{p.mixed
|
||||
? truthQuestionText(
|
||||
{ kind: 'mixedShipment', target: p.player, mixed: p.mixed },
|
||||
id => id === CHEAP_HERO_TRAITOR
|
||||
? 'Cheap Hero / Heroine'
|
||||
: g.allLeaders.find(leader => leader.id === id)?.name ?? id,
|
||||
)
|
||||
: `Will you ${shipmentClaimText(p)} this turn?`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{promises.some((p) => p.answer) && (
|
||||
{promises.some((p) => matchesShipment(p, null) !== p.answer) ? (
|
||||
<p>
|
||||
Complete a qualifying shipment before ground movement or finishing
|
||||
your turn. You can choose any legal count, sector and funding that
|
||||
honor your answers.
|
||||
your turn: skipping would not honor the whole claim you answered.
|
||||
You can choose any legal count, sector and funding that honor all
|
||||
your answers. Current facts remain fixed at the time of your answer.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Skipping shipment honors your whole claims. If you ship, choose a
|
||||
legal shipment that honors every answer. Current facts are historical
|
||||
and create no later card, spice or force holding obligation.
|
||||
</p>
|
||||
)}
|
||||
{next && (
|
||||
|
||||
+54
-245
@@ -1,18 +1,20 @@
|
||||
'use client';
|
||||
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';
|
||||
import { ForceCountFields, forceCountInputError } from './truthtrance-force-count';
|
||||
import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge';
|
||||
import {
|
||||
MixedShipmentFields,
|
||||
TruthFactFields,
|
||||
invalidTruthFact,
|
||||
mixedShipmentInputError,
|
||||
} from './mixed-shipment-fields';
|
||||
import type { MixedShipmentExpression } from '@/game/mixed-shipment-question';
|
||||
import type { PlanClaim } from '@/game/battle-promises';
|
||||
import { useId, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { shipmentAvailable as targetShipmentAvailable } from '@/game/shipment-opportunity';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { shipmentPromiseModeSupported } from '@/game/shipment-promises';
|
||||
import {
|
||||
TRUTH_CARD_NAMES,
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
type TruthQuestion,
|
||||
@@ -50,19 +52,27 @@ export function Truthtrance({
|
||||
g.players.find((p) => p.id !== me.id)!.id,
|
||||
);
|
||||
const [kind, setKind] = useState<
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment'
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment'
|
||||
>('fact');
|
||||
const [shipmentJoin, setShipmentJoin] = useState<'single' | 'and' | 'or'>('single');
|
||||
const [shipmentClauses, setShipmentClauses] = useState<ShipmentClauseInput[]>([
|
||||
{ territory: 'carthag', minimum: 6 },
|
||||
{ territory: 'arrakeen', minimum: 4 },
|
||||
]);
|
||||
const [mixed, setMixed] = useState<MixedShipmentExpression>({
|
||||
kind: 'and',
|
||||
terms: [
|
||||
{ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } },
|
||||
{ kind: 'shipment', territory: 'carthag', minimum: 6 },
|
||||
],
|
||||
});
|
||||
const invalidMixed = mixedShipmentInputError(mixed, g, target);
|
||||
const invalidShipment = shipmentClauses.slice(0, shipmentJoin === 'single' ? 1 : 2).some(invalidShipmentClause);
|
||||
const shipmentAvailable =
|
||||
shipmentPromiseModeSupported(g) &&
|
||||
g.phase === 5 &&
|
||||
g.active === target &&
|
||||
!g.players.find((p) => p.id === target)?.shipped &&
|
||||
targetShipmentAvailable(g, {id:target,shipped:g.players.find(p => p.id === target)?.shipped ?? false}) &&
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
!g.phaseOpening;
|
||||
@@ -78,15 +88,9 @@ export function Truthtrance({
|
||||
{ kind: 'hand', name: 'Snooper' },
|
||||
]);
|
||||
const factId = useId();
|
||||
const invalidSpice = (fact: TruthFact) =>
|
||||
(fact.kind === 'spice' ||
|
||||
fact.kind === 'handCount' ||
|
||||
fact.kind === 'handInventory') &&
|
||||
(!Number.isSafeInteger(fact.value) || fact.value < 0);
|
||||
const invalidFact = clauses
|
||||
.slice(0, join === 'single' ? 1 : 2)
|
||||
.some((fact) => invalidSpice(fact) || (isKnowledgeFact(fact) && !!knowledgeFactInputError(fact)) ||
|
||||
(fact.kind === 'forceCount' && !!forceCountInputError(fact, g, g.players.find(p => p.id === target)!)));
|
||||
.some((fact) => invalidTruthFact(fact, g, target));
|
||||
const leaderName = (id: string) =>
|
||||
id === CHEAP_HERO_TRAITOR
|
||||
? 'Cheap Hero / Heroine'
|
||||
@@ -115,7 +119,9 @@ export function Truthtrance({
|
||||
target,
|
||||
...(shipmentJoin === 'single' ? shipmentClauses[0] : { claim: { op: shipmentJoin, terms: shipmentClauses } }),
|
||||
}
|
||||
: { kind, target, text, scope };
|
||||
: kind === 'mixedShipment'
|
||||
? { kind, target, mixed }
|
||||
: { kind, target, text, scope };
|
||||
const button = (label: string, action: Action, disabled = false) => (
|
||||
<Button
|
||||
className="game-action"
|
||||
@@ -189,16 +195,19 @@ export function Truthtrance({
|
||||
Your answer is public. Answer truthfully about the game; a
|
||||
promise can only bind decisions in this turn.
|
||||
</p>
|
||||
{w.question!.kind === 'shipment' ? (
|
||||
{w.question!.kind === 'shipment' || w.question!.kind === 'mixedShipment' ? (
|
||||
<>
|
||||
<p className="notice">
|
||||
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 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.
|
||||
A definite answer binds the whole claim about your
|
||||
shipment from reserves this turn. Any current facts are
|
||||
fixed privately when you answer; they create no later
|
||||
card, spice or force holding obligation. Available
|
||||
preparation includes your Ghola, Karama and recoverable
|
||||
allied funding. Count, sector and payment remain yours
|
||||
to choose while honoring the whole claim. AND requires
|
||||
all conditions; OR requires at least one. No makes the
|
||||
whole claim false. A Yes answer may allow skipping
|
||||
shipment if a current-fact branch already satisfies it.
|
||||
</p>
|
||||
{(g.truthShipmentAnswers ?? []).map((answer) => (
|
||||
<div key={answer}>
|
||||
@@ -321,6 +330,9 @@ export function Truthtrance({
|
||||
<option value="shipment">
|
||||
Bind a shipment from reserves
|
||||
</option>
|
||||
<option value="mixedShipment">
|
||||
Combine current facts and this-turn shipment
|
||||
</option>
|
||||
<option value="freeform">Write a game question</option>
|
||||
{g.battle && (
|
||||
<option value="battlePlan">
|
||||
@@ -329,10 +341,15 @@ export function Truthtrance({
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{kind === 'shipment' ? (
|
||||
{kind === 'shipment' || kind === 'mixedShipment' ? (
|
||||
<>
|
||||
<ShipmentClaimFields id={`${factId}-shipment`} clauses={shipmentClauses}
|
||||
join={shipmentJoin} onClauses={setShipmentClauses} onJoin={setShipmentJoin} />
|
||||
{kind === 'mixedShipment' ? (
|
||||
<MixedShipmentFields id={`${factId}-mixed`} value={mixed}
|
||||
game={g} target={target} disabled={busy} onChange={setMixed} />
|
||||
) : (
|
||||
<ShipmentClaimFields id={`${factId}-shipment`} clauses={shipmentClauses}
|
||||
join={shipmentJoin} onClauses={setShipmentClauses} onJoin={setShipmentJoin} />
|
||||
)}
|
||||
<p className="fine">
|
||||
Includes Fremen reinforcements and Guild transport from
|
||||
southern reserves. Ground movement and transport of forces
|
||||
@@ -348,10 +365,12 @@ export function Truthtrance({
|
||||
unfinished.
|
||||
</p>
|
||||
)}
|
||||
<p className="notice">
|
||||
{invalidShipment
|
||||
? 'Enter a whole minimum of one to twenty forces.'
|
||||
: truthQuestionText(question, leaderName)}
|
||||
<p className="notice" aria-label="Public grouped question preview">
|
||||
{kind === 'mixedShipment'
|
||||
? invalidMixed ?? truthQuestionText(question, leaderName)
|
||||
: invalidShipment
|
||||
? 'Enter a whole minimum of one to twenty forces.'
|
||||
: truthQuestionText(question, leaderName)}
|
||||
</p>
|
||||
</>
|
||||
) : kind === 'battlePlan' ? (
|
||||
@@ -411,220 +430,8 @@ export function Truthtrance({
|
||||
.map((c, index) => (
|
||||
<fieldset key={index}>
|
||||
<legend>Fact {index + 1}</legend>
|
||||
<label>
|
||||
Fact type
|
||||
<select
|
||||
value={c.kind === 'prediction' ? c.field === 'faction' ? 'predictionFaction' : 'predictionTurn' : c.kind}
|
||||
onChange={(e) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index
|
||||
? e.target.value === 'hand'
|
||||
? { kind: 'hand', name: 'Shield' }
|
||||
: e.target.value === 'handCount'
|
||||
? {
|
||||
kind: 'handCount',
|
||||
name: 'Shield',
|
||||
compare: 'gte',
|
||||
value: 2,
|
||||
}
|
||||
: e.target.value === 'handInventory'
|
||||
? {
|
||||
kind: 'handInventory',
|
||||
category: 'all',
|
||||
compare: 'gte',
|
||||
value: 2,
|
||||
}
|
||||
: e.target.value === 'predictionFaction'
|
||||
? { kind: 'prediction', field: 'faction', faction: 'atreides' }
|
||||
: e.target.value === 'predictionTurn'
|
||||
? { kind: 'prediction', field: 'turn', compare: 'gte', value: 1 }
|
||||
: e.target.value === 'stormDial'
|
||||
? { kind: 'stormDial', compare: 'gte', value: 0 }
|
||||
: e.target.value === 'stormForecast'
|
||||
? { kind: 'stormForecast', compare: 'gte', value: 1 }
|
||||
: e.target.value === 'forceCount'
|
||||
? { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 }
|
||||
: e.target.value === 'spice'
|
||||
? {
|
||||
kind: 'spice',
|
||||
compare: 'gte',
|
||||
value: 6,
|
||||
}
|
||||
: {
|
||||
kind: 'traitor',
|
||||
leader: g.allLeaders[0].id,
|
||||
}
|
||||
: old,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="hand">Holds a named card</option>
|
||||
<option value="handCount">
|
||||
Number of a named card
|
||||
</option>
|
||||
<option value="handInventory">
|
||||
Hand size or primary card role
|
||||
</option>
|
||||
<option value="traitor">Selected a traitor</option>
|
||||
<option value="predictionFaction">Stored faction prediction</option>
|
||||
<option value="predictionTurn">Stored turn prediction</option>
|
||||
<option value="stormDial">Known storm dial</option>
|
||||
<option value="stormForecast">Known storm forecast</option>
|
||||
<option value="spice">
|
||||
Current personal spice
|
||||
</option>
|
||||
<option value="forceCount">Current physical forces</option>
|
||||
</select>
|
||||
</label>
|
||||
{c.kind === 'hand' ? (
|
||||
<label>
|
||||
Card name
|
||||
<select
|
||||
value={c.name}
|
||||
onChange={(e) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index
|
||||
? { kind: 'hand', name: e.target.value }
|
||||
: old,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{TRUTH_CARD_NAMES.map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : c.kind === 'handCount' ||
|
||||
c.kind === 'handInventory' ? (
|
||||
<CardCountFields
|
||||
value={c}
|
||||
onChange={(next) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index ? next : old,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : c.kind === 'forceCount' ? (
|
||||
<ForceCountFields value={c} game={g} player={g.players.find(p => p.id === target)!}
|
||||
onChange={next => setClauses(clauses.map((old, n) => n === index ? next : old))} />
|
||||
) : c.kind === 'prediction' || c.kind === 'stormDial' || c.kind === 'stormForecast' ? (
|
||||
<KnowledgeFactFields
|
||||
value={c}
|
||||
onChange={(next) =>
|
||||
setClauses(clauses.map((old, n) => n === index ? next : old))
|
||||
}
|
||||
/>
|
||||
) : c.kind === 'spice' ? (
|
||||
<>
|
||||
<label>
|
||||
Compare personal spice
|
||||
<select
|
||||
value={c.compare}
|
||||
onChange={(e) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index
|
||||
? {
|
||||
...c,
|
||||
compare: e.target
|
||||
.value as typeof c.compare,
|
||||
}
|
||||
: old,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="eq">Exactly</option>
|
||||
<option value="gte">At least</option>
|
||||
<option value="lte">At most</option>
|
||||
</select>
|
||||
</label>
|
||||
<label htmlFor={`${factId}-${index}-spice-amount`}>
|
||||
Spice amount
|
||||
<Input
|
||||
id={`${factId}-${index}-spice-amount`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={Number.MAX_SAFE_INTEGER}
|
||||
step={1}
|
||||
required
|
||||
value={Number.isNaN(c.value) ? '' : c.value}
|
||||
aria-invalid={invalidSpice(c)}
|
||||
aria-describedby={`${factId}-${index}-spice-help${invalidSpice(c) ? ` ${factId}-${index}-spice-error` : ''}`}
|
||||
onChange={(e) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index
|
||||
? {
|
||||
...c,
|
||||
value:
|
||||
e.currentTarget.valueAsNumber,
|
||||
}
|
||||
: old,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p
|
||||
className="fine"
|
||||
id={`${factId}-${index}-spice-help`}
|
||||
>
|
||||
Counts spice the player personally holds now.
|
||||
Excludes pledged aid and incoming payments. This
|
||||
asks about the current balance; it does not
|
||||
promise future holdings or spending.
|
||||
</p>
|
||||
{invalidSpice(c) && (
|
||||
<p
|
||||
className="notice"
|
||||
role="alert"
|
||||
id={`${factId}-${index}-spice-error`}
|
||||
>
|
||||
{Number.isInteger(c.value) &&
|
||||
c.value > Number.MAX_SAFE_INTEGER
|
||||
? 'That spice amount is too large.'
|
||||
: 'Enter a whole spice amount of zero or more.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
c.kind === 'traitor' && (
|
||||
<label>
|
||||
Leader
|
||||
<select
|
||||
value={c.leader}
|
||||
onChange={(e) =>
|
||||
setClauses(
|
||||
clauses.map((old, n) =>
|
||||
n === index
|
||||
? {
|
||||
kind: 'traitor',
|
||||
leader: e.target.value,
|
||||
}
|
||||
: old,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{g.allLeaders.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={CHEAP_HERO_TRAITOR}>
|
||||
Cheap Hero / Heroine
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
)}
|
||||
<TruthFactFields value={c} game={g} target={target}
|
||||
onChange={next => setClauses(clauses.map((old, n) => n === index ? next : old))} />
|
||||
</fieldset>
|
||||
))}
|
||||
<p className="notice">
|
||||
@@ -670,6 +477,8 @@ export function Truthtrance({
|
||||
{ type: 'truthAsk', question },
|
||||
(kind === 'shipment' &&
|
||||
(invalidShipment || !shipmentAvailable)) ||
|
||||
(kind === 'mixedShipment' &&
|
||||
(!!invalidMixed || !shipmentAvailable)) ||
|
||||
(kind === 'freeform' && !text.trim()) ||
|
||||
(kind === 'fact' && invalidFact) ||
|
||||
(kind === 'battlePlan' &&
|
||||
|
||||
@@ -4,13 +4,64 @@
|
||||
with shipment-only AND/OR questions. Public mode and publication gates remain
|
||||
unchanged.
|
||||
|
||||
## Mixed current facts and future shipment — 3 October 2026
|
||||
|
||||
The structured builder now supports bounded nested AND/OR trees containing
|
||||
current named-card, card-count, inventory, traitor, personal-spice, physical-force
|
||||
or knowledge facts alongside reserve-shipment conditions. Both kinds must be
|
||||
present; the existing software limit is sixteen logical leaves and four grouped
|
||||
levels, not a printed limit on game questions. Current fact legality comes from
|
||||
the existing fact parser; reserve shipment uses the original destination/count
|
||||
quote and the same active-unused-shipment/mode boundary.
|
||||
|
||||
Evaluate current facts when a definite answer is given and freeze their
|
||||
three-valued results privately. Later legal card/force/spice changes cannot
|
||||
rewrite the past statement. Future leaves still describe one eventual reserve
|
||||
shipment or its absence, and the whole expression must match the public answer.
|
||||
For example:
|
||||
|
||||
- **Yes** to “I currently hold at least10 spice OR I will ship4 to Arrakeen”
|
||||
with20 spice permits later spending and no shipment.
|
||||
- **No** to “I currently hold at most10 spice AND I will ship4 to Arrakeen”
|
||||
with20 spice permits shipment even if its cost later reduces the wallet to5.
|
||||
- **Yes AND** with a true current clause still requires the future shipment.
|
||||
**No OR** with a true current clause is impossible.
|
||||
|
||||
Unknown current information stays unknown: it is not converted to false for a
|
||||
No promise. A definite answer is available only when an actually legal future
|
||||
outcome makes the whole statement definite. Multiple ordinary and mixed
|
||||
promises constrain that same event together. Voluntary spending cannot remove
|
||||
the last legal completion, but need not preserve a current holding that was not
|
||||
itself a future promise.
|
||||
|
||||
Only the respondent receives a mixed promise's compiled fixed answers and
|
||||
private feasible choices. Public question/history text keeps the original
|
||||
grouping and whole answer, never which current clause matched. Shared shipment,
|
||||
skip, preparation, release, controls and minimal legal policies use the same
|
||||
frozen expression. Default public parsers reject caller-supplied constants.
|
||||
|
||||
The existing FAQ whole-question semantics and historical/current distinction
|
||||
support this composition; it is not a new expansion or timing ruling. Earlier
|
||||
phases, other future actions, expansion shipment modes and arbitrary prose
|
||||
enforcement remain outside the bounded implementation.
|
||||
|
||||
Twelve actual programs cover mixed truth/skip/shipment/changed-spice, all four
|
||||
legal policies and a second joint pure+mixed promise. The affected
|
||||
truth/ship/discard rule batch passes88/88; mixed/reference23/23, types/lint and
|
||||
build pass. Human390px **4PVKRRWB v10** authored and answered the actual OR
|
||||
claim, then spent all20 spice as a bribe, skipped shipment and fulfilled the
|
||||
promise while the public answer stayed Yes. This is bounded local evidence, not
|
||||
complete Truthtrance, module or deployed acceptance.
|
||||
|
||||
|
||||
## 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.
|
||||
The original shipment-only form does not promise two shipments; the later
|
||||
mixed form above adds current facts without turning them into future holdings.
|
||||
|
||||
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
|
||||
@@ -59,10 +110,11 @@ 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.
|
||||
base Advanced games without Guild or optional modules. Mixed current-fact/
|
||||
shipment trees now use that same opportunity. Earlier timing, other future
|
||||
actions, 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.
|
||||
+19
-1
@@ -1,6 +1,6 @@
|
||||
# Current development status
|
||||
|
||||
Updated 2 October 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
Updated 3 October 2026. **The full twelve-faction goal is unfinished. Basic
|
||||
is playable and the six classic factions have a user-authorized Advanced preview.
|
||||
Expansion starts and full rules/publication acceptance remain gated.**
|
||||
|
||||
@@ -16,6 +16,24 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Mixed current-fact/shipment Truthtrance — bounded Development Verified:**
|
||||
structured questions now combine supported current facts with this-turn reserve
|
||||
shipments in bounded nested AND/OR trees. Current facts freeze privately at a
|
||||
definite answer; later legal card/force/spice changes cannot rewrite them, and
|
||||
unknown is never treated as false. One eventual shipment or skip honors every
|
||||
separate promise. Default public parsers reject caller-supplied frozen answers;
|
||||
only the respondent receives the compiled fixed answers.
|
||||
Two bounded owners supplied the pure expression logic and nested authoring
|
||||
controls; one integration owner wired the question, private projection,
|
||||
promise receipt, public redaction, policies and existing continuation.
|
||||
Types/lint, affected88/88, mixed/reference23/23 and build pass. Twelve actual
|
||||
program cases cover truth/skip/shipment/changed-spice, all four policies and a
|
||||
second joint promise. Human390px **4PVKRRWB v10** authored and answered the
|
||||
actual OR question, then spent all20 spice as a bribe, skipped shipment and
|
||||
fulfilled the promise while its public answer stayed Yes. Earlier phases, other
|
||||
future actions and existing module gates remain unfinished. See
|
||||
[mixed compounds](COMPOUND_SHIPMENT_PROMISES.md#mixed-current-facts-and-future-shipment--3-october-2026).
|
||||
|
||||
**Requested priority — 2 October 2026:** throughput improvements are verified
|
||||
and rule content has resumed. Implement all remaining rules first; comprehensive
|
||||
review and save/recovery/privacy/custody assurance follow afterward, not at each
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 3 October 2026 — mixed current-fact/shipment Truthtrance
|
||||
|
||||
Two bounded owners supplied the public mixed tree/compiler/matcher (including
|
||||
internal frozen-constant storage) and complete nested AND/OR authoring controls.
|
||||
One integration owner owns game/truthtrance.ts, engine projection/promise
|
||||
receipt, bots, the shared table guide and this checkpoint.
|
||||
|
||||
Current facts are evaluated once at the definite answer and stored privately as
|
||||
three-valued constants; later legal changes cannot rewrite them, and unknown is
|
||||
never collapsed to false. Future leaves still describe one eventual reserve
|
||||
shipment or its absence under the existing FAQ whole-question truth. Multiple
|
||||
ordinary and mixed promises constrain that same event together. Public
|
||||
projection filters mixed promises to their respondent and keeps only the
|
||||
original grouped question and whole answer in history.
|
||||
|
||||
The first frozen run exposed three real integration defects, each fixed before
|
||||
verification: the discard-continuation receipt still required a legacy shipment
|
||||
shape; the pre-existing `promise.answer` shipment shortcut treated a satisfied
|
||||
mixed promise as if it must ship; and two workspace type errors
|
||||
(target-shipment projection and test literal narrowing). Ordinary
|
||||
shipment-only promises retain their original behavior.
|
||||
|
||||
Types/lint, the affected truth/ship/discard batch88/88, mixed/reference23/23 and
|
||||
build pass. Twelve actual programs cover OR-true-skip, AND-false-ship,
|
||||
AND-true-required, OR-false-ship, a nested branch after spending, all four legal
|
||||
policies and a second joint pure+mixed promise. Fixtures use genuine base setup
|
||||
and conserved rule-unit movement positions; no staged phase or promise was
|
||||
fabricated.
|
||||
|
||||
Human390px **4PVKRRWB v10** authored the actual "hold at least10 spice OR ship4
|
||||
to Arrakeen" question, answered Yes, spent all20 spice as a public bribe,
|
||||
skipped shipment and fulfilled the promise while the public answer stayed Yes;
|
||||
refresh retained it. Earlier phases, Advanced Guild/other modules, expansion
|
||||
shipment modes, other future actions and arbitrary prose enforcement remain
|
||||
unfinished. No new assurance campaign, public start or old-game retrofit.
|
||||
|
||||
The full offline suite still reports one pre-existing failure unrelated to this
|
||||
wave: the `combined` Advanced CLI sample stalls at phase6 when a bot offers an
|
||||
Advanced combined-Occupy battle against a Richese/No-Field seat that the engine
|
||||
correctly rejects. The same 2353-action failure reproduces at the prior pushed
|
||||
checkpoint 0ceb28b, so this wave neither introduced nor hid it. It remains an
|
||||
open sample/bot-offer defect for a later combined-Occupy slice.
|
||||
|
||||
## 2 October 2026 — native Banker income and Ixian Nexus replacement
|
||||
|
||||
Two independent bounded owners supplied native original setup/payment fixtures
|
||||
|
||||
@@ -192,11 +192,13 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
|
||||
| Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) |
|
||||
| Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) |
|
||||
|
||||
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.
|
||||
Shipment and mixed current-fact/shipment AND/OR follow the FAQ's whole-question
|
||||
truth: Yes to AND requires all, Yes to OR requires one, and No negates the whole.
|
||||
Current facts freeze privately at definite answer; later legal holdings changes
|
||||
do not rewrite them or add a retention obligation. One eventual shipment or
|
||||
skip must honor all separate promises. Unknown is not false. See
|
||||
[mixed compounds](COMPOUND_SHIPMENT_PROMISES.md#mixed-current-facts-and-future-shipment--3-october-2026);
|
||||
earlier timing, other future actions and existing module gates remain unfinished.
|
||||
|
||||
Current Truthtrance force facts count physical counters in reserves, the Tanks
|
||||
or one exact live board location. Totals include elites; normal counts subtract
|
||||
|
||||
+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(
|
||||
|
||||
@@ -29,10 +29,8 @@ void test('compound shipment fields distinguish one shipment from two and retain
|
||||
assert.equal((html.match(/<fieldset/g) ?? []).length, 2);
|
||||
assert.match(html, /All conditions describe the same shipment/);
|
||||
assert.match(html, /A No answer requires the whole statement to be false/);
|
||||
for (const index of [0, 1]) {
|
||||
assert.match(html, new RegExp(`for="shipment-test-territory-${index}"`));
|
||||
assert.match(html, new RegExp(`for="shipment-test-minimum-${index}"`));
|
||||
}
|
||||
assert.equal((html.match(/This-turn shipment destination/g) ?? []).length, 2);
|
||||
assert.equal((html.match(/Minimum physical forces from reserves/g) ?? []).length, 2);
|
||||
const question: TruthQuestion = { kind: 'shipment', target: 'p', claim: { op: join, terms: clauses } };
|
||||
const text = truthQuestionText(question, id => id);
|
||||
assert.match(text, /Carthag/);
|
||||
@@ -44,7 +42,7 @@ void test('compound shipment fields distinguish one shipment from two and retain
|
||||
void test('empty, fractional and out-of-range compound counts stay invalid rather than coercing to a different promise', () => {
|
||||
for (const minimum of [NaN, 0, 2.5, 21]) {
|
||||
assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), true);
|
||||
assert.match(markup('or', minimum), /id="shipment-test-minimum-1"[^>]*aria-invalid="true"/);
|
||||
assert.equal((markup('or', minimum).match(/aria-invalid="true"/g) ?? []).length, 1);
|
||||
}
|
||||
for (const minimum of [1, 20]) assert.equal(invalidShipmentClause({ territory: 'arrakeen', minimum }), false);
|
||||
});
|
||||
@@ -57,8 +55,8 @@ void test('the real answer panel explains whole-expression No and offers answers
|
||||
const own = render('p');
|
||||
assert.match(own, /Answer Yes/);
|
||||
assert.match(own, /Answer No/);
|
||||
assert.match(own, /for AND, at least one condition must be false/);
|
||||
assert.match(own, /every condition must be false/);
|
||||
assert.match(own, /AND requires all conditions; OR requires at least one/);
|
||||
assert.match(own, /No makes the whole claim false/);
|
||||
assert.match(render('p', true), /<button[^>]*disabled=""[^>]*>Answer Yes/);
|
||||
for (const other of ['a', 'o']) {
|
||||
assert.doesNotMatch(render(other), />Answer (Yes|No)</);
|
||||
|
||||
@@ -154,10 +154,10 @@ void test('every canonical Ecaz face receives its full original gameplay guide a
|
||||
assert.equal(serialized.includes(note), false);
|
||||
assert.equal(serialized.includes('sourceRules'), false);
|
||||
assert.equal(serialized.includes('not-implemented'), false);
|
||||
assert.deepEqual(presentation.topics.map((topic) => topic.id), [card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : 'card-recruits']);
|
||||
assert.deepEqual(presentation.topics.map((topic) => topic.id), [card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits']);
|
||||
assert.match(presentation.availability ?? '', card.effect === 'recruits'
|
||||
? /development controls during Revival/ : card.effect === 'harassWithdraw'
|
||||
? /development controls in either battle-card slot/ : /battle effect is still being implemented/);
|
||||
? /development controls in either battle-card slot/ : /development controls in either battle-card slot/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {applyAction,type Game} from '../game/engine';
|
||||
import type {MixedShipmentExpression} from '../game/mixed-shipment-question';
|
||||
import {compoundShipmentGame,openCompoundShipmentQuestion} from './fixture-compound-shipment';
|
||||
|
||||
/** Genuine base setup, then the existing labelled conserved shipment position. */
|
||||
export function mixedShipmentGame(): Game {
|
||||
return compoundShipmentGame();
|
||||
}
|
||||
export function askMixedShipment(game: Game, mixed: MixedShipmentExpression): Game {
|
||||
return applyAction(openCompoundShipmentQuestion(game),game.players[1].id,{
|
||||
type:'truthAsk',question:{kind:'mixedShipment',target:game.players[0].id,mixed},
|
||||
});
|
||||
}
|
||||
export function mixedSpiceShipment(kind:'and'|'or',compare:'gte'|'lte',value:number): MixedShipmentExpression {
|
||||
return {kind,terms:[{kind:'fact',fact:{kind:'spice',compare,value}},
|
||||
{kind:'shipment',territory:'arrakeen',minimum:4}]};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {applyAction,viewGame} from '../game/engine';
|
||||
import {askMixedShipment,mixedShipmentGame,mixedSpiceShipment} from './fixture-mixed-shipment';
|
||||
import {askCompoundShipment} from './fixture-compound-shipment';
|
||||
|
||||
void test('Yes OR a true current spice fact permits later spending and skipping shipment without changing its historical answer',()=>{
|
||||
let game=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('or','gte',10));
|
||||
assert.deepEqual(viewGame(game,'p').truthShipmentAnswers,['yes']);
|
||||
game=applyAction(game,'p',{type:'truthAnswer',answer:'yes'});
|
||||
assert.equal(game.truthHistory!.at(-1)!.answer,'yes');
|
||||
game=applyAction(game,'p',{type:'bribe',target:'a',amount:20});
|
||||
assert.equal(game.players[0].spice,0);
|
||||
game=applyAction(game,'p',{type:'endMovement'});
|
||||
assert.equal(game.shipmentPromises![0].fulfilled,true);
|
||||
assert.equal(game.players[0].shipped,false);
|
||||
assert.equal(game.truthHistory!.at(-1)!.answer,'yes');
|
||||
});
|
||||
|
||||
void test('No AND a false current spice fact permits a shipment that changes that current fact to true',()=>{
|
||||
let game=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('and','lte',10));
|
||||
assert.deepEqual(viewGame(game,'p').truthShipmentAnswers,['no']);
|
||||
game=applyAction(game,'p',{type:'truthAnswer',answer:'no'});
|
||||
game=applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:15});
|
||||
assert.equal(game.players[0].spice,5);
|
||||
assert.equal(game.players[0].forces['arrakeen:10'],15);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled,true);
|
||||
assert.equal(game.truthHistory!.at(-1)!.answer,'no');
|
||||
});
|
||||
|
||||
void test('Yes AND a true current fact requires the exact future shipment while an impossible current branch cannot be answered Yes',()=>{
|
||||
let game=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('and','gte',10));
|
||||
assert.deepEqual(viewGame(game,'p').truthShipmentAnswers,['yes','no']);
|
||||
game=applyAction(game,'p',{type:'truthAnswer',answer:'yes'});
|
||||
const before=JSON.stringify(game);
|
||||
assert.throws(()=>applyAction(game,'p',{type:'endMovement'}));
|
||||
assert.throws(()=>applyAction(game,'p',{type:'ship',territory:'carthag',sector:11,amount:4}));
|
||||
assert.throws(()=>applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:3}));
|
||||
assert.equal(JSON.stringify(game),before);
|
||||
game=applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:4});
|
||||
assert.equal(game.players[0].forces['arrakeen:10'],4);
|
||||
const impossible=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('and','gte',21));
|
||||
assert.deepEqual(viewGame(impossible,'p').truthShipmentAnswers,['no']);
|
||||
assert.throws(()=>applyAction(impossible,'p',{type:'truthAnswer',answer:'yes'}));
|
||||
});
|
||||
|
||||
void test('No OR requires every frozen branch to be false and cannot negate an already true current clause',()=>{
|
||||
let game=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('or','lte',10));
|
||||
assert.deepEqual(viewGame(game,'p').truthShipmentAnswers,['yes','no']);
|
||||
game=applyAction(game,'p',{type:'truthAnswer',answer:'no'});
|
||||
assert.throws(()=>applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:4}));
|
||||
game=applyAction(game,'p',{type:'ship',territory:'carthag',sector:11,amount:15});
|
||||
assert.equal(game.players[0].spice,5);
|
||||
assert.equal(game.players[0].forces['carthag:11'],15);
|
||||
assert.equal(game.shipmentPromises![0].fulfilled,true);
|
||||
const impossible=askMixedShipment(mixedShipmentGame(),mixedSpiceShipment('or','gte',10));
|
||||
assert.deepEqual(viewGame(impossible,'p').truthShipmentAnswers,['yes']);
|
||||
assert.throws(()=>applyAction(impossible,'p',{type:'truthAnswer',answer:'no'}));
|
||||
});
|
||||
|
||||
void test('nested current branches keep the selected future destination after voluntary spice changes',()=>{
|
||||
let game=askMixedShipment(mixedShipmentGame(),{kind:'or',terms:[
|
||||
mixedSpiceShipment('and','gte',10),
|
||||
{kind:'and',terms:[{kind:'fact',fact:{kind:'spice',compare:'lte',value:10}},
|
||||
{kind:'shipment',territory:'carthag',minimum:4}]},
|
||||
]});
|
||||
game=applyAction(game,'p',{type:'truthAnswer',answer:'yes'});
|
||||
game=applyAction(game,'p',{type:'bribe',target:'a',amount:12});
|
||||
assert.equal(game.players[0].spice,8);
|
||||
assert.throws(()=>applyAction(game,'p',{type:'ship',territory:'carthag',sector:11,amount:4}));
|
||||
game=applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:4});
|
||||
assert.equal(game.players[0].spice,4);
|
||||
assert.equal(game.players[0].forces['arrakeen:10'],4);
|
||||
});
|
||||
|
||||
void test('an unconstraining mixed answer cannot override an earlier pure shipment commitment',()=>{
|
||||
let game=askCompoundShipment(mixedShipmentGame(),{territory:'carthag',minimum:4});
|
||||
game=applyAction(game,'p',{type:'truthAnswer',answer:'yes'});
|
||||
game=askMixedShipment(game,mixedSpiceShipment('or','gte',10));
|
||||
game=applyAction(game,'p',{type:'truthAnswer',answer:'yes'});
|
||||
assert.equal(game.shipmentPromises!.length,2);
|
||||
assert.throws(()=>applyAction(game,'p',{type:'endMovement'}));
|
||||
assert.throws(()=>applyAction(game,'p',{type:'ship',territory:'arrakeen',sector:10,amount:4}));
|
||||
game=applyAction(game,'p',{type:'ship',territory:'carthag',sector:11,amount:4});
|
||||
assert.deepEqual(game.shipmentPromises!.map(p=>p.fulfilled),[true,true]);
|
||||
assert.equal(game.players[0].forces['carthag:11'],4);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
compileMixedShipmentExpression,
|
||||
parseMixedShipmentExpression,
|
||||
type MixedShipmentExpression,
|
||||
} from '../game/mixed-shipment-question';
|
||||
import {
|
||||
matchesShipment,
|
||||
parseShipmentClaim,
|
||||
parseShipmentExpression,
|
||||
shipmentClaimDestinations,
|
||||
validShipmentClaim,
|
||||
ShipmentClaimError,
|
||||
type ShipmentExpression,
|
||||
} from '../game/shipment-promises';
|
||||
import { truthFactAnswer, type TruthAnswer, type TruthFact } from '../game/truthtrance';
|
||||
|
||||
const fact = (value = 5): MixedShipmentExpression => ({
|
||||
kind: 'fact', fact: { kind: 'spice', compare: 'gte', value },
|
||||
});
|
||||
const shipment = (territory = 'carthag', minimum = 4): MixedShipmentExpression => ({
|
||||
kind: 'shipment', territory, minimum,
|
||||
});
|
||||
const group = (
|
||||
kind: 'and' | 'or', ...terms: MixedShipmentExpression[]
|
||||
): MixedShipmentExpression => ({ kind, terms });
|
||||
// The caller supplies fact legality; these already-typed fixtures isolate the shared budget.
|
||||
const decode = (value: unknown) =>
|
||||
parseMixedShipmentExpression(value, (input) => input as TruthFact);
|
||||
const events = [
|
||||
null,
|
||||
{ territory: 'carthag', amount: 3 },
|
||||
{ territory: 'carthag', amount: 4 },
|
||||
{ territory: 'arrakeen', amount: 4 },
|
||||
];
|
||||
const outcomes = (expression: ShipmentExpression) =>
|
||||
events.map((event) => matchesShipment({ claim: expression }, event));
|
||||
|
||||
void test('AND and OR bind the whole question, including No rather than individual leaves', () => {
|
||||
for (const [kind, answer, expected] of [
|
||||
['and', 'yes', [false, false, true, false]],
|
||||
['and', 'no', [false, false, false, false]],
|
||||
['or', 'yes', [true, true, true, true]],
|
||||
['or', 'no', [false, false, true, false]],
|
||||
] as const) {
|
||||
const expression = compileMixedShipmentExpression(
|
||||
decode(group(kind, fact(), shipment())), () => answer,
|
||||
);
|
||||
assert.deepEqual(outcomes(expression), expected);
|
||||
}
|
||||
});
|
||||
|
||||
void test('nested mixed branches evaluate the same eventual event across different destinations', () => {
|
||||
const expression = decode(group('or',
|
||||
group('and', fact(5), shipment('carthag', 4)),
|
||||
group('and', fact(10), shipment('arrakeen', 3)),
|
||||
));
|
||||
const player = { hand: [], traitors: [], traitorChoices: [], spice: 7 };
|
||||
const claim = compileMixedShipmentExpression(expression, (f) => truthFactAnswer(player, f));
|
||||
assert.deepEqual(outcomes(claim), [false, false, true, false]);
|
||||
player.spice = 12;
|
||||
const laterClaim = compileMixedShipmentExpression(expression, (f) => truthFactAnswer(player, f));
|
||||
assert.deepEqual(outcomes(laterClaim), [false, false, true, true]);
|
||||
assert.deepEqual(shipmentClaimDestinations({ claim }), ['carthag', 'arrakeen']);
|
||||
});
|
||||
|
||||
void test('unknown is not false: decisive AND/OR branches dominate regardless of order', () => {
|
||||
const unknown: ShipmentExpression = { constant: 'unknown' };
|
||||
for (const order of [false, true]) {
|
||||
for (const [op, constant, expected] of [
|
||||
['and', 'no', false],
|
||||
['and', 'yes', null],
|
||||
['or', 'yes', true],
|
||||
['or', 'no', null],
|
||||
] as const) {
|
||||
const known: ShipmentExpression = { constant };
|
||||
const claim: ShipmentExpression = {
|
||||
op, terms: order ? [known, unknown] : [unknown, known],
|
||||
};
|
||||
assert.equal(matchesShipment({ claim }, null), expected);
|
||||
}
|
||||
}
|
||||
const and = compileMixedShipmentExpression(group('and', fact(), shipment()), () => 'unknown');
|
||||
const or = compileMixedShipmentExpression(group('or', fact(), shipment()), () => 'unknown');
|
||||
assert.deepEqual(outcomes(and), [false, false, null, false]);
|
||||
assert.deepEqual(outcomes(or), [null, null, true, null]);
|
||||
assert.deepEqual(outcomes({ op: 'or', terms: [and, { constant: 'no' }] }),
|
||||
[false, false, null, false]);
|
||||
});
|
||||
|
||||
void test('compilation freezes current answers once, not a callback or later fact state', () => {
|
||||
const player = { hand: [], traitors: [], traitorChoices: [], spice: 7 };
|
||||
const asked = decode(group('or', fact(), shipment()));
|
||||
let reads = 0;
|
||||
const frozen = compileMixedShipmentExpression(asked, (f) => {
|
||||
reads++;
|
||||
return truthFactAnswer(player, f);
|
||||
});
|
||||
player.spice = 0;
|
||||
assert.equal(matchesShipment({ claim: frozen }, null), true);
|
||||
assert.equal(matchesShipment({ claim: JSON.parse(JSON.stringify(frozen)) as ShipmentExpression },
|
||||
{ territory: 'arrakeen', amount: 1 }), true);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(matchesShipment({
|
||||
claim: compileMixedShipmentExpression(asked, (f) => truthFactAnswer(player, f)),
|
||||
}, null), false);
|
||||
|
||||
const negative = compileMixedShipmentExpression(group('and', fact(), shipment()),
|
||||
(f) => truthFactAnswer(player, f));
|
||||
player.spice = 20;
|
||||
assert.equal(matchesShipment({ claim: negative }, { territory: 'carthag', amount: 20 }), false);
|
||||
});
|
||||
|
||||
void test('public shipment and mixed decoders reject private literals; stored internal claims retain unknown', () => {
|
||||
for (const constant of ['yes', 'no', 'unknown'] as TruthAnswer[]) {
|
||||
const compiled: ShipmentExpression = {
|
||||
op: 'or', terms: [{ constant }, { territory: 'carthag', minimum: 4 }],
|
||||
};
|
||||
assert.throws(() => parseShipmentExpression(compiled), ShipmentClaimError);
|
||||
assert.throws(() => parseShipmentClaim({ claim: compiled }), ShipmentClaimError);
|
||||
assert.equal(validShipmentClaim({ claim: compiled }), false);
|
||||
const stored = parseShipmentClaim({ claim: compiled }, { allowConstants: true });
|
||||
assert.equal(matchesShipment(stored, null), constant === 'unknown' ? null : constant === 'yes');
|
||||
assert.deepEqual(shipmentClaimDestinations(stored), ['carthag']);
|
||||
assert.throws(() => decode({kind:'or',terms:[fact(),{constant}]}), ShipmentClaimError);
|
||||
assert.throws(() => decode({kind:'or',terms:[{...fact(),constant},shipment()]}), ShipmentClaimError);
|
||||
}
|
||||
for (const constant of ['maybe', true, null])
|
||||
assert.throws(() => parseShipmentExpression({ constant }, { allowConstants: true }), ShipmentClaimError);
|
||||
assert.throws(() => parseShipmentExpression({ constant: 'yes', territory: 'carthag' },
|
||||
{ allowConstants: true }), ShipmentClaimError);
|
||||
});
|
||||
|
||||
void test('one grouping depth and logical-leaf budget includes embedded fact groups', () => {
|
||||
// Group nodes do not consume leaves: fifteen current facts plus one shipment fit.
|
||||
decode(group('and', ...Array.from({ length: 15 }, () => fact()), shipment()));
|
||||
assert.throws(() => decode(group('and', ...Array.from({ length: 16 }, () => fact()), shipment())),
|
||||
ShipmentClaimError);
|
||||
const factGroup: TruthFact = { kind: 'or', terms: [
|
||||
{ kind: 'spice', compare: 'gte', value: 5 },
|
||||
{ kind: 'spice', compare: 'gte', value: 10 },
|
||||
] };
|
||||
const embedded: MixedShipmentExpression = { kind: 'fact', fact: factGroup };
|
||||
decode(group('and', ...Array.from({ length: 7 }, () => embedded), fact(), shipment()));
|
||||
assert.throws(() => decode(group('and', ...Array.from({ length: 8 }, () => embedded), shipment())),
|
||||
ShipmentClaimError);
|
||||
let nested: MixedShipmentExpression = embedded;
|
||||
for (let n = 0; n < 3; n++) nested = group('or', shipment(), nested);
|
||||
decode(nested); // Three mixed groups plus the embedded fact group.
|
||||
assert.throws(() => decode(group('and', shipment(), nested)), ShipmentClaimError);
|
||||
});
|
||||
|
||||
void test('mixed decoding keeps the supplied fact legality authority and rejects malformed grouping or shipment leaves', () => {
|
||||
const forbidden = new Error('Fact is unavailable to this respondent.');
|
||||
assert.throws(() => parseMixedShipmentExpression(group('and', fact(), shipment()), () => {
|
||||
throw forbidden;
|
||||
}), (error) => error === forbidden);
|
||||
for (const value of [
|
||||
fact(), shipment(), group('and', fact()), group('or'),
|
||||
{ kind: 'xor', terms: [fact(), shipment()] },
|
||||
group('and', fact(), shipment('missing', 4)),
|
||||
group('and', fact(), shipment('carthag', 0)),
|
||||
group('and', fact(), shipment('carthag', 21)),
|
||||
group('and', fact(), shipment('carthag', 1.5)),
|
||||
{ kind: 'and', terms: [fact(), shipment()], constant: 'yes' },
|
||||
]) assert.throws(() => decode(value), ShipmentClaimError);
|
||||
});
|
||||
Reference in new issue
Block a user