Support mixed current-fact and shipment Truthtrance questions

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

No files matched your search

+2 -2
View File
@@ -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
}
+217
View File
@@ -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>;
}
+25 -9
View File
@@ -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`}>
+22 -6
View File
@@ -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 && (
+48 -239
View File
@@ -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,6 +119,8 @@ export function Truthtrance({
target,
...(shipmentJoin === 'single' ? shipmentClauses[0] : { claim: { op: shipmentJoin, terms: shipmentClauses } }),
}
: kind === 'mixedShipment'
? { kind, target, mixed }
: { kind, target, text, scope };
const button = (label: string, action: Action, disabled = false) => (
<Button
@@ -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' ? (
<>
{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,8 +365,10 @@ export function Truthtrance({
unfinished.
</p>
)}
<p className="notice">
{invalidShipment
<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>
@@ -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)!}
<TruthFactFields value={c} game={g} target={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>
)
)}
</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' &&
+57 -5
View File
@@ -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
View File
@@ -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
+43
View File
@@ -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
+7 -5
View File
@@ -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
View File
@@ -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' }];
}
+19 -10
View File
@@ -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 &&
(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,
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,
+116
View File
@@ -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
View File
@@ -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',
],
},
],
+68 -17
View File
@@ -1,9 +1,12 @@
import { TERRITORIES, territory } from './board';
import type { MixedShipmentExpression } from './mixed-shipment-question';
import type { TruthAnswer } from './truthtrance';
/** Every leaf describes the same eventual ordinary reserve shipment, or its absence. */
/** Every shipment leaf describes the same eventual reserve shipment, or its absence. */
export type ShipmentLeaf = { territory: string; minimum: number };
export type ShipmentExpression =
| ShipmentLeaf
| { constant: TruthAnswer }
| { op: 'and' | 'or'; terms: ShipmentExpression[] };
/** Old flat questions and saved promises remain valid without migration. */
export type ShipmentClaim =
@@ -12,6 +15,8 @@ export type ShipmentClaim =
export const SHIPMENT_EXPRESSION_MAX_DEPTH = 4;
export const SHIPMENT_EXPRESSION_MAX_LEAVES = 16;
export class ShipmentClaimError extends Error {}
/** Only trusted stored mixed claims may contain frozen current-fact answers. */
export type ShipmentParseOptions = { allowConstants?: boolean };
function check(value: unknown, message: string): asserts value {
if (!value) throw new ShipmentClaimError(message);
}
@@ -34,7 +39,10 @@ function parseLeaf(value: Record<string, unknown>): ShipmentLeaf {
return { territory: value.territory, minimum: value.minimum };
}
/** Bounded software input, not an additional printed limit on asking questions. */
export function parseShipmentExpression(value: unknown): ShipmentExpression {
export function parseShipmentExpression(
value: unknown,
options: ShipmentParseOptions = {},
): ShipmentExpression {
let leaves = 0;
const parse = (value: unknown, depth: number): ShipmentExpression => {
check(
@@ -55,6 +63,18 @@ export function parseShipmentExpression(value: unknown): ShipmentExpression {
terms: value.terms.map((term) => parse(term, depth + 1)),
};
}
if (Object.hasOwn(value, 'constant')) {
check(
options.allowConstants === true &&
Object.keys(value).join(',') === 'constant' &&
(value.constant === 'yes' ||
value.constant === 'no' ||
value.constant === 'unknown') &&
++leaves <= SHIPMENT_EXPRESSION_MAX_LEAVES,
'Frozen fact answers are reserved for internal mixed shipment claims.',
);
return { constant: value.constant };
}
check(
Object.keys(value).sort().join(',') === 'minimum,territory' &&
++leaves <= SHIPMENT_EXPRESSION_MAX_LEAVES,
@@ -65,24 +85,32 @@ export function parseShipmentExpression(value: unknown): ShipmentExpression {
return parse(value, 0);
}
/** Extract only claim fields; surrounding question and lifecycle metadata stays separate. */
export function parseShipmentClaim(value: unknown): ShipmentClaim {
export function parseShipmentClaim(
value: unknown,
options: ShipmentParseOptions = {},
): ShipmentClaim {
check(object(value), 'Choose a valid shipment statement.');
if (Object.hasOwn(value, 'claim')) {
check(
!Object.hasOwn(value, 'territory') && !Object.hasOwn(value, 'minimum'),
'Choose either a legacy shipment statement or an expression, not both.',
);
return { claim: parseShipmentExpression(value.claim) };
return { claim: parseShipmentExpression(value.claim, options) };
}
check(
!Object.hasOwn(value, 'op') && !Object.hasOwn(value, 'terms'),
!Object.hasOwn(value, 'op') &&
!Object.hasOwn(value, 'terms') &&
!Object.hasOwn(value, 'constant'),
'Place the shipment expression in its claim field.',
);
return parseLeaf(value);
}
export function validShipmentClaim(value: unknown): value is ShipmentClaim {
export function validShipmentClaim(
value: unknown,
options: ShipmentParseOptions = {},
): value is ShipmentClaim {
try {
parseShipmentClaim(value);
parseShipmentClaim(value, options);
return true;
} catch (error) {
if (error instanceof ShipmentClaimError) return false;
@@ -98,14 +126,24 @@ export function shipmentClaimDestinations(claim: ShipmentClaim): string[] {
const collect = (expression: ShipmentExpression): string[] =>
'op' in expression
? expression.terms.flatMap(collect)
: '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 &&
): 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
View File
@@ -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(
+5 -7
View File
@@ -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)</);
+2 -2
View File
@@ -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/);
}
});
+17
View File
@@ -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}]};
}
+87
View File
@@ -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);
});
+168
View File
@@ -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);
});