Expose full Truthtrance battle-plan and shipment editors
This commit is contained in:
1 parent
8e2f2c6a95
commit
4ee51917bc
21 files changed
+1958
-92
No files matched your search
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import type { PlanClaim } from '@/game/battle-promises';
|
||||
import {
|
||||
BATTLE_CLAIM_MAX_DEPTH,
|
||||
BATTLE_CLAIM_MAX_NODES,
|
||||
BATTLE_CLAIM_MAX_TERMS,
|
||||
battleClaimExpressionError,
|
||||
battleClaimExpressionSize,
|
||||
} from '@/game/battle-claim-expression';
|
||||
import { BattleClaimFields } from './battle-promises';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const newClaim = (): PlanClaim => ({ kind: 'dial', compare: 'eq', value: 0 });
|
||||
|
||||
/** Controlled structure editor. Invalid leaf input stays available for repair. */
|
||||
export function BattleClaimExpressionFields({ value, game, disabled, onChange }: {
|
||||
value: PlanClaim;
|
||||
game: GameView;
|
||||
disabled: boolean;
|
||||
onChange: (claim: PlanClaim) => void;
|
||||
}) {
|
||||
const size = battleClaimExpressionSize(value);
|
||||
const commit = (next: PlanClaim) => {
|
||||
if (!disabled && !battleClaimExpressionError(next)) onChange(next);
|
||||
};
|
||||
const render = (
|
||||
node: PlanClaim,
|
||||
path: string,
|
||||
replace: (next: PlanClaim) => PlanClaim,
|
||||
remove?: () => PlanClaim,
|
||||
): ReactElement => {
|
||||
const group = node.kind === 'and' || node.kind === 'or';
|
||||
const allowed = (next: PlanClaim) => !disabled && !battleClaimExpressionError(replace(next));
|
||||
const change = (next: PlanClaim) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): PlanClaim => ({ kind, terms: [node, newClaim()] });
|
||||
return <fieldset key={path} data-battle-claim-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{group ? `Group ${path} · ${node.kind.toUpperCase()}` : `Plan condition ${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-battle-claim-control={`add-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newClaim()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newClaim()] })}>
|
||||
Add plan condition
|
||||
</Button>
|
||||
<Button type="button" data-battle-claim-control={`nested-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, { kind: 'and', terms: [newClaim(), newClaim()] }] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, { kind: 'and', terms: [newClaim(), newClaim()] }] })}>
|
||||
Add nested group
|
||||
</Button>
|
||||
</div>
|
||||
</> : <BattleClaimFields value={node} game={game} onChange={change} />}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" data-battle-claim-control={`and-${path}`} disabled={!allowed(wrap('and'))}
|
||||
onClick={() => change(wrap('and'))}>Group with AND</Button>
|
||||
<Button type="button" data-battle-claim-control={`or-${path}`} disabled={!allowed(wrap('or'))}
|
||||
onClick={() => change(wrap('or'))}>Group with OR</Button>
|
||||
{remove && <Button type="button" data-battle-claim-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {group ? 'group' : 'condition'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="truth-battle-plan-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>Battle-plan expression</legend>
|
||||
<p className="fine">
|
||||
AND requires all conditions; OR requires at least one. The player answers once for the whole
|
||||
expression. No makes the whole expression false, not necessarily every condition.
|
||||
</p>
|
||||
<p className="fine">
|
||||
A definite answer commits the player to a legal battle plan that keeps the whole answer.
|
||||
Available answers and the sealed plan are checked by the game.
|
||||
</p>
|
||||
<p className="fine">
|
||||
App limits: {BATTLE_CLAIM_MAX_TERMS} terms per group, {BATTLE_CLAIM_MAX_NODES} total clauses
|
||||
including groups, and {BATTLE_CLAIM_MAX_DEPTH} levels of grouping. These are input limits,
|
||||
not additional printed rules.
|
||||
</p>
|
||||
<p className="fine">{size.nodes} / {BATTLE_CLAIM_MAX_NODES} total clauses · {size.depth} / {BATTLE_CLAIM_MAX_DEPTH} levels of grouping</p>
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function BattleClaimFields({
|
||||
min={0}
|
||||
max={40}
|
||||
step={c.kind === 'dial' ? 0.5 : 1}
|
||||
value={c.value}
|
||||
value={Number.isNaN(c.value) ? '' : c.value}
|
||||
onChange={(e) =>
|
||||
onChange({ ...c, value: Number(e.target.value) })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
SHIPMENT_EXPRESSION_MAX_DEPTH,
|
||||
SHIPMENT_EXPRESSION_MAX_LEAVES,
|
||||
type ShipmentExpression,
|
||||
} from '@/game/shipment-promises';
|
||||
import { shipmentExpressionInputError, shipmentExpressionInputSize } from '@/game/shipment-expression-input';
|
||||
import { ShipmentClauseFields } from './shipment-claim-fields';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const newLeaf = (): ShipmentExpression => ({ territory: 'carthag', minimum: 1 });
|
||||
|
||||
/** Hook-free controlled tree. Stateful leaf descendants are rendered by React. */
|
||||
export function ShipmentExpressionFields({ value, id = 'truth-shipment', disabled, onChange }: {
|
||||
value: ShipmentExpression;
|
||||
id?: string;
|
||||
disabled: boolean;
|
||||
onChange: (expression: ShipmentExpression) => void;
|
||||
}) {
|
||||
const size = shipmentExpressionInputSize(value);
|
||||
const commit = (next: ShipmentExpression) => {
|
||||
if (!disabled && !shipmentExpressionInputError(next)) onChange(next);
|
||||
};
|
||||
const render = (
|
||||
node: ShipmentExpression,
|
||||
path: string,
|
||||
replace: (next: ShipmentExpression) => ShipmentExpression,
|
||||
remove?: () => ShipmentExpression,
|
||||
): ReactElement => {
|
||||
const group = 'op' in node;
|
||||
const allowed = (next: ShipmentExpression) => !disabled && !shipmentExpressionInputError(replace(next));
|
||||
const change = (next: ShipmentExpression) => commit(replace(next));
|
||||
const wrap = (op: 'and' | 'or'): ShipmentExpression => ({ op, terms: [node, newLeaf()] });
|
||||
return <fieldset key={path} data-shipment-expression-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{group ? `Shipment group ${path} · ${node.op.toUpperCase()}` : `Shipment condition ${path}`}</legend>
|
||||
{group ? <>
|
||||
<label htmlFor={`${id}-${path}-meaning`}>Shipment group {path} meaning</label>
|
||||
<select id={`${id}-${path}-meaning`} value={node.op} disabled={disabled} onChange={event => {
|
||||
const op = event.target.value;
|
||||
if (op === 'and' || op === 'or') change({ ...node, op });
|
||||
}}>
|
||||
<option value="and">All conditions · AND</option>
|
||||
<option value="or">At least one condition · OR</option>
|
||||
</select>
|
||||
{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-shipment-expression-control={`add-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newLeaf()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newLeaf()] })}>Add shipment condition</Button>
|
||||
{(['and', 'or'] as const).map(op => {
|
||||
const next: ShipmentExpression = { ...node, terms: [...node.terms, { op, terms: [newLeaf(), newLeaf()] }] };
|
||||
return <Button key={op} type="button" data-shipment-expression-control={`nested-${op}-${path}`}
|
||||
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {op.toUpperCase()} group</Button>;
|
||||
})}
|
||||
</div>
|
||||
</> : 'constant' in node ? <p className="notice" role="alert">
|
||||
Frozen current facts are unavailable in a pure shipment question. Remove this condition.
|
||||
</p> : <ShipmentClauseFields id={`${id}-${path}`} value={node} onChange={change} />}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" data-shipment-expression-control={`and-${path}`} disabled={!allowed(wrap('and'))}
|
||||
onClick={() => change(wrap('and'))}>Group with AND</Button>
|
||||
<Button type="button" data-shipment-expression-control={`or-${path}`} disabled={!allowed(wrap('or'))}
|
||||
onClick={() => change(wrap('or'))}>Group with OR</Button>
|
||||
{remove && <Button type="button" data-shipment-expression-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {group ? 'group' : 'condition'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="shipment-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>Shipment expression</legend>
|
||||
<p className="fine">
|
||||
Every condition describes the same eventual shipment from reserves this turn.
|
||||
AND requires all conditions; OR requires at least one. No requires the whole expression to be false,
|
||||
not every individual condition. Skipping shipment makes every condition false.
|
||||
</p>
|
||||
<p className="fine">
|
||||
App input limits: {SHIPMENT_EXPRESSION_MAX_LEAVES} shipment conditions and {SHIPMENT_EXPRESSION_MAX_DEPTH} levels of grouping.
|
||||
Groups do not count as conditions. These are input limits, not additional printed rules.
|
||||
</p>
|
||||
<p className="fine">{size.leaves} / {SHIPMENT_EXPRESSION_MAX_LEAVES} shipment conditions · {size.depth} / {SHIPMENT_EXPRESSION_MAX_DEPTH} levels of grouping</p>
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { parsePlanClaim, type PlanClaim } from '@/game/battle-promises';
|
||||
import { battleClaimExpressionError } from '@/game/battle-claim-expression';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { truthQuestionText, type TruthQuestion } from '@/game/truthtrance';
|
||||
import { BattleClaimExpressionFields } from './battle-claim-expression-fields';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
/** Authoring uses public identities; the server determines feasible promises. */
|
||||
export function TruthBattlePlanQuestion({ game, question, busy, act, onChange }: {
|
||||
game: GameView;
|
||||
question: Extract<TruthQuestion, { kind: 'battlePlan' }>;
|
||||
busy: boolean;
|
||||
act: (action: Action) => void;
|
||||
onChange: (claim: PlanClaim) => 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) &&
|
||||
!!game.battle && [game.battle.attacker, game.battle.defender].includes(question.target) &&
|
||||
question.territory === game.battle.territory;
|
||||
const inputError = () => {
|
||||
const structureError = battleClaimExpressionError(question.claim);
|
||||
if (structureError) return structureError;
|
||||
try {
|
||||
parsePlanClaim(question.claim, game.allLeaders.map(leader => leader.id));
|
||||
return null;
|
||||
} catch {
|
||||
return 'Resolve the battle-plan fields before asking.';
|
||||
}
|
||||
};
|
||||
const blocked = () => busy || !ownsQuestion() || !hasTarget() || !!inputError();
|
||||
const error = inputError();
|
||||
return <>
|
||||
{hasTarget() ? <BattleClaimExpressionFields value={question.claim} game={game}
|
||||
disabled={busy || !ownsQuestion() || !hasTarget()} onChange={next => {
|
||||
if (!busy && ownsQuestion() && hasTarget() && !battleClaimExpressionError(next)) onChange(next);
|
||||
}} /> : <p className="notice">Choose another seated combatant in the current battle.</p>}
|
||||
<blockquote data-testid="truth-battle-plan-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-battle-plan-ask" disabled={blocked()}
|
||||
onClick={() => { if (!blocked()) act({ type: 'truthAsk', question }); }}>
|
||||
Ask publicly
|
||||
</Button>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import {
|
||||
parseShipmentExpression,
|
||||
ShipmentClaimError,
|
||||
shipmentExpressionOf,
|
||||
type ShipmentExpression,
|
||||
} from '@/game/shipment-promises';
|
||||
import { shipmentExpressionInputError } from '@/game/shipment-expression-input';
|
||||
import { truthQuestionText, type TruthQuestion } from '@/game/truthtrance';
|
||||
import { ShipmentExpressionFields } from './shipment-expression-fields';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
/** Public authoring only. Feasibility and answer choices stay authoritative. */
|
||||
export function TruthShipmentQuestion({ game, question, busy, act, onChange }: {
|
||||
game: GameView;
|
||||
question: Extract<TruthQuestion, { kind: 'shipment' }>;
|
||||
busy: boolean;
|
||||
act: (action: Action) => void;
|
||||
onChange: (expression: ShipmentExpression) => 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 supported = () => hasTarget() && game.truthShipmentTargets.includes(question.target);
|
||||
const inputError = () => {
|
||||
try {
|
||||
parseShipmentExpression(shipmentExpressionOf(question), { allowConstants: false });
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof ShipmentClaimError) return error.message;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const blocked = () => busy || !ownsQuestion() || !supported() || !!inputError();
|
||||
const error = inputError();
|
||||
return <>
|
||||
{hasTarget() ? <ShipmentExpressionFields value={shipmentExpressionOf(question)}
|
||||
disabled={busy || !ownsQuestion() || !supported()} onChange={next => {
|
||||
if (!busy && ownsQuestion() && supported() && !shipmentExpressionInputError(next)) onChange(next);
|
||||
}} /> : <p className="notice">Choose another seated player for this question.</p>}
|
||||
{!supported() && <p className="notice">Choose an eligible respondent with an unused shipment this turn and finish pending decisions.</p>}
|
||||
<blockquote data-testid="truth-shipment-preview">
|
||||
{error ?? truthQuestionText(question,
|
||||
id => 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-shipment-ask" disabled={blocked()}
|
||||
onClick={() => { if (!blocked()) act({ type: 'truthAsk', question }); }}>Ask publicly</Button>
|
||||
</>;
|
||||
}
|
||||
+19
-75
@@ -2,8 +2,9 @@
|
||||
import { MovementClaimFields } from './movement-claim-fields';
|
||||
import { TraitorDecisionAnswers } from './traitor-decision-promises';
|
||||
import { validMovementClaim, type MovementClaim } from '@/game/movement-promises';
|
||||
import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput } from './shipment-claim-fields';
|
||||
import { BattleClaimFields } from './battle-promises';
|
||||
import { TruthShipmentQuestion } from './truth-shipment-question';
|
||||
import { TruthBattlePlanQuestion } from './truth-battle-plan-question';
|
||||
import type { ShipmentExpression } from '@/game/shipment-promises';
|
||||
import {
|
||||
MixedShipmentFields,
|
||||
mixedShipmentInputError,
|
||||
@@ -55,11 +56,7 @@ export function Truthtrance({
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall'
|
||||
>('fact');
|
||||
const [movement, setMovement] = useState<MovementClaim>({ territory: 'carthag', minimum: 6 });
|
||||
const [shipmentJoin, setShipmentJoin] = useState<'single' | 'and' | 'or'>('single');
|
||||
const [shipmentClauses, setShipmentClauses] = useState<ShipmentClauseInput[]>([
|
||||
{ territory: 'carthag', minimum: 6 },
|
||||
{ territory: 'arrakeen', minimum: 4 },
|
||||
]);
|
||||
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
|
||||
const [mixed, setMixed] = useState<MixedShipmentExpression>({
|
||||
kind: 'and',
|
||||
terms: [
|
||||
@@ -68,15 +65,10 @@ export function Truthtrance({
|
||||
],
|
||||
});
|
||||
const invalidMixed = mixedShipmentInputError(mixed, g, target);
|
||||
const invalidShipment = shipmentClauses.slice(0, shipmentJoin === 'single' ? 1 : 2).some(invalidShipmentClause);
|
||||
const shipmentAvailable = g.truthShipmentTargets.includes(target);
|
||||
const [planClauses, setPlanClauses] = useState<PlanClaim[]>([
|
||||
{ kind: 'dial', compare: 'gte', value: 1 },
|
||||
{ kind: 'weapon', name: null },
|
||||
]);
|
||||
const [planClaim, setPlanClaim] = useState<PlanClaim>({ kind: 'dial', compare: 'gte', value: 1 });
|
||||
const [scope, setScope] = useState<'fact' | 'currentTurn'>('fact');
|
||||
const [text, setText] = useState('');
|
||||
const [join, setJoin] = useState<'single' | 'and' | 'or'>('single');
|
||||
const [fact, setFact] = useState<TruthFact>({ kind: 'hand', name: 'Shield' });
|
||||
const factId = useId();
|
||||
const leaderName = (id: string) =>
|
||||
@@ -97,10 +89,7 @@ export function Truthtrance({
|
||||
kind,
|
||||
target,
|
||||
territory: g.battle?.territory ?? '',
|
||||
claim:
|
||||
join === 'single'
|
||||
? planClauses[0]
|
||||
: { kind: join, terms: planClauses },
|
||||
claim: planClaim,
|
||||
}
|
||||
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
|
||||
: kind === 'movement' ? { kind, target, ...movement }
|
||||
@@ -108,7 +97,7 @@ export function Truthtrance({
|
||||
? {
|
||||
kind,
|
||||
target,
|
||||
...(shipmentJoin === 'single' ? shipmentClauses[0] : { claim: { op: shipmentJoin, terms: shipmentClauses } }),
|
||||
claim: shipment,
|
||||
}
|
||||
: kind === 'mixedShipment'
|
||||
? { kind, target, mixed }
|
||||
@@ -365,9 +354,9 @@ export function Truthtrance({
|
||||
{kind === 'mixedShipment' ? (
|
||||
<MixedShipmentFields id={`${factId}-mixed`} value={mixed}
|
||||
game={g} target={target} disabled={busy} onChange={setMixed} />
|
||||
) : (
|
||||
<ShipmentClaimFields id={`${factId}-shipment`} clauses={shipmentClauses}
|
||||
join={shipmentJoin} onClauses={setShipmentClauses} onJoin={setShipmentJoin} />
|
||||
) : question.kind === 'shipment' && (
|
||||
<TruthShipmentQuestion game={g} question={question} busy={busy}
|
||||
act={act} onChange={setShipment} />
|
||||
)}
|
||||
<p className="fine">
|
||||
Includes Fremen reinforcements and Guild transport from
|
||||
@@ -390,53 +379,15 @@ export function Truthtrance({
|
||||
remain unfinished.
|
||||
</p>
|
||||
)}
|
||||
<p className="notice" aria-label="Public grouped question preview">
|
||||
{kind === 'mixedShipment'
|
||||
? invalidMixed ?? truthQuestionText(question, leaderName, locationName)
|
||||
: invalidShipment
|
||||
? 'Enter a whole minimum of one to twenty forces.'
|
||||
: truthQuestionText(question, leaderName, locationName)}
|
||||
</p>
|
||||
{kind === 'mixedShipment' && <p className="notice" aria-label="Public grouped question preview">
|
||||
{invalidMixed ?? truthQuestionText(question, leaderName, locationName)}
|
||||
</p>}
|
||||
</>
|
||||
) : kind === 'battlePlan' ? (
|
||||
<>
|
||||
<label>
|
||||
Combine conditions
|
||||
<select
|
||||
value={join}
|
||||
onChange={(e) => setJoin(e.target.value as typeof join)}
|
||||
>
|
||||
<option value="single">One condition</option>
|
||||
<option value="and">Both · AND</option>
|
||||
<option value="or">Either · OR</option>
|
||||
</select>
|
||||
</label>
|
||||
{planClauses
|
||||
.slice(0, join === 'single' ? 1 : 2)
|
||||
.map((c, index) => (
|
||||
<fieldset key={index}>
|
||||
<legend>Condition {index + 1}</legend>
|
||||
<BattleClaimFields
|
||||
game={g}
|
||||
value={c}
|
||||
onChange={(next) =>
|
||||
setPlanClauses(
|
||||
planClauses.map((old, n) =>
|
||||
n === index ? next : old,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</fieldset>
|
||||
))}
|
||||
<p className="notice">
|
||||
{truthQuestionText(question, leaderName, locationName)}
|
||||
</p>
|
||||
{g.battle &&
|
||||
![g.battle.attacker, g.battle.defender].includes(
|
||||
target,
|
||||
) && <p>Select one of the combatants for this question.</p>}
|
||||
</>
|
||||
question.kind === 'battlePlan' && (
|
||||
<TruthBattlePlanQuestion game={g} question={question} busy={busy}
|
||||
act={act} onChange={setPlanClaim} />
|
||||
)
|
||||
) : kind === 'fact' ? (
|
||||
question.kind === 'fact' && (
|
||||
<TruthFactQuestion game={g} question={question} busy={busy}
|
||||
@@ -474,21 +425,14 @@ export function Truthtrance({
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{kind !== 'fact' && button(
|
||||
{!['fact', 'battlePlan', 'shipment'].includes(kind) && button(
|
||||
'Ask publicly',
|
||||
{ type: 'truthAsk', question },
|
||||
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
|
||||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
|
||||
(kind === 'shipment' &&
|
||||
(invalidShipment || !shipmentAvailable)) ||
|
||||
(kind === 'mixedShipment' &&
|
||||
(!!invalidMixed || !shipmentAvailable)) ||
|
||||
(kind === 'freeform' && !text.trim()) ||
|
||||
(kind === 'battlePlan' &&
|
||||
(!g.battle ||
|
||||
![g.battle.attacker, g.battle.defender].includes(
|
||||
target,
|
||||
))),
|
||||
(kind === 'freeform' && !text.trim()),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user