Expose grouped Truthtrance current-fact questions
This commit is contained in:
1 parent
2ea3c58f68
commit
8e2f2c6a95
14 files changed
+1187
-54
No files matched your search
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import type { TruthFact } from '@/game/truthtrance';
|
||||
import {
|
||||
TRUTH_FACT_MAX_DEPTH,
|
||||
TRUTH_FACT_MAX_NODES,
|
||||
TRUTH_FACT_MAX_TERMS,
|
||||
truthFactExpressionError,
|
||||
truthFactExpressionSize,
|
||||
} from '@/game/truth-fact-expression';
|
||||
import { TruthFactFields } from './mixed-shipment-fields';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const newFact = (): TruthFact => ({ kind: 'hand', name: 'Shield' });
|
||||
|
||||
/** Controlled structure editor. Leaf controls retain their existing validators. */
|
||||
export function TruthFactExpressionFields({ value, game, target, disabled, onChange }: {
|
||||
value: TruthFact;
|
||||
game: GameView;
|
||||
target: string;
|
||||
disabled: boolean;
|
||||
onChange: (fact: TruthFact) => void;
|
||||
}) {
|
||||
const size = truthFactExpressionSize(value);
|
||||
const commit = (next: TruthFact) => {
|
||||
if (!disabled && !truthFactExpressionError(next)) onChange(next);
|
||||
};
|
||||
const render = (
|
||||
node: TruthFact,
|
||||
path: string,
|
||||
replace: (next: TruthFact) => TruthFact,
|
||||
remove?: () => TruthFact,
|
||||
): ReactElement => {
|
||||
const group = node.kind === 'and' || node.kind === 'or';
|
||||
const allowed = (next: TruthFact) => !disabled && !truthFactExpressionError(replace(next));
|
||||
const change = (next: TruthFact) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): TruthFact => ({ kind, terms: [node, newFact()] });
|
||||
return <fieldset key={path} data-truth-fact-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{group ? `Group ${path} · ${node.kind.toUpperCase()}` : `Current fact ${path}`}</legend>
|
||||
{group ? <>
|
||||
<label>
|
||||
Group {path} meaning
|
||||
<select value={node.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.target.value;
|
||||
if (kind === 'and' || kind === 'or') change({ ...node, kind });
|
||||
}}>
|
||||
<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}`,
|
||||
next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }),
|
||||
() => {
|
||||
const terms = node.terms.filter((_, i) => i !== index);
|
||||
return replace(terms.length === 1 ? terms[0] : { ...node, terms });
|
||||
},
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" data-truth-fact-control={`add-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newFact()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newFact()] })}>
|
||||
Add current fact
|
||||
</Button>
|
||||
<Button type="button" data-truth-fact-control={`nested-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, { kind: 'and', terms: [newFact(), newFact()] }] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, { kind: 'and', terms: [newFact(), newFact()] }] })}>
|
||||
Add nested group
|
||||
</Button>
|
||||
</div>
|
||||
</> : <TruthFactFields value={node} game={game} target={target} onChange={change} />}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" data-truth-fact-control={`and-${path}`} disabled={!allowed(wrap('and'))}
|
||||
onClick={() => change(wrap('and'))}>Group with AND</Button>
|
||||
<Button type="button" data-truth-fact-control={`or-${path}`} disabled={!allowed(wrap('or'))}
|
||||
onClick={() => change(wrap('or'))}>Group with OR</Button>
|
||||
{remove && <Button type="button" data-truth-fact-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {group ? 'group' : 'fact'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="truth-fact-expression"
|
||||
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>Current-fact expression</legend>
|
||||
<p className="fine">
|
||||
AND requires all conditions; OR requires at least one. The player gives one aggregate answer
|
||||
for the whole expression. No makes the whole expression false, not necessarily every condition.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Current facts are evaluated when the player answers. They create no promise to keep cards,
|
||||
spice or forces afterward.
|
||||
</p>
|
||||
<p className="fine">
|
||||
App limits: {TRUTH_FACT_MAX_TERMS} terms per group, {TRUTH_FACT_MAX_NODES} total clauses
|
||||
including groups, and {TRUTH_FACT_MAX_DEPTH} levels of grouping. These are input limits,
|
||||
not additional printed rules.
|
||||
</p>
|
||||
<p className="fine">{size.nodes} / {TRUTH_FACT_MAX_NODES} total clauses · {size.depth} / {TRUTH_FACT_MAX_DEPTH} levels of grouping</p>
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { truthFactExpressionError } from '@/game/truth-fact-expression';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { truthQuestionText, type TruthFact, type TruthQuestion } from '@/game/truthtrance';
|
||||
import { invalidTruthFact } from './mixed-shipment-fields';
|
||||
import { TruthFactExpressionFields } from './truth-fact-expression-fields';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
/** Current-fact authoring uses only the caller's entitled public projection. */
|
||||
export function TruthFactQuestion({ game, question, busy, act, onChange }: {
|
||||
game: GameView;
|
||||
question: Extract<TruthQuestion, { kind: 'fact' }>;
|
||||
busy: boolean;
|
||||
act: (action: Action) => void;
|
||||
onChange: (fact: TruthFact) => void;
|
||||
}) {
|
||||
const ownsQuestion = () => !!game.me && game.players.some(player => player.id === game.me) &&
|
||||
game.truthtrance?.stage === 'ask' && game.truthtrance.queue[0]?.player === game.me;
|
||||
const hasTarget = () => question.target !== game.me && game.players.some(player => player.id === question.target);
|
||||
const inputError = () => truthFactExpressionError(question.fact) ||
|
||||
(hasTarget() && invalidTruthFact(question.fact, game, question.target)
|
||||
? 'Resolve the marked current-fact fields before asking.' : null);
|
||||
const blocked = () => busy || !ownsQuestion() || !hasTarget() || !!inputError();
|
||||
const error = inputError();
|
||||
return <>
|
||||
{hasTarget() ? <TruthFactExpressionFields value={question.fact} game={game} target={question.target}
|
||||
disabled={busy || !ownsQuestion() || !hasTarget()} onChange={next => {
|
||||
if (!busy && ownsQuestion() && hasTarget() && !truthFactExpressionError(next)) onChange(next);
|
||||
}} /> : <p className="notice">Choose another seated player for this question.</p>}
|
||||
<blockquote data-testid="truth-fact-preview">
|
||||
{truthQuestionText(question,
|
||||
id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : game.allLeaders.find(leader => leader.id === id)?.name ?? id,
|
||||
id => game.combatLocations.find(location => location.id === id)?.name ?? id)}
|
||||
</blockquote>
|
||||
{error && <p className="notice" role="alert">{error}</p>}
|
||||
<Button className="game-action" data-testid="truth-fact-ask" disabled={blocked()}
|
||||
onClick={() => { if (!blocked()) act({ type: 'truthAsk', question }); }}>
|
||||
Ask publicly
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
@@ -6,10 +6,9 @@ import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput }
|
||||
import { BattleClaimFields } from './battle-promises';
|
||||
import {
|
||||
MixedShipmentFields,
|
||||
TruthFactFields,
|
||||
invalidTruthFact,
|
||||
mixedShipmentInputError,
|
||||
} from './mixed-shipment-fields';
|
||||
import { TruthFactQuestion } from './truth-fact-question';
|
||||
import type { MixedShipmentExpression } from '@/game/mixed-shipment-question';
|
||||
import type { PlanClaim } from '@/game/battle-promises';
|
||||
import { useId, useState } from 'react';
|
||||
@@ -78,14 +77,8 @@ export function Truthtrance({
|
||||
const [scope, setScope] = useState<'fact' | 'currentTurn'>('fact');
|
||||
const [text, setText] = useState('');
|
||||
const [join, setJoin] = useState<'single' | 'and' | 'or'>('single');
|
||||
const [clauses, setClauses] = useState<TruthFact[]>([
|
||||
{ kind: 'hand', name: 'Shield' },
|
||||
{ kind: 'hand', name: 'Snooper' },
|
||||
]);
|
||||
const [fact, setFact] = useState<TruthFact>({ kind: 'hand', name: 'Shield' });
|
||||
const factId = useId();
|
||||
const invalidFact = clauses
|
||||
.slice(0, join === 'single' ? 1 : 2)
|
||||
.some((fact) => invalidTruthFact(fact, g, target));
|
||||
const leaderName = (id: string) =>
|
||||
id === CHEAP_HERO_TRAITOR
|
||||
? 'Cheap Hero / Heroine'
|
||||
@@ -97,7 +90,7 @@ export function Truthtrance({
|
||||
? {
|
||||
kind,
|
||||
target,
|
||||
fact: join === 'single' ? clauses[0] : { kind: join, terms: clauses },
|
||||
fact,
|
||||
}
|
||||
: kind === 'battlePlan'
|
||||
? {
|
||||
@@ -445,33 +438,10 @@ export function Truthtrance({
|
||||
) && <p>Select one of the combatants for this question.</p>}
|
||||
</>
|
||||
) : kind === 'fact' ? (
|
||||
<>
|
||||
<label>
|
||||
Combine facts
|
||||
<select
|
||||
value={join}
|
||||
onChange={(e) => setJoin(e.target.value as typeof join)}
|
||||
>
|
||||
<option value="single">One fact</option>
|
||||
<option value="and">Both facts · AND</option>
|
||||
<option value="or">Either fact · OR</option>
|
||||
</select>
|
||||
</label>
|
||||
{clauses
|
||||
.slice(0, join === 'single' ? 1 : 2)
|
||||
.map((c, index) => (
|
||||
<fieldset key={index}>
|
||||
<legend>Fact {index + 1}</legend>
|
||||
<TruthFactFields value={c} game={g} target={target}
|
||||
onChange={next => setClauses(clauses.map((old, n) => n === index ? next : old))} />
|
||||
</fieldset>
|
||||
))}
|
||||
<p className="notice">
|
||||
{invalidFact
|
||||
? 'Resolve the marked fields before asking this question.'
|
||||
: truthQuestionText(question, leaderName, locationName)}
|
||||
</p>
|
||||
</>
|
||||
question.kind === 'fact' && (
|
||||
<TruthFactQuestion game={g} question={question} busy={busy}
|
||||
act={act} onChange={setFact} />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
@@ -504,7 +474,7 @@ export function Truthtrance({
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{button(
|
||||
{kind !== 'fact' && button(
|
||||
'Ask publicly',
|
||||
{ type: 'truthAsk', question },
|
||||
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
|
||||
@@ -514,7 +484,6 @@ export function Truthtrance({
|
||||
(kind === 'mixedShipment' &&
|
||||
(!!invalidMixed || !shipmentAvailable)) ||
|
||||
(kind === 'freeform' && !text.trim()) ||
|
||||
(kind === 'fact' && invalidFact) ||
|
||||
(kind === 'battlePlan' &&
|
||||
(!g.battle ||
|
||||
![g.battle.attacker, g.battle.defender].includes(
|
||||
|
||||
+35
-7
@@ -16,6 +16,41 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Truthtrance compound current-fact authoring — 26 October. Prototyped / Partial.**
|
||||
The form now exposes two-through-eight-term AND/OR groups and nesting, with
|
||||
add/wrap/remove/collapse controls and a whole-question preview. Shared existing
|
||||
sixteen-node/depth-four input limits govern parser and editor. Invalid leaves
|
||||
beyond the former second row also block submission. Current target-only
|
||||
aggregate answers, the original action/queue, one discard and continuation stay
|
||||
unchanged; no future retention promise or new saved field is added.
|
||||
|
||||
Two disjoint native/control lanes and root integration pass **11 files,80/80**,
|
||||
types/lint/build and **eight native engine/GameTable SSR/actual edit-and-Ask
|
||||
callback paths** on unchanged source. A genuine public Basic A/E/H/G eleven-
|
||||
action setup/Storm prefix earns the original Truthtrance and Emperor Baliset,
|
||||
then keeps the real nonempty Ready list. Three/eight-term, mixed nested,
|
||||
sixteen-node and depth-four questions publish the truthful aggregate, discard
|
||||
once and resume the same Storm controls; each runtime takes seven continuation
|
||||
actions. All33 physical cards remain. Eight engine cases include24 flat paths,
|
||||
four nested paths, boundaries/rejections and four minimal profiles. Thirteen
|
||||
controlled form cases cover edits, whole-leaf validation and live ownership.
|
||||
|
||||
Frozen v2 report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-fact-expression-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-fact-expression-runtime/`.
|
||||
No saved-room/D1/server, reset/restart, deployment or recovery operation occurs.
|
||||
SSR/callbacks do not prove DOM/hydration or browser/mobile/deployed acceptance;
|
||||
the native source is Basic. Complete Truthtrance/rules/combinations and final
|
||||
assurance remain open. [Source and controls](TRUTHTRANCE_CARD_COUNT.md#compound-current-fact-authoring--26-october-2026).
|
||||
|
||||
Next concrete Basic function gap: battle-plan Truthtrance authoring still caps
|
||||
the existing AND/OR parser at two flat conditions. Connect its full grouped
|
||||
input controls to the existing legal-plan feasibility and sealing path. See
|
||||
`game/battle-promises.ts`, `components/battle-promises.tsx`, the battle-plan
|
||||
branch of `components/truthtrance.tsx` and the genuine `beforePlans` source in
|
||||
`tests/fixture-truth-traitor-decision.ts`. Pure shipment's maximum sixteen-leaf
|
||||
form is also pending; ordinary nested shipment already has its Mixed equivalent.
|
||||
|
||||
**Distrans before the first bid/pass — 26 October. Prototyped / Partial.**
|
||||
An untouched ordinary lot now allows the private card transfer before its first
|
||||
bid or pass, with the same dealt pool/index/opener/order/active seat. The span is
|
||||
@@ -44,13 +79,6 @@ deployment or recovery operation occurs. SSR/callbacks do not prove DOM/hydratio
|
||||
browser/mobile/deployed acceptance. Complete rules/combinations and final
|
||||
assurance remain open. [Source and policy](RICHESE_DISTRANS_ENGINE_AUDIT.md#untouched-ordinary-lot--26-october-2026).
|
||||
|
||||
Next concrete Basic function gap: Truthtrance's structured-question engine
|
||||
accepts two through eight facts, but the authoring panel only exposes two.
|
||||
Connect add/remove fact controls to the existing AND/OR action with actual
|
||||
aggregate-answer/resumption evidence. See `components/truthtrance.tsx`,
|
||||
`game/truthtrance.ts`, `tests/fixture-truth-movement-early.ts` and
|
||||
[the source contract](TRUTHTRANCE_CARD_COUNT.md).
|
||||
|
||||
**Shared Ecaz Ambassador Duke — 26 October. Prototyped / Partial.**
|
||||
The triggered token can now grant its living Duke to Ecaz's current reciprocal
|
||||
ally. Per-recipient controls and all four minimal profiles use the server's
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Full structured current-fact question authoring
|
||||
|
||||
**Prototyped / Partial.** Replace the two-flat-fact form cap with controlled
|
||||
AND/OR trees: add up to eight siblings, nest/wrap groups, switch their meaning
|
||||
and remove/collapse without losing other predicates. The whole preview and
|
||||
public action retain all terms. Existing parser limits (sixteen total nodes,
|
||||
depth four) move to a focused shared helper without changing accepted engine
|
||||
input. All leaf validation, including later rows, applies before Ask publicly;
|
||||
busy/stage/owner/target guards also apply to actual callbacks. The stateful parent
|
||||
keeps its draft, while battle-plan/shipment controls remain separate. No action,
|
||||
saved schema, timing, card-custody or mode permission is added.
|
||||
|
||||
Frozen **11-file80/80**, types/lint/build and **eight native engine/GameTable
|
||||
SSR/actual edit-and-Ask callback paths** pass. A genuine public Basic A/E/H/G
|
||||
eleven-action setup/Storm prefix earns the original cards and nonempty Ready.
|
||||
Three/eight-term AND/OR, mixed nesting, sixteen nodes and depth four use actual
|
||||
authoring callbacks, truthful target aggregate, one discard and the same Storm
|
||||
Ready resumption, seven continuation actions each. All33 original cards remain.
|
||||
Eight engine cases include24 flat and four nested native variants, accepted and
|
||||
rejected bounds, dishonest/foreign/unknown/repeated denials and all four minimal
|
||||
profiles. Thirteen controlled cases and four structure cases supplement the
|
||||
existing Truthtrance/reference regressions. Feedback corrected only two test
|
||||
typing issues: the malformed No-Field input cast and optional assertion regex.
|
||||
|
||||
Proof: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-fact-expression-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-fact-expression-runtime/`.
|
||||
No saved-room/D1/server, reset/restart, deployment or recovery operation. Native
|
||||
source is Basic; SSR/callbacks do not certify DOM/hydration or browser/mobile/
|
||||
deployed acceptance. Full Truthtrance, combinations and final assurance remain
|
||||
open. [Source and authoring contract](TRUTHTRANCE_CARD_COUNT.md#compound-current-fact-authoring--26-october-2026).
|
||||
|
||||
## 26 October 2026 — Distrans before the first ordinary bid/pass
|
||||
|
||||
**Prototyped / Partial.** Select a provisional first-bid/pass span under the
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Rule decision index
|
||||
|
||||
## Truthtrance compound current-fact controls — 26 October 2026
|
||||
|
||||
Expose existing AND/OR current-fact expressions without the old two-fact form
|
||||
cap. The November 2020 FAQ p8 authority already permits combined questions.
|
||||
Eight children per group, sixteen total nodes and depth four remain unchanged
|
||||
software input limits; sharing them with the editor does not establish printed
|
||||
rules or expand answer/custody permissions. Current aggregate facts create no
|
||||
future retention promise. [Source and controls](TRUTHTRANCE_CARD_COUNT.md#compound-current-fact-authoring--26-october-2026).
|
||||
|
||||
## Distrans before the first ordinary bid/pass — 26 October 2026
|
||||
|
||||
The printed card permits use except during a bid (publisher E2 p5). Under
|
||||
|
||||
@@ -2,6 +2,52 @@
|
||||
|
||||
Implemented 7 September 2026 local date. This closes a bounded base-game question gap: a player can now ask whether another player currently holds exactly, at least, or at most a specified number of cards with one canonical printed name. Previously the structured hand fact checked existence only; asking an AI the knowable equivalent in freeform produced unknown.
|
||||
|
||||
## Compound current-fact authoring — 26 October 2026
|
||||
|
||||
Current-fact questions now expose the existing structured AND/OR expression
|
||||
envelope instead of limiting the form to two facts. A player can add up to
|
||||
eight terms to a group, nest AND/OR groups, switch each group's meaning and
|
||||
remove individual facts or groups. Removing one of two siblings leaves the
|
||||
remaining fact directly; other conditions and their values are preserved.
|
||||
The preview and submitted action retain the whole tree without truncation.
|
||||
|
||||
The existing parser accepts at most sixteen total nodes, counting groups, and
|
||||
depth four. Those software input limits are now shared by the parser and
|
||||
editor through `game/truth-fact-expression.ts`; they are not newly asserted
|
||||
printed restrictions. Candidate edits that exceed them are unavailable.
|
||||
Every leaf uses the existing entitled fields and validation, including invalid
|
||||
values beyond the old second fact. Invalid structure or any invalid leaf blocks
|
||||
Ask publicly. Busy/wrong-owner/absent-target controls cannot submit a question.
|
||||
|
||||
The original `truthAsk` fact action, queue, card custody and answer projection
|
||||
are unchanged. All current facts are evaluated when the target answers. AND
|
||||
requires every branch; OR requires at least one. The player publishes one
|
||||
aggregate Yes/No/unknown result, without a separate answer per condition or a
|
||||
future promise to retain current cards, spice or forces. Existing minimal AI
|
||||
answers use their entitled result; no strategy or calibration is added.
|
||||
|
||||
`components/truth-fact-expression-fields.tsx` and `truth-fact-question.tsx`
|
||||
provide controlled edit/submit handlers; the stateful original Truthtrance
|
||||
panel owns the draft. Battle-plan and shipment/mixed-shipment authoring keep
|
||||
their separate existing controls. No saved field, action kind or mode gate is
|
||||
added. This closes the current-fact form cap, not full Truthtrance acceptance.
|
||||
|
||||
Frozen proof passes **11 files,80/80**, types/lint/build and eight native
|
||||
engine/GameTable SSR/actual edit-and-Ask callback journeys. The original Basic
|
||||
A/E/H/G setup/Storm source uses11 public actions, including the native two
|
||||
dials and Emperor Ready. Actual callbacks author three/eight-term AND/OR,
|
||||
mixed nesting, sixteen nodes and depth four; truthful target aggregate, one
|
||||
discard and original Storm Ready resumption take seven more actions each.
|
||||
All33 original cards remain. Eight engine cases cover24 flat variants, four
|
||||
nested variants, size/depth bounds, refusals and four-profile legal answers;
|
||||
thirteen controlled form and four structure cases add edit/validation evidence.
|
||||
Private report/logs:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-fact-expression-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-fact-expression-runtime/`.
|
||||
No saved-room/D1/server or recovery operation occurs. This native source is
|
||||
Basic; DOM/hydration, browser/mobile/deployed and broader combination acceptance
|
||||
remain due. Historical named-count evidence below is separately dated.
|
||||
|
||||
## Source and interpretation
|
||||
|
||||
The [official November 2020 GF9 FAQ, printed p.8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) permits game-related yes/no questions and combinations with AND/OR. Its separate future-action discussion limits commitments to the current turn. The official PDF's indexed page text was retrieved again during this implementation; a newly downloaded local PDF is not claimed. The [earlier nonbattle audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) records the same authority and remaining promise work.
|
||||
|
||||
+30
-3
@@ -2977,7 +2977,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary:
|
||||
'Ask whether another player holds exactly, at least or at most a number of copies of one named card.',
|
||||
steps: [
|
||||
'Declare Truthtrance and choose Verified cards, traitors or spice. Select Number of a named card, choose its exact name, select a comparison and enter a whole count of zero or more. Review the public question before asking.',
|
||||
'Declare Truthtrance and choose Verified current facts. Select Number of a named card, choose its exact name, select a comparison and enter a whole count of zero or more. Review the public question before asking.',
|
||||
'Only physical cards currently in that player’s hand count. Separate cache cards, cards in discard and cards still in a deck are excluded. Shield and Shield Snooper are different names. A card retained in hand after battle still counts.',
|
||||
'The shared canonical name list includes the three independent Ecaz cards as well as ordinary/Ix and separate Richese cache identities. Naming one does not select its family, expose its physical ID or change its battle role.',
|
||||
'The server establishes the truthful Yes or No privately for the target. Their public answer reveals only the requested comparison, not card identities or the rest of their hand. An exact-count question answered Yes naturally confirms that count.',
|
||||
@@ -2986,7 +2986,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
example:
|
||||
'“Do you currently hold at least two cards named Shield?” is Yes with two Shields and No with one Shield plus one Shield Snooper.',
|
||||
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
|
||||
related: ['card-truthtrance', 'truthtrance-fact-expression', 'truthtrance-spice', 'privacy'],
|
||||
checklist: [
|
||||
{
|
||||
area: 'Implementation',
|
||||
@@ -3024,6 +3024,32 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-fact-expression',
|
||||
title: 'Truthtrance: grouped current facts',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
summary: 'Build one current-fact question with multiple AND/OR conditions and nested groups.',
|
||||
steps: [
|
||||
'After declaring Truthtrance and resolving priority, choose another player and Verified current facts. Start with one current fact. Group it with AND or OR, add further facts or nested groups, and review the whole public question.',
|
||||
'AND requires all conditions. OR requires at least one condition. Each nested group keeps its own meaning. Removing a fact preserves its siblings; a group with one remaining fact collapses to that fact.',
|
||||
'The builder supports up to eight terms per group, sixteen total items including groups, and four levels of grouping. These are app input limits. Add/group controls explain the available expression, and invalid fields anywhere in the tree block Ask publicly.',
|
||||
'Current facts are evaluated when the target answers. Only the aggregate answer is published. No makes the whole expression false; it does not necessarily make each condition false. Unknown retains the existing option to ask again or save the card.',
|
||||
'A definite answer uses the original single card discard and resumes the previous table decision. Current facts do not promise that cards, spice or forces will remain afterward. Battle-plan and future shipment or movement promises use their separate question types.',
|
||||
],
|
||||
example: 'Does the Emperor have at least ten spice AND at least one Baliset AND no Shields? One public Yes confirms that whole current conjunction without listing any other card.',
|
||||
related: ['card-truthtrance', 'truthtrance-card-count', 'truthtrance-spice', 'truthtrance-forces'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Existing structured fact parsing and aggregate evaluation retain their limits, now shared with controlled whole-tree authoring; actions and saved state are unchanged.' },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Add, wrap, AND/OR selection, nested groups, remove/collapse, whole-tree validation, persistent meaning and actual public submission are connected.' },
|
||||
{ area: 'AI', status: 'Partial', detail: 'The four existing profiles answer their entitled aggregate result. Full proactive question strategy and difficulty calibration remain deferred.' },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Explains grouping, aggregate disclosure, current-only meaning and software input limits separately from printed permission.' },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused structure/control and genuine original-card question/answer cases cover the authoring path. Browser/mobile/deployed acceptance and full Truthtrance combinations remain due.', evidence: [
|
||||
'game/truth-fact-expression.ts', 'components/truth-fact-expression-fields.tsx', 'components/truth-fact-question.tsx', 'components/truthtrance.tsx',
|
||||
'tests/truth-fact-expression.test.ts', 'tests/truth-fact-expression-controls.test.ts', 'tests/truth-fact-expression-engine.test.ts', 'tests/fixture-truth-fact-expression.ts', 'docs/TRUTHTRANCE_CARD_COUNT.md',
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-face-dancers',
|
||||
title: 'Truthtrance: currently unrevealed Face Dancers',
|
||||
@@ -3054,7 +3080,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary:
|
||||
'Ask a verified Yes/No question about total hand size or how many held cards have a selected primary role.',
|
||||
steps: [
|
||||
'Declare Truthtrance, choose Verified cards, traitors or spice, then Hand size or primary card role. Select all cards or a primary role, a comparison and a whole count of zero or more. Review the question before asking.',
|
||||
'Declare Truthtrance, choose Verified current facts, then Hand size or primary card role. Select all cards or a primary role, a comparison and a whole count of zero or more. Review the question before asking.',
|
||||
'Count only physical cards currently in the target’s hand, including reserved cards still held there. Decks, discards, separate caches and cards already on the table do not count. Every held physical copy counts once.',
|
||||
'Primary roles are the cards’ default roles. Weirding Way is a weapon; Chemistry is a defense. Alternate battle roles do not change these counts. Worthless cards and Cheap Heroes or Heroines are separate categories. Bene Gesserit and CHOAM powers do not turn held Worthless cards into another category.',
|
||||
'Richese cards use their printed roles: Stone Burner and Mirror Weapon are weapons; Portable Snooper is a defense. Other Special cards belong to the Special category. A classification question does not make an unfinished card effect available.',
|
||||
@@ -3296,6 +3322,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-forces',
|
||||
'truthtrance-card-count',
|
||||
'truthtrance-hand-inventory',
|
||||
'truthtrance-fact-expression',
|
||||
'truthtrance-shipment',
|
||||
'truthtrance-movement',
|
||||
'richese-no-field',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { TruthFact } from './truthtrance';
|
||||
|
||||
// Existing parser limits. These bound structured input; they are not printed
|
||||
// restrictions on which game-related yes/no questions may be asked.
|
||||
export const TRUTH_FACT_MAX_NODES = 16;
|
||||
export const TRUTH_FACT_MAX_DEPTH = 4;
|
||||
export const TRUTH_FACT_MAX_TERMS = 8;
|
||||
export const TRUTH_FACT_SIZE_ERROR =
|
||||
'Truthtrance supports at most 16 clauses and four levels of grouping.';
|
||||
export const TRUTH_FACT_GROUP_ERROR =
|
||||
'Combine two to eight fact clauses with AND or OR.';
|
||||
|
||||
export function truthFactExpressionSize(fact: TruthFact): { nodes: number; depth: number } {
|
||||
if (fact.kind !== 'and' && fact.kind !== 'or') return { nodes: 1, depth: 0 };
|
||||
const children = fact.terms.map(truthFactExpressionSize);
|
||||
return {
|
||||
nodes: 1 + children.reduce((sum, child) => sum + child.nodes, 0),
|
||||
depth: 1 + Math.max(0, ...children.map(child => child.depth)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Structural input feedback; leaf eligibility remains with the existing
|
||||
* entitled field validators and the authoritative question parser. */
|
||||
export function truthFactExpressionError(fact: TruthFact): string | null {
|
||||
const size = truthFactExpressionSize(fact);
|
||||
if (size.nodes > TRUTH_FACT_MAX_NODES || size.depth > TRUTH_FACT_MAX_DEPTH)
|
||||
return TRUTH_FACT_SIZE_ERROR;
|
||||
const groupError = (node: TruthFact): string | null => {
|
||||
if (node.kind !== 'and' && node.kind !== 'or') return null;
|
||||
if (node.terms.length < 2 || node.terms.length > TRUTH_FACT_MAX_TERMS)
|
||||
return TRUTH_FACT_GROUP_ERROR;
|
||||
for (const term of node.terms) {
|
||||
const error = groupError(term);
|
||||
if (error) return error;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return groupError(fact);
|
||||
}
|
||||
+9
-5
@@ -37,6 +37,10 @@ import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCo
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
|
||||
TRUTH_FACT_SIZE_ERROR, TRUTH_FACT_GROUP_ERROR,
|
||||
} from './truth-fact-expression';
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { canUseAsTruthtranceRole } from './shrine';
|
||||
import { isKnowledgeFact, parseKnowledgeFact, knowledgeFactAnswer, knowledgeFactText, truthKnowledgeOf, type KnowledgeFact, type TruthKnowledge } from './truthtrance-knowledge';
|
||||
@@ -103,11 +107,11 @@ function parseFact(
|
||||
value: unknown,
|
||||
respondent: Player,
|
||||
depth = 0,
|
||||
budget = { remaining: 16 },
|
||||
budget = { remaining: TRUTH_FACT_MAX_NODES },
|
||||
): TruthFact {
|
||||
check(
|
||||
depth <= 4 && budget.remaining-- > 0,
|
||||
'Truthtrance supports at most 16 clauses and four levels of grouping.',
|
||||
depth <= TRUTH_FACT_MAX_DEPTH && budget.remaining-- > 0,
|
||||
TRUTH_FACT_SIZE_ERROR,
|
||||
);
|
||||
const v = record(value);
|
||||
if (v.kind === 'forceCount') {
|
||||
@@ -183,8 +187,8 @@ function parseFact(
|
||||
(v.kind === 'and' || v.kind === 'or') &&
|
||||
Array.isArray(v.terms) &&
|
||||
v.terms.length >= 2 &&
|
||||
v.terms.length <= 8,
|
||||
'Combine two to eight fact clauses with AND or OR.',
|
||||
v.terms.length <= TRUTH_FACT_MAX_TERMS,
|
||||
TRUTH_FACT_GROUP_ERROR,
|
||||
);
|
||||
return {
|
||||
kind: v.kind,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import type { TruthAnswer, TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
createTruthMovementEarlyFixture,
|
||||
actTruthMovementEarly,
|
||||
truthMovementEarlyClean,
|
||||
truthMovementEarlyPlayer,
|
||||
truthMovementEarlyInventory,
|
||||
type TruthMovementEarlyStep,
|
||||
} from './fixture-truth-movement-early';
|
||||
|
||||
export type TruthFactExpressionStep = TruthMovementEarlyStep;
|
||||
export type TruthFactExpressionScenario = {
|
||||
id: string;
|
||||
fact: TruthFact;
|
||||
answer: 'yes' | 'no';
|
||||
};
|
||||
export const actTruthFactExpression = actTruthMovementEarly;
|
||||
export const truthFactExpressionClean = truthMovementEarlyClean;
|
||||
export const truthFactExpressionPlayer = truthMovementEarlyPlayer;
|
||||
export const truthFactExpressionInventory = truthMovementEarlyInventory;
|
||||
|
||||
/** These predicates describe the genuine Emperor starting position below.
|
||||
* Basic Emperor uses total physical forces; no elite split is invented. */
|
||||
export function truthFactExpressionFacts(): { trueFacts: TruthFact[]; falseFacts: TruthFact[] } {
|
||||
return {
|
||||
trueFacts: [
|
||||
{ kind: 'spice', compare: 'gte', value: 10 },
|
||||
{ kind: 'handCount', name: 'Baliset', compare: 'gte', value: 1 },
|
||||
{ kind: 'handCount', name: 'Shield', compare: 'eq', value: 0 },
|
||||
{ kind: 'handInventory', category: 'all', compare: 'eq', value: 1 },
|
||||
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'eq', value: 20 },
|
||||
{ kind: 'forceCount', zone: { kind: 'tanks' }, counter: 'total', compare: 'eq', value: 0 },
|
||||
{ kind: 'forceCount', zone: { kind: 'location', territory: 'arrakeen', sector: 10 },
|
||||
counter: 'total', compare: 'eq', value: 0 },
|
||||
{ kind: 'handInventory', category: 'worthless', compare: 'eq', value: 1 },
|
||||
],
|
||||
falseFacts: [
|
||||
{ kind: 'spice', compare: 'gte', value: 11 },
|
||||
{ kind: 'handCount', name: 'Baliset', compare: 'gte', value: 2 },
|
||||
{ kind: 'handCount', name: 'Shield', compare: 'gte', value: 1 },
|
||||
{ kind: 'handInventory', category: 'all', compare: 'eq', value: 0 },
|
||||
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 21 },
|
||||
{ kind: 'forceCount', zone: { kind: 'tanks' }, counter: 'total', compare: 'gte', value: 1 },
|
||||
{ kind: 'forceCount', zone: { kind: 'location', territory: 'arrakeen', sector: 10 },
|
||||
counter: 'total', compare: 'gte', value: 1 },
|
||||
{ kind: 'handInventory', category: 'weapon', compare: 'gte', value: 1 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Build three through eight clauses from original, independently known facts.
|
||||
* The decisive final clause also exercises evaluation beyond the old second row. */
|
||||
export function truthFactExpressionScenario(
|
||||
kind: 'and' | 'or' = 'and',
|
||||
count = 3,
|
||||
answer: 'yes' | 'no' = 'yes',
|
||||
): TruthFactExpressionScenario {
|
||||
assert.ok(Number.isInteger(count) && count >= 3 && count <= 8);
|
||||
const { trueFacts, falseFacts } = truthFactExpressionFacts();
|
||||
const terms = (kind === 'and' ? trueFacts : falseFacts).slice(0, count);
|
||||
if (kind === 'and' && answer === 'no') terms[count - 1] = falseFacts[count - 1];
|
||||
if (kind === 'or' && answer === 'yes') terms[count - 1] = trueFacts[count - 1];
|
||||
return { id: `${count}-${kind}-${answer}`, fact: { kind, terms }, answer };
|
||||
}
|
||||
|
||||
export function truthFactExpressionNestedScenario(
|
||||
kind: 'and' | 'or' = 'and',
|
||||
answer: 'yes' | 'no' = 'yes',
|
||||
): TruthFactExpressionScenario {
|
||||
const { trueFacts, falseFacts } = truthFactExpressionFacts();
|
||||
const fact: TruthFact = kind === 'and'
|
||||
? { kind: 'and', terms: [trueFacts[0], { kind: 'or', terms: [falseFacts[0], falseFacts[1],
|
||||
answer === 'yes' ? trueFacts[2] : falseFacts[2]] }, trueFacts[3]] }
|
||||
: { kind: 'or', terms: [falseFacts[0], { kind: 'and', terms: [trueFacts[0], trueFacts[1],
|
||||
answer === 'yes' ? trueFacts[2] : falseFacts[2]] }, falseFacts[3]] };
|
||||
return { id: `nested-${kind}-${answer}`, fact, answer };
|
||||
}
|
||||
|
||||
/** Original public Basic A/E/H/G setup, followed by its actual two zero Storm
|
||||
* dials and one Emperor Ready. No postdeal hand, wallet, army, phase, response,
|
||||
* question, answer or discard is installed. Only the inherited unopened deck
|
||||
* positions and original scalar lotteries are selected. */
|
||||
export function createTruthFactExpressionFixture() {
|
||||
const base = createTruthMovementEarlyFixture({ phase: 0 });
|
||||
let game = structuredClone(base.game);
|
||||
const actions: TruthFactExpressionStep[] = structuredClone(base.actions);
|
||||
const beforeStormControls = structuredClone(game);
|
||||
assert.equal(game.stormPending, null);
|
||||
for (const actor of game.stormDialers)
|
||||
game = actTruthFactExpression(game, actor, { type: 'stormDial', amount: 0 }, actions);
|
||||
game = actTruthFactExpression(game, base.target, { type: 'ready' }, actions);
|
||||
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 0); assert.equal(game.stormPending, 0);
|
||||
assert.deepEqual(game.ready, [base.target]); assert.deepEqual(game.expansions, []);
|
||||
assert.equal(game.response ?? null, null); assert.ok(truthFactExpressionClean(game));
|
||||
const target = truthFactExpressionPlayer(game, base.target);
|
||||
assert.equal(target.spice, 10); assert.equal(target.reserves, 20); assert.equal(target.tanks, 0);
|
||||
assert.deepEqual(target.forces, {}); assert.deepEqual(target.hand.map(card => card.name), ['Baliset']);
|
||||
assert.ok(truthFactExpressionPlayer(game, base.asker).hand.some(card => card.id === base.card));
|
||||
assert.ok(truthFactExpressionPlayer(game, base.counterHolder).hand.some(card => card.id === base.karama));
|
||||
assert.equal(base.originalCards.length, 33);
|
||||
assert.deepEqual(truthFactExpressionInventory(game), base.originalCards);
|
||||
const scenario = truthFactExpressionScenario();
|
||||
const question: TruthQuestion = { kind: 'fact', target: base.target, fact: scenario.fact };
|
||||
const declarationStep: TruthFactExpressionStep = { actor: base.asker,
|
||||
action: { type: 'card', card: base.card } };
|
||||
const askStep: TruthFactExpressionStep = { actor: base.asker, action: { type: 'truthAsk', question } };
|
||||
const answerStep: TruthFactExpressionStep = { actor: base.target,
|
||||
action: { type: 'truthAnswer', answer: scenario.answer } };
|
||||
const continuationStep: TruthFactExpressionStep = { actor: base.asker, action: { type: 'ready' } };
|
||||
return {
|
||||
game, source: structuredClone(game), base, beforeStormControls,
|
||||
lobby: base.lobby, setup: base.setup, beforeStartingDeal: base.beforeStartingDeal,
|
||||
afterSetup: base.afterSetup, deal: base.deal, dealOrder: base.dealOrder,
|
||||
spiceOrder: base.spiceOrder, originalCards: base.originalCards, originalTreachery: base.originalTreachery,
|
||||
originalSpice: base.originalSpice, asker: base.asker, target: base.target,
|
||||
counterHolder: base.counterHolder, guild: base.guild, card: base.card, truth: base.card, karama: base.karama,
|
||||
scenario, fact: scenario.fact, question, declarationStep, askStep, answerStep, continuationStep,
|
||||
actions, sourceActionCount: actions.length,
|
||||
stormSteps: structuredClone(actions.slice(base.sourceActionCount)),
|
||||
sourceCounts: { setup: base.sourceActionCount, storm: actions.length },
|
||||
staging: [...base.staging,
|
||||
'The current-fact source additionally takes the two native zero Storm dials and Emperor Ready, retaining turn 1 Storm with that nonempty Ready list. Questions concern only Emperor’s original Baliset, ten spice, twenty total reserves, zero Tanks and empty Arrakis positions. Truthtrance declaration, every priority pass, authored question, aggregate answer, physical discard and later Atreides Ready remain caller actions.'],
|
||||
};
|
||||
}
|
||||
export type TruthFactExpressionFixture = ReturnType<typeof createTruthFactExpressionFixture>;
|
||||
|
||||
export function passTruthFactExpressionPriority(game: Game, actions: TruthFactExpressionStep[] = []): Game {
|
||||
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthFactExpression(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function declareTruthFactExpression(fixture: TruthFactExpressionFixture) {
|
||||
const before = structuredClone(fixture.game), actions: TruthFactExpressionStep[] = [];
|
||||
const declaration = structuredClone(fixture.declarationStep);
|
||||
const game = actTruthFactExpression(before, declaration.actor, declaration.action, actions);
|
||||
assert.equal(game.truthtrance?.stage, 'priority');
|
||||
return { game, before, declaration, opened: structuredClone(game), actions };
|
||||
}
|
||||
|
||||
export function askTruthFactExpression(fixture: TruthFactExpressionFixture, fact: TruthFact = fixture.fact) {
|
||||
const declared = declareTruthFactExpression(fixture), actions = declared.actions;
|
||||
let game = passTruthFactExpressionPriority(declared.game, actions);
|
||||
const beforeQuestion = structuredClone(game);
|
||||
const question: TruthFactExpressionStep = { actor: fixture.asker,
|
||||
action: { type: 'truthAsk', question: { kind: 'fact', target: fixture.target, fact: structuredClone(fact) } } };
|
||||
game = actTruthFactExpression(game, question.actor, question.action, actions);
|
||||
assert.equal(game.truthtrance?.stage, 'answer');
|
||||
return { ...declared, game, beforeQuestion, question, actions };
|
||||
}
|
||||
|
||||
export function answerTruthFactExpression(
|
||||
game: Game, answer: TruthAnswer, actions: TruthFactExpressionStep[] = [],
|
||||
): Game {
|
||||
const target = game.truthtrance?.question?.target; assert.ok(target);
|
||||
assert.equal(viewGame(game, target).truthAnswer, answer);
|
||||
return actTruthFactExpression(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
|
||||
export function finishTruthFactExpression(
|
||||
fixture: TruthFactExpressionFixture,
|
||||
scenario: TruthFactExpressionScenario = fixture.scenario,
|
||||
) {
|
||||
const asked = askTruthFactExpression(fixture, scenario.fact), actions = asked.actions;
|
||||
const game = answerTruthFactExpression(asked.game, scenario.answer, actions);
|
||||
const answerStep = actions.at(-1)!;
|
||||
assert.ok(truthFactExpressionClean(game));
|
||||
return { ...asked, asked: asked.game, game, answerStep, actions };
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TruthFactFields } from '../components/mixed-shipment-fields';
|
||||
import { TruthFactExpressionFields } from '../components/truth-fact-expression-fields';
|
||||
import { TruthFactQuestion } from '../components/truth-fact-question';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { truthFactExpressionError, truthFactExpressionSize } from '../game/truth-fact-expression';
|
||||
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
|
||||
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
|
||||
},
|
||||
});
|
||||
const { Truthtrance } = await import('../components/truthtrance');
|
||||
aliases.deregister();
|
||||
|
||||
// Controlled entitled projections supplement the separate native card journeys.
|
||||
// No authoritative hand, question result or continuation is installed in a Game.
|
||||
// Only hook-free components are invoked directly. React renders TruthFactFields
|
||||
// and its stateful descendants; leaf edits cross its actual onChange boundary.
|
||||
function projected(): GameView {
|
||||
const game = createGame('TRUTHFACTCONTROLS', newPlayer('a', 'Asker', 'atreides'), false);
|
||||
joinGame(game, newPlayer('t', 'Target', 'emperor'));
|
||||
joinGame(game, newPlayer('o', 'Observer', 'harkonnen'));
|
||||
const view = viewGame(game, 'a');
|
||||
view.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'projected-truth-card' }], passed: [], question: null };
|
||||
return view;
|
||||
}
|
||||
const hand = (name = 'Shield'): TruthFact => ({ kind: 'hand', name });
|
||||
const spice = (value: number): TruthFact => ({ kind: 'spice', compare: 'gte', value });
|
||||
const factQuestion = (fact: TruthFact, target = 't'): Extract<TruthQuestion, { kind: 'fact' }> => ({ kind: 'fact', target, fact });
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<ElementProps>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
function control(tree: ReactNode, key: string): Element {
|
||||
const found = elements(tree).find(element => element.props['data-truth-fact-control'] === key);
|
||||
assert.ok(found, `Control ${key} is rendered`);
|
||||
return found;
|
||||
}
|
||||
function click(button: Element) { (button.props.onClick as () => void)(); }
|
||||
function fieldset(tree: ReactNode, path: string): Element {
|
||||
const found = elements(tree).find(element => element.props['data-truth-fact-path'] === path);
|
||||
assert.ok(found, `Fact or group ${path} is rendered`);
|
||||
return found;
|
||||
}
|
||||
function editLeaf(tree: ReactNode, path: string, next: TruthFact) {
|
||||
const field = elements(fieldset(tree, path)).find(element => element.type === TruthFactFields);
|
||||
assert.ok(field, `Current fact fields for ${path} are rendered`);
|
||||
(field.props.onChange as (fact: TruthFact) => void)(next);
|
||||
}
|
||||
function switchGroup(tree: ReactNode, path: string, kind: string) {
|
||||
const select = elements(fieldset(tree, path)).find(element => element.type === 'select');
|
||||
assert.ok(select, `Group meaning selector ${path} is rendered`);
|
||||
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: kind } });
|
||||
}
|
||||
function editor(initial: TruthFact, game = projected(), disabled = false) {
|
||||
let value = initial;
|
||||
let changes = 0;
|
||||
return {
|
||||
get value() { return value; },
|
||||
get changes() { return changes; },
|
||||
tree: () => TruthFactExpressionFields({ value, game, target: 't', disabled, onChange(next) { value = next; changes++; } }),
|
||||
html: () => renderToStaticMarkup(createElement(TruthFactExpressionFields, {
|
||||
value, game, target: 't', disabled, onChange() {},
|
||||
})),
|
||||
};
|
||||
}
|
||||
function askTree(game: GameView, question: Extract<TruthQuestion, { kind: 'fact' }>, busy = false) {
|
||||
const actions: Action[] = [];
|
||||
const changes: TruthFact[] = [];
|
||||
const props = { game, question, busy, act(action: Action) { actions.push(action); }, onChange(next: TruthFact) { changes.push(next); } };
|
||||
const tree = TruthFactQuestion(props);
|
||||
const button = elements(tree).find(element => element.props['data-testid'] === 'truth-fact-ask');
|
||||
assert.ok(button, 'The actual Ask action is rendered');
|
||||
return { tree, button, actions, changes, html: () => renderToStaticMarkup(createElement(TruthFactQuestion, props)) };
|
||||
}
|
||||
function groupsDeep(depth: number): TruthFact {
|
||||
let fact = hand();
|
||||
for (let level = 1; level <= depth; level++) fact = { kind: 'and', terms: [fact, spice(level)] };
|
||||
return fact;
|
||||
}
|
||||
|
||||
void test('actual add and leaf-edit callbacks author three through eight terms without losing prior facts', () => {
|
||||
const first: TruthFact = { kind: 'handCount', name: 'Shield', compare: 'eq', value: 2 };
|
||||
const e = editor(first);
|
||||
click(control(e.tree(), 'and-1'));
|
||||
editLeaf(e.tree(), '1.2', { kind: 'traitor', leader: CHEAP_HERO_TRAITOR });
|
||||
for (let count = 3; count <= 8; count++) {
|
||||
const before = structuredClone(e.value);
|
||||
click(control(e.tree(), 'add-1'));
|
||||
editLeaf(e.tree(), `1.${count}`, spice(count));
|
||||
assert.ok(e.value.kind === 'and');
|
||||
assert.equal(e.value.terms.length, count);
|
||||
assert.deepEqual(e.value.terms.slice(0, count - 1), (before as Extract<TruthFact, { kind: 'and' | 'or' }>).terms);
|
||||
assert.deepEqual(e.value.terms[count - 1], spice(count));
|
||||
}
|
||||
assert.ok(e.value.kind === 'and');
|
||||
assert.deepEqual(e.value.terms[0], first);
|
||||
const html = e.html();
|
||||
assert.equal((html.match(/Current fact type/g) ?? []).length, 8);
|
||||
assert.match(html, /9 \/ 16 total clauses/);
|
||||
const before = structuredClone(e.value), changes = e.changes;
|
||||
assert.equal(control(e.tree(), 'add-1').props.disabled, true);
|
||||
click(control(e.tree(), 'add-1'));
|
||||
assert.equal(e.changes, changes);
|
||||
assert.deepEqual(e.value, before);
|
||||
});
|
||||
|
||||
void test('nested AND and OR callbacks preserve every branch while switching group meaning', () => {
|
||||
const e = editor(hand('Snooper'));
|
||||
click(control(e.tree(), 'and-1'));
|
||||
editLeaf(e.tree(), '1.2', spice(7));
|
||||
click(control(e.tree(), 'or-1.2'));
|
||||
editLeaf(e.tree(), '1.2.2', { kind: 'handInventory', category: 'defense', compare: 'lte', value: 3 });
|
||||
click(control(e.tree(), 'add-1.2'));
|
||||
editLeaf(e.tree(), '1.2.3', { kind: 'stormForecast', compare: 'gte', value: 2 });
|
||||
const before = structuredClone(e.value);
|
||||
assert.ok(before.kind === 'and');
|
||||
const originalNested = before.terms[1];
|
||||
assert.ok(originalNested.kind === 'or');
|
||||
switchGroup(e.tree(), '1.2', 'and');
|
||||
assert.deepEqual(e.value, { kind: 'and', terms: [hand('Snooper'), {
|
||||
kind: 'and', terms: [spice(7), { kind: 'handInventory', category: 'defense', compare: 'lte', value: 3 }, { kind: 'stormForecast', compare: 'gte', value: 2 }],
|
||||
}] });
|
||||
switchGroup(e.tree(), '1.2', 'or');
|
||||
assert.deepEqual(e.value, before);
|
||||
const changes = e.changes;
|
||||
switchGroup(e.tree(), '1.2', 'single');
|
||||
assert.equal(e.changes, changes);
|
||||
click(control(e.tree(), 'nested-1.2'));
|
||||
assert.ok(e.value.kind === 'and');
|
||||
const nested = e.value.terms[1];
|
||||
assert.ok(nested.kind === 'or');
|
||||
assert.deepEqual(nested.terms.slice(0, 3), originalNested.terms);
|
||||
assert.deepEqual(nested.terms[3], { kind: 'and', terms: [hand(), hand()] });
|
||||
assert.match(e.html(), /Group 1\.2 · OR/);
|
||||
assert.match(e.html(), /Group 1\.2\.4 · AND/);
|
||||
});
|
||||
|
||||
void test('removing a fact collapses a singleton group and removing a group keeps the full survivor', () => {
|
||||
const retained: TruthFact = { kind: 'forceCount', zone: { kind: 'location', territory: 'carthag', sector: 10 }, counter: 'total', compare: 'eq', value: 4 };
|
||||
const e = editor({ kind: 'or', terms: [hand(), { kind: 'and', terms: [spice(9), retained] }] });
|
||||
click(control(e.tree(), 'remove-1.2.1'));
|
||||
assert.deepEqual(e.value, { kind: 'or', terms: [hand(), retained] });
|
||||
assert.equal(truthFactExpressionError(e.value), null);
|
||||
click(control(e.tree(), 'remove-1.1'));
|
||||
assert.deepEqual(e.value, retained);
|
||||
assert.equal(truthFactExpressionError(e.value), null);
|
||||
const nestedSurvivor: TruthFact = { kind: 'or', terms: [spice(1), spice(2), spice(3)] };
|
||||
const whole = editor({ kind: 'and', terms: [{ kind: 'and', terms: [hand(), hand('Snooper')] }, nestedSurvivor] });
|
||||
click(control(whole.tree(), 'remove-1.1'));
|
||||
assert.deepEqual(whole.value, nestedSurvivor);
|
||||
});
|
||||
|
||||
void test('the sixteen-node envelope guards add and wrap callbacks without truncating the maximum expression', () => {
|
||||
const full: TruthFact = { kind: 'and', terms: [
|
||||
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => spice(index)) },
|
||||
{ kind: 'and', terms: Array.from({ length: 5 }, (_, index) => spice(index + 8)) },
|
||||
] };
|
||||
assert.deepEqual(truthFactExpressionSize(full), { nodes: 16, depth: 2 });
|
||||
const e = editor(full), before = structuredClone(full);
|
||||
for (const key of ['add-1', 'nested-1', 'add-1.2', 'and-1.2.1', 'or-1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true, key);
|
||||
click(control(e.tree(), key));
|
||||
}
|
||||
assert.equal(e.changes, 0);
|
||||
assert.deepEqual(e.value, before);
|
||||
assert.equal((e.html().match(/Current fact type/g) ?? []).length, 13);
|
||||
editLeaf(e.tree(), '1.2.5', { kind: 'handCount', name: 'Snooper', compare: 'eq', value: 4 });
|
||||
assert.equal(e.changes, 1, 'Editing an existing leaf is still available at the maximum');
|
||||
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 16, depth: 2 });
|
||||
});
|
||||
|
||||
void test('four levels allow shallow nesting and deepest leaf edits while rejecting a fifth level in actual callbacks', () => {
|
||||
const e = editor(groupsDeep(4));
|
||||
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 9, depth: 4 });
|
||||
for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-1.1.1.1']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true, key);
|
||||
click(control(e.tree(), key));
|
||||
}
|
||||
assert.equal(e.changes, 0);
|
||||
editLeaf(e.tree(), '1.1.1.1.1', hand('Snooper'));
|
||||
click(control(e.tree(), 'add-1.1.1.1'));
|
||||
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 10, depth: 4 });
|
||||
assert.equal(control(e.tree(), 'or-1.2').props.disabled, false);
|
||||
click(control(e.tree(), 'or-1.2'));
|
||||
assert.deepEqual(truthFactExpressionSize(e.value), { nodes: 12, depth: 4 });
|
||||
assert.equal(truthFactExpressionError(e.value), null);
|
||||
});
|
||||
|
||||
void test('busy disables the outer fieldset and guards directly invoked add, wrap, remove, switch and leaf callbacks', () => {
|
||||
const initial: TruthFact = { kind: 'and', terms: [hand(), spice(3)] };
|
||||
const e = editor(initial, projected(), true);
|
||||
assert.match(e.html(), /<fieldset\b[^>]*disabled=""[^>]*data-testid="truth-fact-expression"/);
|
||||
for (const key of ['add-1', 'nested-1', 'and-1.1', 'or-1.1', 'remove-1.2']) {
|
||||
assert.equal(control(e.tree(), key).props.disabled, true, key);
|
||||
click(control(e.tree(), key));
|
||||
}
|
||||
switchGroup(e.tree(), '1', 'or');
|
||||
editLeaf(e.tree(), '1.2', spice(999));
|
||||
assert.equal(e.changes, 0);
|
||||
assert.deepEqual(e.value, initial);
|
||||
});
|
||||
|
||||
void test('Ask sends the exact complete three-term, eight-term and nested question payload', () => {
|
||||
const facts: TruthFact[] = [
|
||||
{ kind: 'and', terms: [hand('Shield'), hand('Snooper'), spice(6)] },
|
||||
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => spice(index)) },
|
||||
{ kind: 'and', terms: [hand(), { kind: 'or', terms: [spice(5), hand('Snooper'), { kind: 'traitor', leader: CHEAP_HERO_TRAITOR }] }] },
|
||||
];
|
||||
for (const fact of facts) {
|
||||
const game = projected(), question = factQuestion(fact), before = structuredClone(question);
|
||||
const ask = askTree(game, question);
|
||||
assert.equal(ask.button.props.disabled, false);
|
||||
click(ask.button);
|
||||
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question }]);
|
||||
assert.deepEqual(Object.keys(ask.actions[0]).sort(), ['question', 'type']);
|
||||
assert.deepEqual(question, before);
|
||||
assert.match(ask.html(), /data-testid="truth-fact-preview"/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the stateful parent renders the default Shield expression with one public Ask action', () => {
|
||||
for (const busy of [false, true]) {
|
||||
const game = projected();
|
||||
const html = renderToStaticMarkup(createElement(Truthtrance, { game, busy, act() {} }));
|
||||
assert.match(html, /Verified current facts/);
|
||||
assert.match(html, /data-testid="truth-fact-expression"/);
|
||||
assert.match(html, /Is it true that you hold Shield\?/);
|
||||
assert.equal((html.match(/Current fact type/g) ?? []).length, 1);
|
||||
assert.equal((html.match(/Ask publicly/g) ?? []).length, 1);
|
||||
const button = html.match(/<button\b(?=[^>]*data-testid="truth-fact-ask")[^>]*>[\s\S]*?<\/button>/)?.[0];
|
||||
assert.ok(button);
|
||||
assert.equal(/\sdisabled=/.test(button), busy);
|
||||
assert.doesNotMatch(html, /Both · AND|Either · OR|Combine conditions/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a malformed third leaf blocks the whole AND or OR despite valid earlier clauses', () => {
|
||||
const invalid: TruthFact[] = [
|
||||
spice(-1), { kind: 'handCount', name: 'Shield', compare: 'eq', value: 0.5 },
|
||||
{ kind: 'handInventory', category: 'all', compare: 'gte', value: NaN },
|
||||
{ kind: 'prediction', field: 'turn', compare: 'eq', value: 0 },
|
||||
{ kind: 'stormDial', compare: 'gte', value: 21 }, { kind: 'stormForecast', compare: 'lte', value: 0 },
|
||||
{ kind: 'noFieldValue', value: 7 } as unknown as TruthFact,
|
||||
{ kind: 'faceDancer', leader: 'not-a-leader' },
|
||||
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: -1 },
|
||||
];
|
||||
for (const kind of ['and', 'or'] as const) for (const fact of invalid) {
|
||||
const ask = askTree(projected(), factQuestion({ kind, terms: [hand(), spice(0), fact] }));
|
||||
assert.equal(ask.button.props.disabled, true, `${kind}: ${fact.kind}`);
|
||||
click(ask.button);
|
||||
assert.deepEqual(ask.actions, []);
|
||||
assert.match(ask.html(), /Resolve the marked current-fact fields before asking/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('over-width, over-node, over-depth and singleton expressions cannot reach Ask and remain fully rendered', () => {
|
||||
const invalid: TruthFact[] = [
|
||||
{ kind: 'and', terms: [hand()] },
|
||||
{ kind: 'or', terms: Array.from({ length: 9 }, (_, index) => spice(index)) },
|
||||
{ kind: 'and', terms: [
|
||||
{ kind: 'or', terms: Array.from({ length: 8 }, (_, index) => spice(index)) },
|
||||
{ kind: 'and', terms: Array.from({ length: 6 }, (_, index) => spice(index + 8)) },
|
||||
] },
|
||||
groupsDeep(5),
|
||||
];
|
||||
for (const fact of invalid) {
|
||||
const ask = askTree(projected(), factQuestion(fact));
|
||||
assert.equal(ask.button.props.disabled, true);
|
||||
click(ask.button);
|
||||
assert.deepEqual(ask.actions, []);
|
||||
const html = ask.html();
|
||||
assert.match(html, /role="alert"/);
|
||||
const leafCount = (value: TruthFact): number => value.kind === 'and' || value.kind === 'or'
|
||||
? value.terms.reduce((count, term) => count + leafCount(term), 0) : 1;
|
||||
assert.equal((html.match(/Current fact type/g) ?? []).length, leafCount(fact));
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy, wrong owner, absent windows and invalid targets guard Ask and the editor change callback', () => {
|
||||
const question = factQuestion(hand());
|
||||
const cases: { game: GameView; question?: typeof question; busy?: boolean }[] = [{ game: projected(), busy: true }];
|
||||
for (const stage of ['priority', 'answer', 'unknown'] as const) {
|
||||
const game = projected(); game.truthtrance!.stage = stage; cases.push({ game });
|
||||
}
|
||||
for (const owner of ['t', 'not-seated']) {
|
||||
const game = projected(); game.truthtrance!.queue[0].player = owner; cases.push({ game });
|
||||
}
|
||||
const unseated = projected(); unseated.me = 'not-seated'; unseated.truthtrance!.queue[0].player = 'not-seated'; cases.push({ game: unseated });
|
||||
const empty = projected(); empty.truthtrance!.queue = []; cases.push({ game: empty });
|
||||
const absent = projected(); absent.truthtrance = null; cases.push({ game: absent });
|
||||
for (const target of ['a', 'not-seated']) cases.push({ game: projected(), question: factQuestion({
|
||||
kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 1,
|
||||
}, target) });
|
||||
for (const entry of cases) {
|
||||
const ask = askTree(entry.game, entry.question ?? question, entry.busy);
|
||||
assert.equal(ask.button.props.disabled, true);
|
||||
click(ask.button);
|
||||
const fields = elements(ask.tree).find(element => element.type === TruthFactExpressionFields);
|
||||
if (fields) {
|
||||
assert.equal(fields.props.disabled, true);
|
||||
(fields.props.onChange as (next: TruthFact) => void)(spice(9));
|
||||
}
|
||||
assert.deepEqual(ask.actions, []);
|
||||
assert.deepEqual(ask.changes, []);
|
||||
assert.doesNotThrow(ask.html);
|
||||
}
|
||||
const game = projected(), stale = askTree(game, question);
|
||||
game.truthtrance!.stage = 'answer';
|
||||
click(stale.button);
|
||||
assert.deepEqual(stale.actions, [], 'The existing callback rechecks the live ask stage');
|
||||
game.truthtrance!.stage = 'ask'; game.truthtrance!.queue[0].player = 't';
|
||||
click(stale.button);
|
||||
assert.deepEqual(stale.actions, [], 'The existing callback rechecks the live queue owner');
|
||||
game.truthtrance!.queue[0].player = 'a';
|
||||
game.players = game.players.filter(player => player.id !== question.target);
|
||||
click(stale.button);
|
||||
assert.deepEqual(stale.actions, [], 'The existing callback rechecks the live target seat');
|
||||
});
|
||||
|
||||
void test('meaning, current custody, app limits and public leader labels persist with the editor and preview', () => {
|
||||
const game = projected(), leader = game.allLeaders[0];
|
||||
const ask = askTree(game, factQuestion({ kind: 'and', terms: [
|
||||
{ kind: 'traitor', leader: leader.id },
|
||||
{ kind: 'or', terms: [hand(), { kind: 'traitor', leader: CHEAP_HERO_TRAITOR }] },
|
||||
] }));
|
||||
for (let render = 0; render < 3; render++) {
|
||||
const html = ask.html();
|
||||
assert.match(html, /AND requires all conditions; OR requires at least one/);
|
||||
assert.match(html, /one aggregate answer/);
|
||||
assert.match(html, /No makes the whole expression false, not necessarily every condition/);
|
||||
assert.match(html, /Current facts are evaluated when the player answers/);
|
||||
assert.match(html, /no promise to keep cards, spice or forces afterward/);
|
||||
assert.match(html, /App limits: 8 terms per group, 16 total clauses/);
|
||||
assert.match(html, /not additional printed rules/);
|
||||
assert.ok(html.includes(`${leader.name} is your traitor`));
|
||||
assert.match(html, /Cheap Hero \/ Heroine is your traitor/);
|
||||
assert.doesNotMatch(html, /Confirm question|Acknowledge/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all current-fact controls and actual Ask callbacks avoid opponent private holdings or expected answers', () => {
|
||||
const game = projected();
|
||||
for (const player of game.players) for (const field of [
|
||||
'hand', 'traitors', 'faceDancers', 'spice', 'leaders', 'forces', 'handLimit', 'prediction', 'noField',
|
||||
]) Object.defineProperty(player, field, { get() { throw Error(`Current fact controls read private ${field}`); } });
|
||||
for (const field of ['truthAnswer', 'truthKnowledge', 'truthHistory'])
|
||||
Object.defineProperty(game, field, { get() { throw Error(`Current fact controls read ${field}`); } });
|
||||
const facts: TruthFact[] = [
|
||||
hand(), spice(5), { kind: 'handCount', name: 'Snooper', compare: 'eq', value: 1 },
|
||||
{ kind: 'handInventory', category: 'weapon', compare: 'gte', value: 2 },
|
||||
{ kind: 'traitor', leader: game.allLeaders[0].id }, { kind: 'faceDancer', leader: CHEAP_HERO_TRAITOR },
|
||||
{ kind: 'prediction', field: 'faction', faction: 'atreides' },
|
||||
{ kind: 'prediction', field: 'turn', compare: 'eq', value: 2 },
|
||||
{ kind: 'stormDial', compare: 'lte', value: 20 }, { kind: 'stormForecast', compare: 'gte', value: 1 },
|
||||
{ kind: 'noFieldValue', value: 3 },
|
||||
{ kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'eq', value: 4 },
|
||||
];
|
||||
for (const fact of facts) {
|
||||
const question = factQuestion(fact), ask = askTree(game, question);
|
||||
assert.equal(ask.button.props.disabled, false, fact.kind);
|
||||
assert.doesNotThrow(ask.html);
|
||||
click(ask.button);
|
||||
assert.deepEqual(ask.actions, [{ type: 'truthAsk', question }]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import type { TruthFact } from '../game/truthtrance';
|
||||
import {
|
||||
createTruthFactExpressionFixture as fixture,
|
||||
actTruthFactExpression as act,
|
||||
truthFactExpressionClean as clean,
|
||||
truthFactExpressionPlayer as player,
|
||||
truthFactExpressionInventory as inventory,
|
||||
truthFactExpressionFacts,
|
||||
truthFactExpressionScenario as scenario,
|
||||
truthFactExpressionNestedScenario as nestedScenario,
|
||||
declareTruthFactExpression as declare,
|
||||
passTruthFactExpressionPriority as passPriority,
|
||||
askTruthFactExpression as ask,
|
||||
answerTruthFactExpression as answer,
|
||||
finishTruthFactExpression as finish,
|
||||
type TruthFactExpressionFixture,
|
||||
} from './fixture-truth-fact-expression';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action, message?: RegExp) {
|
||||
const before = structuredClone(game);
|
||||
const run = () => applyAction(game, actor, action);
|
||||
if (message) assert.throws(run, message);
|
||||
else assert.throws(run);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
/** Only the actual interrupted Storm controls, not a recovery snapshot. */
|
||||
function stormControls(game: Game) {
|
||||
return {
|
||||
status: game.status, turn: game.turn, phase: game.phase, active: game.active,
|
||||
order: game.order, ready: game.ready, storm: game.storm,
|
||||
stormPending: game.stormPending, stormDials: game.stormDials, stormDialers: game.stormDialers,
|
||||
stormMovementSource: game.stormMovementSource,
|
||||
response: game.response ?? null, decision: game.decision ?? null,
|
||||
phaseOpening: game.phaseOpening ?? null, pendingKarama: game.pendingKarama ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function aggregateOnly(game: Game, f: TruthFactExpressionFixture, expected: 'yes' | 'no') {
|
||||
assert.equal(game.truthtrance?.stage, 'answer');
|
||||
assert.equal(viewGame(game, f.target).truthAnswer, expected);
|
||||
for (const other of game.players.filter(candidate => candidate.id !== f.target))
|
||||
assert.equal(viewGame(game, other.id).truthAnswer, null);
|
||||
}
|
||||
|
||||
function completed(game: Game, f: TruthFactExpressionFixture, fact: TruthFact, expected: 'yes' | 'no') {
|
||||
assert.ok(clean(game)); assert.equal(game.truthtrance, null);
|
||||
assert.deepEqual(stormControls(game), stormControls(f.source));
|
||||
assert.deepEqual(player(game, f.target).hand, player(f.source, f.target).hand);
|
||||
assert.equal(player(game, f.target).spice, 10); assert.equal(player(game, f.target).reserves, 20);
|
||||
assert.equal(game.discard.length, f.source.discard.length + 1);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(game, f.asker).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
assert.equal(game.truthHistory!.length, (f.source.truthHistory?.length ?? 0) + 1);
|
||||
assert.deepEqual(game.truthHistory!.at(-1), {
|
||||
turn: 1, phase: 0, asker: f.asker,
|
||||
question: { kind: 'fact', target: f.target, fact }, answer: expected,
|
||||
});
|
||||
}
|
||||
|
||||
void test('compound facts begin with original Basic cards and native Storm dials plus a real nonempty Ready list', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(candidate => candidate.hand.length === 0));
|
||||
assert.deepEqual(f.game.players.map(candidate => candidate.faction), ['atreides', 'emperor', 'harkonnen', 'guild']);
|
||||
assert.equal(f.sourceActionCount, f.base.sourceActionCount + 3);
|
||||
assert.equal(f.actions.length, f.sourceActionCount);
|
||||
assert.deepEqual(f.stormSteps, [
|
||||
...f.game.stormDialers.map(actor => ({ actor, action: { type: 'stormDial', amount: 0 } })),
|
||||
{ actor: f.target, action: { type: 'ready' } },
|
||||
]);
|
||||
assert.equal(f.beforeStormControls.stormPending, null); assert.deepEqual(f.beforeStormControls.ready, []);
|
||||
assert.deepEqual(f.game.ready, [f.target]); assert.equal(f.game.phase, 0); assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.response ?? null, null); assert.equal(f.game.truthtrance ?? null, null);
|
||||
assert.equal(f.game.truthHistory?.length ?? 0, 0);
|
||||
assert.deepEqual(player(f.game, f.asker).hand.map(card => card.id), [f.card]);
|
||||
assert.deepEqual(player(f.game, f.target).hand.map(card => card.name), ['Baliset']);
|
||||
assert.ok(player(f.game, f.counterHolder).hand.some(card => card.id === f.karama));
|
||||
assert.deepEqual(inventory(f.game), f.originalCards); assert.equal(f.originalCards.length, 33);
|
||||
assert.deepEqual(f.fact, { kind: 'and', terms: [
|
||||
{ kind: 'spice', compare: 'gte', value: 10 },
|
||||
{ kind: 'handCount', name: 'Baliset', compare: 'gte', value: 1 },
|
||||
{ kind: 'handCount', name: 'Shield', compare: 'eq', value: 0 },
|
||||
] });
|
||||
});
|
||||
|
||||
void test('original three through eight clause AND and OR questions enforce both truthful aggregate answers', () => {
|
||||
const f = fixture();
|
||||
for (const kind of ['and', 'or'] as const)
|
||||
for (const count of [3, 4, 5, 6, 7, 8])
|
||||
for (const expected of ['yes', 'no'] as const) {
|
||||
const selected = scenario(kind, count, expected), asked = ask(f, selected.fact);
|
||||
assert.equal(asked.actions.length, 5, 'One declaration, three other-seat priority passes and one question');
|
||||
assert.deepEqual(stormControls(asked.game), stormControls(f.source));
|
||||
aggregateOnly(asked.game, f, expected);
|
||||
reject(asked.game, f.target, { type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes' }, /truthfully/);
|
||||
reject(asked.game, f.target, { type: 'truthAnswer', answer: 'unknown' }, /truthfully/);
|
||||
const done = answer(asked.game, expected);
|
||||
completed(done, f, selected.fact, expected);
|
||||
}
|
||||
});
|
||||
|
||||
void test('mixed nested AND inside OR and OR inside AND retain their public question and target-only aggregate', () => {
|
||||
const f = fixture();
|
||||
for (const kind of ['and', 'or'] as const)
|
||||
for (const expected of ['yes', 'no'] as const) {
|
||||
const selected = nestedScenario(kind, expected), asked = ask(f, selected.fact);
|
||||
assert.deepEqual(asked.game.truthtrance!.question, { kind: 'fact', target: f.target, fact: selected.fact });
|
||||
aggregateOnly(asked.game, f, expected);
|
||||
const done = answer(asked.game, expected);
|
||||
completed(done, f, selected.fact, expected);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the original holder waits for real priority and one answer discards once before the same Storm Ready control resumes', () => {
|
||||
const f = fixture(), declared = declare(f);
|
||||
assert.deepEqual(declared.game.truthtrance!.queue, [{ player: f.asker, card: f.card }]);
|
||||
assert.deepEqual(declared.game.truthtrance!.passed, [f.asker]);
|
||||
assert.deepEqual(stormControls(declared.game), stormControls(f.source));
|
||||
reject(declared.game, f.asker, f.askStep.action, /priority/);
|
||||
let game = passPriority(declared.game, declared.actions);
|
||||
assert.equal(declared.actions.length, 4); assert.equal(game.truthtrance!.stage, 'ask');
|
||||
reject(game, f.target, f.askStep.action, /holder/);
|
||||
reject(game, f.asker, { type: 'truthPass' });
|
||||
game = act(game, f.asker, f.askStep.action, declared.actions);
|
||||
aggregateOnly(game, f, 'yes');
|
||||
reject(game, f.asker, f.answerStep.action, /questioned player/);
|
||||
reject(game, f.guild, f.answerStep.action, /questioned player/);
|
||||
reject(game, f.target, { type: 'truthAnswer', answer: 'no' }, /truthfully/);
|
||||
reject(game, f.target, { type: 'truthAnswer', answer: 'unknown' }, /truthfully/);
|
||||
reject(game, f.asker, f.continuationStep.action);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 0);
|
||||
game = answer(game, 'yes', declared.actions);
|
||||
assert.equal(declared.actions.length, 6);
|
||||
completed(game, f, f.fact, 'yes');
|
||||
reject(game, f.target, f.answerStep.action);
|
||||
reject(game, f.asker, f.askStep.action);
|
||||
const continued = act(game, f.continuationStep.actor, f.continuationStep.action, declared.actions);
|
||||
assert.equal(declared.actions.length, 7);
|
||||
assert.equal(continued.phase, 0); assert.equal(continued.turn, 1);
|
||||
assert.deepEqual(continued.ready, [f.target, f.asker]);
|
||||
assert.equal(continued.response ?? null, null); assert.ok(clean(continued));
|
||||
assert.equal(continued.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(continued.truthHistory!.length, 1);
|
||||
assert.deepEqual(inventory(continued), f.originalCards);
|
||||
});
|
||||
|
||||
void test('complete server validation rejects a bad third clause even when the first clause determines the aggregate', () => {
|
||||
const f = fixture(), declared = declare(f), game = passPriority(declared.game);
|
||||
const { trueFacts, falseFacts } = truthFactExpressionFacts();
|
||||
const invalid = [
|
||||
{ kind: 'or', terms: [trueFacts[0], falseFacts[0], { kind: 'spice', compare: 'gte', value: -1 }] },
|
||||
{ kind: 'and', terms: [falseFacts[0], trueFacts[0],
|
||||
{ kind: 'handCount', name: 'Not an original card', compare: 'eq', value: 0 }] },
|
||||
{ kind: 'or', terms: [trueFacts[0], falseFacts[0],
|
||||
{ kind: 'and', terms: [trueFacts[0], { kind: 'handInventory', category: 'unknown', compare: 'eq', value: 0 }] }] },
|
||||
];
|
||||
for (const fact of invalid)
|
||||
reject(game, f.asker, { type: 'truthAsk', question: { kind: 'fact', target: f.target, fact } });
|
||||
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
|
||||
assert.deepEqual(stormControls(game), stormControls(f.source));
|
||||
assert.deepEqual(game.discard, f.source.discard); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('original current facts accept the existing sixteen-node and four-depth boundaries', () => {
|
||||
const f = fixture(), { trueFacts } = truthFactExpressionFacts();
|
||||
const sixteen: TruthFact = { kind: 'and', terms: [
|
||||
{ kind: 'or', terms: trueFacts.slice(0, 6) },
|
||||
{ kind: 'and', terms: trueFacts.slice(0, 7) },
|
||||
] };
|
||||
let depthFour: TruthFact = trueFacts[0];
|
||||
for (let depth = 0; depth < 4; depth++) depthFour = { kind: 'and', terms: [trueFacts[1], depthFour] };
|
||||
for (const [id, fact] of [['sixteen-nodes', sixteen], ['depth-four', depthFour]] as const) {
|
||||
const done = finish(f, { id, fact, answer: 'yes' });
|
||||
assert.equal(done.actions.length, 6);
|
||||
completed(done.game, f, fact, 'yes');
|
||||
}
|
||||
});
|
||||
|
||||
void test('a ninth group child, seventeenth node and fifth depth reject before the original card is consumed', () => {
|
||||
const f = fixture(), game = passPriority(declare(f).game);
|
||||
const { trueFacts } = truthFactExpressionFacts();
|
||||
let depthFive: TruthFact = trueFacts[0];
|
||||
for (let depth = 0; depth < 5; depth++) depthFive = { kind: 'and', terms: [trueFacts[1], depthFive] };
|
||||
const invalid: [TruthFact, RegExp][] = [
|
||||
[{ kind: 'and', terms: [] }, /two to eight/],
|
||||
[{ kind: 'or', terms: [trueFacts[0]] }, /two to eight/],
|
||||
[{ kind: 'or', terms: [...trueFacts, trueFacts[0]] }, /two to eight/],
|
||||
[{ kind: 'and', terms: [
|
||||
{ kind: 'or', terms: trueFacts.slice(0, 7) },
|
||||
{ kind: 'and', terms: trueFacts.slice(0, 7) },
|
||||
] }, /16 clauses/],
|
||||
[depthFive, /four levels/],
|
||||
];
|
||||
for (const [fact, message] of invalid)
|
||||
reject(game, f.asker, { type: 'truthAsk', question: { kind: 'fact', target: f.target, fact } }, message);
|
||||
assert.equal(game.truthtrance!.stage, 'ask'); assert.equal(game.truthtrance!.question, null);
|
||||
assert.deepEqual(game.discard, f.source.discard);
|
||||
assert.ok(player(game, f.asker).hand.some(card => card.id === f.card));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles pass real priority and answer new compound questions from the private aggregate', () => {
|
||||
const f = fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const declared = declare(f), priorityView = viewGame(declared.game, f.target);
|
||||
priorityView.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const priorityAction = botActions(priorityView);
|
||||
assert.deepEqual(priorityAction, [{ type: 'truthPass' }]);
|
||||
const passed = act(declared.game, f.target, priorityAction[0]);
|
||||
assert.equal(passed.truthtrance!.passed.includes(f.target), true);
|
||||
assert.equal(passPriority(passed).truthtrance!.stage, 'ask');
|
||||
for (const selected of [scenario('and', 8, 'yes'), scenario('or', 8, 'no'),
|
||||
nestedScenario('and', 'no'), nestedScenario('or', 'yes')]) {
|
||||
const asked = ask(f, selected.fact), view = viewGame(asked.game, f.target);
|
||||
view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
|
||||
const before = structuredClone(view), actions = botActions(view);
|
||||
assert.deepEqual(view, before);
|
||||
assert.deepEqual(actions, [{ type: 'truthAnswer', answer: selected.answer }]);
|
||||
assert.deepEqual(botActions(viewGame(asked.game, f.asker)), []);
|
||||
assert.deepEqual(botActions(viewGame(asked.game, f.guild)), []);
|
||||
aggregateOnly(asked.game, f, selected.answer);
|
||||
const done = act(asked.game, f.target, actions[0]);
|
||||
completed(done, f, selected.fact, selected.answer);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { TruthFact } from '../game/truthtrance';
|
||||
import {
|
||||
truthFactExpressionError as error, truthFactExpressionSize as size,
|
||||
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
|
||||
TRUTH_FACT_SIZE_ERROR, TRUTH_FACT_GROUP_ERROR,
|
||||
} from '../game/truth-fact-expression';
|
||||
|
||||
const leaf = (): TruthFact => ({ kind: 'hand', name: 'Shield' });
|
||||
const group = (kind: 'and' | 'or', count: number): TruthFact =>
|
||||
({ kind, terms: Array.from({ length: count }, leaf) });
|
||||
|
||||
void test('fact structure counts group containers and retains every clause without mutation', () => {
|
||||
const fact: TruthFact = { kind: 'or', terms: [group('and', 3), group('or', 8)] };
|
||||
const before = structuredClone(fact);
|
||||
assert.deepEqual(size(fact), { nodes: 14, depth: 2 });
|
||||
assert.equal(error(fact), null);
|
||||
assert.deepEqual(fact, before);
|
||||
assert.deepEqual(size(leaf()), { nodes: 1, depth: 0 });
|
||||
assert.equal(error(leaf()), null);
|
||||
});
|
||||
|
||||
void test('standalone AND and OR retain the existing two-through-eight term envelope', () => {
|
||||
for (const kind of ['and', 'or'] as const) {
|
||||
for (const count of [2, 3, 8]) assert.equal(error(group(kind, count)), null);
|
||||
for (const count of [0, 1, 9]) assert.equal(error(group(kind, count)), TRUTH_FACT_GROUP_ERROR);
|
||||
}
|
||||
assert.equal(TRUTH_FACT_MAX_TERMS, 8);
|
||||
});
|
||||
|
||||
void test('nested fact editing shares the parser total-node and depth boundaries', () => {
|
||||
const maximum: TruthFact = { kind: 'and', terms: [group('and', 8), group('or', 5)] };
|
||||
assert.deepEqual(size(maximum), { nodes: TRUTH_FACT_MAX_NODES, depth: 2 });
|
||||
assert.equal(error(maximum), null);
|
||||
assert.equal(error({ kind: 'and', terms: [group('and', 8), group('or', 6)] }), TRUTH_FACT_SIZE_ERROR);
|
||||
let nested = leaf();
|
||||
for (let depth = 1; depth <= TRUTH_FACT_MAX_DEPTH + 1; depth++) {
|
||||
nested = { kind: depth % 2 ? 'and' : 'or', terms: [nested, leaf()] };
|
||||
assert.equal(size(nested).depth, depth);
|
||||
assert.equal(error(nested), depth <= TRUTH_FACT_MAX_DEPTH ? null : TRUTH_FACT_SIZE_ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
void test('structural validation does not replace entitled leaf eligibility checks', () => {
|
||||
const malformedGroup: TruthFact = { kind: 'or', terms: [leaf(), group('and', 1)] };
|
||||
assert.equal(error(malformedGroup), TRUTH_FACT_GROUP_ERROR);
|
||||
const invalidLeaf: TruthFact = { kind: 'and', terms: [leaf(), leaf(), { kind: 'spice', compare: 'gte', value: -1 }] };
|
||||
assert.equal(error(invalidLeaf), null, 'The common leaf validator owns numeric and target-specific eligibility');
|
||||
});
|
||||
Reference in new issue
Block a user