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(
|
||||
|
||||
Reference in new issue
Block a user