Combine current facts with native battle plans
This commit is contained in:
1 parent
05330ca7c9
commit
812f77e348
24 files changed
+1615
-42
No files matched your search
@@ -123,19 +123,18 @@ export function BattleClaimFields({
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function BattlePromises({
|
||||
game: g,
|
||||
fill,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
export function BattlePromises(props: {
|
||||
game: GameView;
|
||||
fill: (plan: Plan) => void;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { game: g, fill, act } = props;
|
||||
const b = g.battle;
|
||||
if (!b || (!b.truthPromises.length && !b.compliantPreparation)) return null;
|
||||
if (!b) return null;
|
||||
const mixedRows = (g.mixedBattlePromises ?? []).filter(promise => promise.player === g.me && promise.turn === g.turn &&
|
||||
promise.battle === b.event && promise.territory === b.territory && !promise.fulfilled && !promise.released);
|
||||
if (!b.truthPromises.length && !b.compliantPreparation && (!b.compliantPlan || !mixedRows.length)) return null;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const name = (id: string | null) =>
|
||||
id === null
|
||||
@@ -144,6 +143,39 @@ export function BattlePromises({
|
||||
g.allLeaders.find((l) => l.id === id)?.name ??
|
||||
id);
|
||||
const plan = b.compliantPlan;
|
||||
const preparation = b.compliantPreparation;
|
||||
const nextPreparation = preparation?.actions[0];
|
||||
const actor = g.me, turn = g.turn, event = b.event, territory = b.territory;
|
||||
const attacker = b.attacker, defender = b.defender;
|
||||
const mixed = mixedRows
|
||||
.map(promise => ({ promise, event: promise.event, asker: promise.asker, answer: promise.answer,
|
||||
mixed: promise.mixed, signature: JSON.stringify(promise.mixed) }));
|
||||
const mixedAssistance = mixed.length > 0;
|
||||
const planSignature = JSON.stringify(plan), preparationSignature = JSON.stringify(preparation);
|
||||
// Native completion owns feasibility. For mixed assistance, a saved callback
|
||||
// also has to belong to this exact live question and current private guide.
|
||||
const canUseMixedAssistance = () => !mixedAssistance || (
|
||||
!props.busy && props.game === g && g.status === 'playing' && g.phase === 6 && g.me === actor && g.turn === turn &&
|
||||
g.battle === b && typeof event === 'string' && b.event === event && b.territory === territory &&
|
||||
b.attacker === attacker && b.defender === defender && [attacker, defender].includes(actor) &&
|
||||
!b.revealed && !b.submitted.includes(actor) && mixed.length > 0 &&
|
||||
mixed.every(context => g.mixedBattlePromises.includes(context.promise) && context.promise.event === context.event &&
|
||||
context.promise.asker === context.asker && context.promise.answer === context.answer &&
|
||||
context.promise.mixed === context.mixed && JSON.stringify(context.promise.mixed) === context.signature &&
|
||||
context.promise.player === actor &&
|
||||
context.promise.turn === turn && context.promise.battle === event && context.promise.territory === territory &&
|
||||
!context.promise.fulfilled && !context.promise.released) &&
|
||||
g.mixedBattlePromises.filter(promise => promise.player === actor && promise.turn === turn &&
|
||||
promise.battle === event && promise.territory === territory && !promise.fulfilled && !promise.released).length === mixed.length &&
|
||||
!g.truthtrance && !g.response && !g.decision && !g.phaseOpening && !g.automaticContinuationPending &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !!me &&
|
||||
g.players.find(player => player.id === actor) === me && !me.autopilot
|
||||
);
|
||||
const canPrepare = () => !props.busy && !g.truthtrance && !g.response && !g.decision && !g.phaseOpening &&
|
||||
canUseMixedAssistance() && (!mixedAssistance || b.compliantPreparation === preparation &&
|
||||
b.compliantPreparation?.actions[0] === nextPreparation && JSON.stringify(preparation) === preparationSignature && props.act === act);
|
||||
const canFill = () => !props.busy && canUseMixedAssistance() && (!mixedAssistance ||
|
||||
b.compliantPlan === plan && !b.compliantPreparation && JSON.stringify(plan) === planSignature && props.fill === fill);
|
||||
return (
|
||||
<section
|
||||
className="notice truthtrance-history"
|
||||
@@ -198,24 +230,18 @@ export function BattlePromises({
|
||||
: 'The revival is complete. Resolve its income response before reviewing and sealing the plan.'}{' '}
|
||||
Other legal ways to keep your answer remain available.
|
||||
</p>
|
||||
{b.compliantPreparation.actions[0] && (
|
||||
{b.compliantPreparation.actions[0] && (!mixedAssistance || mixed.length > 0 && !b.revealed && !b.submitted.includes(actor)) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
busy ||
|
||||
!!g.truthtrance ||
|
||||
!!g.response ||
|
||||
!!g.decision ||
|
||||
!!g.phaseOpening
|
||||
}
|
||||
onClick={() => act(b.compliantPreparation!.actions[0])}
|
||||
disabled={!canPrepare()}
|
||||
onClick={() => { if (canPrepare() && nextPreparation) act(nextPreparation); }}
|
||||
>
|
||||
Complete next preparation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{plan && (
|
||||
{plan && (!mixedAssistance || mixed.length > 0 && !b.revealed && !b.submitted.includes(actor)) && (
|
||||
<details>
|
||||
<summary>Review a compliant plan · private</summary>
|
||||
<p>
|
||||
@@ -240,7 +266,7 @@ export function BattlePromises({
|
||||
These choices satisfy your current commitments. Review and change
|
||||
the other elements before sealing your plan.
|
||||
</p>
|
||||
<Button variant="outline" disabled={busy} onClick={() => fill(plan)}>
|
||||
<Button variant="outline" disabled={!canFill()} onClick={() => { if (canFill()) fill(plan); }}>
|
||||
Fill these plan choices
|
||||
</Button>
|
||||
</details>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client';
|
||||
import { MixedBattlePromises } from './mixed-battle-promises';
|
||||
import { TraitorDecisionPromises } from './traitor-decision-promises';
|
||||
import { MovementPromises } from './movement-promises';
|
||||
import { MovementExpressionPromises } from './movement-expression-promises';
|
||||
@@ -2058,6 +2059,7 @@ export function GameTable({
|
||||
{g.status === 'playing' && <RevivalPromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <MixedBattlePromises game={g} />}
|
||||
{g.guildBetrayalReaction ? (
|
||||
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
|
||||
) : g.richeseBetrayalReaction ? (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { parseMixedActionExpression, type MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import { mixedActionExpressionSize, parseMixedActionExpression, type MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import type { TruthFact } from '@/game/truthtrance';
|
||||
import { truthFactExpressionError } from '@/game/truth-fact-expression';
|
||||
import { TruthFactFields, invalidTruthFact } from './mixed-shipment-fields';
|
||||
@@ -17,6 +17,8 @@ export type MixedActionConfig<A> = {
|
||||
fallbackError: string;
|
||||
maxLeaves: number;
|
||||
maxDepth: number;
|
||||
maxNodes?: number;
|
||||
maxTerms?: number;
|
||||
newAction: () => A;
|
||||
parseAction: (value: unknown, game: GameView, target: string) => A;
|
||||
renderAction: (props: { value: A; id: string; game: GameView; target: string;
|
||||
@@ -25,21 +27,14 @@ export type MixedActionConfig<A> = {
|
||||
|
||||
const newFact = <A,>(): MixedActionExpression<A> => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } });
|
||||
const newFactLeaf = (): TruthFact => ({ kind: 'hand', name: 'Shield' });
|
||||
function factSize(fact: TruthFact): { leaves: number; depth: number } {
|
||||
if (fact.kind !== 'and' && fact.kind !== 'or') return { leaves: 1, depth: 0 };
|
||||
const sizes = fact.terms.map(factSize);
|
||||
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
|
||||
}
|
||||
function sizeOf<A>(node: MixedActionExpression<A>): { leaves: number; depth: number } {
|
||||
if (node.kind === 'fact') return factSize(node.fact);
|
||||
if (node.kind === 'action') return { leaves: 1, depth: 0 };
|
||||
const sizes = node.terms.map(sizeOf);
|
||||
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
|
||||
}
|
||||
function structuralError<A>(node: MixedActionExpression<A>, config: MixedActionConfig<A>): string | null {
|
||||
const size = sizeOf(node);
|
||||
const size = mixedActionExpressionSize(node);
|
||||
if (size.leaves > config.maxLeaves || size.depth > config.maxDepth)
|
||||
return 'Use at most sixteen conditions and four levels of grouping.';
|
||||
if (config.maxNodes !== undefined && size.nodes > config.maxNodes)
|
||||
return `Use at most ${config.maxNodes} total clauses, including groups.`;
|
||||
if (config.maxTerms !== undefined && size.maxTerms > config.maxTerms)
|
||||
return `Use at most ${config.maxTerms} conditions in each AND/OR group.`;
|
||||
if (node.kind === 'fact') return truthFactExpressionError(node.fact);
|
||||
if (node.kind === 'action') return null;
|
||||
if (node.terms.length < 2) return 'Join two or more conditions with AND or OR.';
|
||||
@@ -60,7 +55,9 @@ export function mixedActionInputError<A>(value: unknown, game: GameView, target:
|
||||
if (error) throw new Error(error);
|
||||
if (invalidTruthFact(fact, game, target)) throw new Error('Resolve the marked current-fact fields.');
|
||||
return fact;
|
||||
}, input => config.parseAction(input, game, target), { maxDepth: config.maxDepth, maxLeaves: config.maxLeaves });
|
||||
}, input => config.parseAction(input, game, target), { maxDepth: config.maxDepth, maxLeaves: config.maxLeaves,
|
||||
...(config.maxNodes === undefined ? {} : { maxNodes: config.maxNodes }),
|
||||
...(config.maxTerms === undefined ? {} : { maxTerms: config.maxTerms }) });
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : config.fallbackError;
|
||||
@@ -79,7 +76,7 @@ export function MixedActionFields<A>({ value, game, target, disabled, onChange,
|
||||
}) {
|
||||
const newAction = (): MixedActionExpression<A> => ({ kind: 'action', action: config.newAction() });
|
||||
const attrs = (suffix: string, value: string) => ({ [`data-${config.prefix}-${suffix}`]: value });
|
||||
const size = sizeOf(value), error = mixedActionInputError(value, game, target, config);
|
||||
const size = mixedActionExpressionSize(value), error = mixedActionInputError(value, game, target, config);
|
||||
const commit = (next: MixedActionExpression<A>) => {
|
||||
if (!disabled && !structuralError(next, config)) onChange(next);
|
||||
};
|
||||
@@ -171,9 +168,11 @@ export function MixedActionFields<A>({ value, game, target, disabled, onChange,
|
||||
AND requires every condition; OR requires at least one. No makes the whole grouped claim false, not necessarily every condition.
|
||||
Only the aggregate answer is shared; an unknown current fact can still allow a definite whole answer.</p>
|
||||
<p className="fine">{config.actionHelp}</p>
|
||||
<p className="fine">App input limits: {config.maxLeaves} conditions and {config.maxDepth} levels of grouping.
|
||||
<p className="fine">App input limits: {config.maxNodes === undefined ? `${config.maxLeaves} conditions` : `${config.maxNodes} total clauses, including groups`}
|
||||
{config.maxTerms !== undefined && `, ${config.maxTerms} conditions per group`} and {config.maxDepth} levels of grouping.
|
||||
These are input limits, not additional printed rules.</p>
|
||||
<p className="fine">{size.leaves} / {config.maxLeaves} conditions · {size.depth} / {config.maxDepth} levels of grouping</p>
|
||||
<p className="fine">{config.maxNodes === undefined ? `${size.leaves} / ${config.maxLeaves} conditions`
|
||||
: `${size.nodes} / ${config.maxNodes} total clauses`} · {size.depth} / {config.maxDepth} levels of grouping</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import type { GameView } from '@/game/engine';
|
||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import {
|
||||
MIXED_BATTLE_MAX_DEPTH, MIXED_BATTLE_MAX_LEAVES, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS,
|
||||
parseBattleActionClaim, type MixedBattleActionClaim,
|
||||
} from '@/game/mixed-battle-question';
|
||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
const dial = (): MixedBattleActionClaim => ({ kind: 'dial', compare: 'eq', value: 0 });
|
||||
|
||||
/** The original plan-element vocabulary is public. Authoring a question never
|
||||
* inspects either player's cards, available leaders, forces or ability to pay. */
|
||||
const config: MixedActionConfig<MixedBattleActionClaim> = {
|
||||
prefix: 'mixed-battle',
|
||||
legend: 'Mixed current-fact / actual battle-plan claim',
|
||||
actionLabel: 'battle-plan condition',
|
||||
actionTiming: 'Actual battle plan · this battle',
|
||||
actionHelp: <>Battle conditions concern the respondent’s actual plan in this battle, including a plan already fixed, sealed or revealed.
|
||||
Current facts are snapshotted at the answer. An unknown current fact uses the conservative plan commitment that guarantees the whole answer;
|
||||
if no such answer is possible, the answer is I don’t know. No alternate route is forced.
|
||||
Native Voice, Prescience, card, leader, force and payment rules still apply.</>,
|
||||
fallbackError: 'Choose a current fact and an actual battle-plan condition.',
|
||||
maxLeaves: MIXED_BATTLE_MAX_LEAVES,
|
||||
maxDepth: MIXED_BATTLE_MAX_DEPTH,
|
||||
maxNodes: MIXED_BATTLE_MAX_NODES,
|
||||
maxTerms: MIXED_BATTLE_MAX_TERMS,
|
||||
newAction: dial,
|
||||
parseAction: (value, game) => parseBattleActionClaim(value, game.allLeaders.map(leader => leader.id)),
|
||||
renderAction: ({ value, id, game, disabled, onChange }) => <>
|
||||
<label htmlFor={`${id}-kind`}>Plan element</label>
|
||||
<select id={`${id}-kind`} value={value.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.currentTarget.value;
|
||||
if (kind === 'dial' || kind === 'support') onChange({ kind, compare: 'eq', value: 0 });
|
||||
else if (kind === 'weapon' || kind === 'defense') onChange({ kind, name: null });
|
||||
else if (kind === 'leader') onChange({ kind, leader: game.allLeaders[0]?.id ?? null });
|
||||
else if (kind === 'kwisatz') onChange({ kind, use: true });
|
||||
}}>
|
||||
<option value="dial">Dial</option>
|
||||
<option value="leader">Leader or hero</option>
|
||||
<option value="weapon">Weapon slot</option>
|
||||
<option value="defense">Defense slot</option>
|
||||
<option value="support">Spice support</option>
|
||||
<option value="kwisatz">Kwisatz Haderach</option>
|
||||
</select>
|
||||
{(value.kind === 'dial' || value.kind === 'support') && <>
|
||||
<label htmlFor={`${id}-compare`}>Comparison</label>
|
||||
<select id={`${id}-compare`} value={value.compare} disabled={disabled} onChange={event => {
|
||||
const compare = event.currentTarget.value;
|
||||
if (compare === 'eq' || compare === 'gte' || compare === 'lte') onChange({ ...value, compare });
|
||||
}}>
|
||||
<option value="eq">Exactly</option>
|
||||
<option value="gte">At least</option>
|
||||
<option value="lte">At most</option>
|
||||
</select>
|
||||
<label htmlFor={`${id}-amount`}>Amount</label>
|
||||
<Input id={`${id}-amount`} type="number" min={0} max={40} step={value.kind === 'dial' ? 0.5 : 1}
|
||||
required disabled={disabled} value={Number.isFinite(value.value) ? value.value : ''}
|
||||
aria-invalid={!Number.isFinite(value.value) || value.value < 0 || value.value > 40 ||
|
||||
!Number.isInteger(value.value * (value.kind === 'dial' ? 2 : 1))}
|
||||
onChange={event => onChange({ ...value, value: event.currentTarget.valueAsNumber })} />
|
||||
</>}
|
||||
{(value.kind === 'weapon' || value.kind === 'defense') && <>
|
||||
<label htmlFor={`${id}-card`}>Card name</label>
|
||||
<select id={`${id}-card`} value={value.name ?? ''} disabled={disabled}
|
||||
aria-invalid={value.name !== null && !TRUTH_CARD_NAMES.includes(value.name)}
|
||||
onChange={event => onChange({ ...value, name: event.currentTarget.value || null })}>
|
||||
<option value="">Empty slot</option>
|
||||
{TRUTH_CARD_NAMES.map(name => <option key={name}>{name}</option>)}
|
||||
</select>
|
||||
</>}
|
||||
{value.kind === 'leader' && <>
|
||||
<label htmlFor={`${id}-leader`}>Leader</label>
|
||||
<select id={`${id}-leader`} value={value.leader ?? ''} disabled={disabled}
|
||||
aria-invalid={value.leader !== null && value.leader !== CHEAP_HERO_TRAITOR && !game.allLeaders.some(leader => leader.id === value.leader)}
|
||||
onChange={event => onChange({ ...value, leader: event.currentTarget.value || null })}>
|
||||
<option value="">No leader or hero</option>
|
||||
{game.allLeaders.map(leader => <option key={leader.id} value={leader.id}>{leader.name}</option>)}
|
||||
<option value={CHEAP_HERO_TRAITOR}>Cheap Hero / Heroine</option>
|
||||
</select>
|
||||
</>}
|
||||
{value.kind === 'kwisatz' && <>
|
||||
<label htmlFor={`${id}-kwisatz`}>Kwisatz use</label>
|
||||
<select id={`${id}-kwisatz`} value={value.use ? 'yes' : 'no'} disabled={disabled}
|
||||
onChange={event => onChange({ ...value, use: event.currentTarget.value === 'yes' })}>
|
||||
<option value="yes">Included</option>
|
||||
<option value="no">Not included</option>
|
||||
</select>
|
||||
</>}
|
||||
</>,
|
||||
};
|
||||
|
||||
export function mixedBattleInputError(value: unknown, game: GameView, target: string): string | null {
|
||||
return mixedActionInputError(value, game, target, config);
|
||||
}
|
||||
|
||||
export function MixedBattleFields(props: {
|
||||
value: MixedActionExpression<MixedBattleActionClaim>;
|
||||
game: GameView;
|
||||
target: string;
|
||||
disabled: boolean;
|
||||
onChange: (expression: MixedActionExpression<MixedBattleActionClaim>) => void;
|
||||
}) {
|
||||
return MixedActionFields({ ...props, config });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { truthQuestionText } from '@/game/truthtrance';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
|
||||
/** The original question and whole answer remain visible after this battle.
|
||||
* Assistance belongs to the native battle panel; facts and residuals stay private. */
|
||||
export function MixedBattlePromises({ game }: { game: GameView }) {
|
||||
const own = game.mixedBattlePromises.filter(promise => promise.player === game.me);
|
||||
if (!own.length) return null;
|
||||
const leaderName = (id: string) => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
|
||||
: game.allLeaders.find(leader => leader.id === id)?.name ?? id;
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance mixed battle promises" id="mixed-battle-promise-guidance">
|
||||
<h3>Your mixed battle promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedBattlePlan',
|
||||
target: promise.player, battle: promise.battle, territory: promise.territory, mixed: promise.mixed }, leaderName,
|
||||
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
|
||||
id => game.players.find(player => player.id === id)?.name ?? id)}
|
||||
{promise.released ? ' Released: this whole commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding for this battle.'}
|
||||
</li>)}</ul>
|
||||
<p>Battle conditions concern your actual plan in the named battle, including an already fixed, sealed or revealed plan.
|
||||
Current facts are snapshotted at the answer. They create no promise to keep cards, spice or forces afterward.
|
||||
Only the aggregate answer is shared. Individual fact answers and any reduced plan commitment remain private.
|
||||
AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.</p>
|
||||
<p className="fine">An unknown current fact uses the conservative plan commitment that guarantees the whole answer;
|
||||
if no such answer is possible, the answer is I don’t know. No alternate route is forced.
|
||||
An open commitment completes when both plans are revealed; sealing your own plan does not finish it.
|
||||
Native Voice, Prescience, card, leader, force and payment rules still apply.
|
||||
{' '}<a href="/rules#truthtrance-mixed-battle">Mixed battle rules</a>.</p>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { MixedBattleFields, mixedBattleInputError } from './mixed-battle-fields';
|
||||
import type { MixedBattleActionClaim } from '@/game/mixed-battle-question';
|
||||
|
||||
import { MixedBribeFields, mixedBribeInputError } from './mixed-bribe-fields';
|
||||
import { MixedRevivalFields, mixedRevivalInputError } from './mixed-revival-fields';
|
||||
import { MixedBidFields, mixedBidInputError } from './mixed-bid-fields';
|
||||
@@ -83,7 +86,7 @@ export function Truthtrance({
|
||||
g.players.find((p) => p.id !== me.id)!.id,
|
||||
);
|
||||
const [kind, setKind] = useState<
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance'
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan'
|
||||
>('fact');
|
||||
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
|
||||
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
|
||||
@@ -150,6 +153,11 @@ export function Truthtrance({
|
||||
{ kind: 'action', action: { minimum: 4 } },
|
||||
] });
|
||||
const mixedBidError = mixedBidInputError(mixedBid, g, target);
|
||||
const [mixedBattle, setMixedBattle] = useState<MixedActionExpression<MixedBattleActionClaim>>({ kind: 'and', terms: [
|
||||
{ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } },
|
||||
{ kind: 'action', action: { kind: 'dial', compare: 'eq', value: 0 } },
|
||||
] });
|
||||
const mixedBattleError = mixedBattleInputError(mixedBattle, g, target);
|
||||
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
|
||||
const [mixed, setMixed] = useState<MixedShipmentExpression>({
|
||||
kind: 'and',
|
||||
@@ -185,6 +193,7 @@ export function Truthtrance({
|
||||
territory: g.battle?.territory ?? '',
|
||||
claim: planClaim,
|
||||
}
|
||||
: kind === 'mixedBattlePlan' ? { kind, target, battle: g.battle?.event ?? null, territory: g.battle?.territory ?? '', mixed: mixedBattle }
|
||||
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
|
||||
: kind === 'mixedMovement' ? { kind, target, turn: g.turn, mixed: mixedMovement }
|
||||
: kind === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression }
|
||||
@@ -308,6 +317,13 @@ export function Truthtrance({
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : w.question!.kind === 'mixedBattlePlan' ? (
|
||||
<>
|
||||
<p className="notice">Answer the complete current-fact and battle-plan question. Current facts freeze now; any remaining plan conditions use your native legal plans and preparation. No negates the whole expression. A binding open plan settles only when the plans are actually revealed; a plan already submitted is fixed. Voice, Prescience, card eligibility and normal costs still apply.</p>
|
||||
{(g.truthMixedBattleAnswers ?? []).map(answer => <div key={answer}>
|
||||
{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'mixedBid' ? (
|
||||
<>
|
||||
<p className="notice">Answer the whole mixed question. Current facts freeze now; any remaining bid conditions concern future accepted declarations in this ordinary lot. No keeps the whole expression false. Passing, funding, Karama and the original winning payment still follow the auction rules. An unknown fact requires a commitment that guarantees the whole answer; otherwise choose I don’t know.</p>
|
||||
@@ -538,6 +554,7 @@ export function Truthtrance({
|
||||
Combine current facts and this-turn shipment
|
||||
</option>
|
||||
<option value="freeform">Write a game question</option>
|
||||
{g.battle && <option value="mixedBattlePlan">Combine current facts and this battle plan</option>}
|
||||
{g.battle && (
|
||||
<option value="battlePlan">
|
||||
Bind a battle-plan choice
|
||||
@@ -545,7 +562,14 @@ export function Truthtrance({
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{kind === 'allianceExpression' ? (
|
||||
{kind === 'mixedBattlePlan' ? (
|
||||
<>
|
||||
<MixedBattleFields value={mixedBattle} game={g} target={target}
|
||||
disabled={busy || !g.truthMixedBattleTargets.includes(target)} onChange={setMixedBattle} />
|
||||
{!g.truthMixedBattleTargets.includes(target) && <p className="notice">Choose another combatant in the current battle.</p>}
|
||||
<p className="notice" aria-label="Public mixed battle question preview">{mixedBattleError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'allianceExpression' ? (
|
||||
<>
|
||||
<AllianceExpressionFields value={allianceExpression} partners={alliancePartners}
|
||||
disabled={busy || !g.truthAllianceExpressionTargets.includes(target)} onChange={setAllianceExpression} />
|
||||
@@ -761,6 +785,7 @@ export function Truthtrance({
|
||||
(kind === 'mixedRevival' && (!!mixedRevivalError || !g.truthMixedRevivalTargets.includes(target))) ||
|
||||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
|
||||
(kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) ||
|
||||
(kind === 'mixedBattlePlan' && (!!mixedBattleError || !g.truthMixedBattleTargets.includes(target))) ||
|
||||
(kind === 'allianceExpression' && (!!allianceExpressionError || !g.truthAllianceExpressionTargets.includes(target))) ||
|
||||
(kind === 'mixedAlliance' && (!!mixedAllianceError || !g.truthMixedAllianceTargets.includes(target))) ||
|
||||
(kind === 'alliance' && (!!allianceClaimInputError(allianceClaim, alliancePartners) || !g.truthAllianceTargets.includes(target))) ||
|
||||
|
||||
Reference in new issue
Block a user