diff --git a/components/battle-promises.tsx b/components/battle-promises.tsx index a197fb40..de448059 100644 --- a/components/battle-promises.tsx +++ b/components/battle-promises.tsx @@ -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 (
- {b.compliantPreparation.actions[0] && ( + {b.compliantPreparation.actions[0] && (!mixedAssistance || mixed.length > 0 && !b.revealed && !b.submitted.includes(actor)) && ( )} )} - {plan && ( + {plan && (!mixedAssistance || mixed.length > 0 && !b.revealed && !b.submitted.includes(actor)) && (
Review a compliant plan · private

@@ -240,7 +266,7 @@ export function BattlePromises({ These choices satisfy your current commitments. Review and change the other elements before sealing your plan.

-
diff --git a/components/game-table.tsx b/components/game-table.tsx index f2e1b46f..6f233b63 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -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' && } {g.status === 'playing' && } {g.status === 'playing' && } + {g.status === 'playing' && } {g.guildBetrayalReaction ? ( ) : g.richeseBetrayalReaction ? ( diff --git a/components/mixed-action-fields.tsx b/components/mixed-action-fields.tsx index abd4ae21..835cc4ec 100644 --- a/components/mixed-action-fields.tsx +++ b/components/mixed-action-fields.tsx @@ -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 = { 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 = { const newFact = (): MixedActionExpression => ({ 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(node: MixedActionExpression): { 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(node: MixedActionExpression, config: MixedActionConfig): 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(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({ value, game, target, disabled, onChange, }) { const newAction = (): MixedActionExpression => ({ 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) => { if (!disabled && !structuralError(next, config)) onChange(next); }; @@ -171,9 +168,11 @@ export function MixedActionFields({ 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.

{config.actionHelp}

-

App input limits: {config.maxLeaves} conditions and {config.maxDepth} levels of grouping. +

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.

-

{size.leaves} / {config.maxLeaves} conditions · {size.depth} / {config.maxDepth} levels of grouping

+

{config.maxNodes === undefined ? `${size.leaves} / ${config.maxLeaves} conditions` + : `${size.nodes} / ${config.maxNodes} total clauses`} · {size.depth} / {config.maxDepth} levels of grouping

{error &&

{error}

} {render(value, '1', next => next)} ; diff --git a/components/mixed-battle-fields.tsx b/components/mixed-battle-fields.tsx new file mode 100644 index 00000000..d54ec2f1 --- /dev/null +++ b/components/mixed-battle-fields.tsx @@ -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 = { + 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 }) => <> + + + {(value.kind === 'dial' || value.kind === 'support') && <> + + + + 40 || + !Number.isInteger(value.value * (value.kind === 'dial' ? 2 : 1))} + onChange={event => onChange({ ...value, value: event.currentTarget.valueAsNumber })} /> + } + {(value.kind === 'weapon' || value.kind === 'defense') && <> + + + } + {value.kind === 'leader' && <> + + + } + {value.kind === 'kwisatz' && <> + + + } + , +}; + +export function mixedBattleInputError(value: unknown, game: GameView, target: string): string | null { + return mixedActionInputError(value, game, target, config); +} + +export function MixedBattleFields(props: { + value: MixedActionExpression; + game: GameView; + target: string; + disabled: boolean; + onChange: (expression: MixedActionExpression) => void; +}) { + return MixedActionFields({ ...props, config }); +} diff --git a/components/mixed-battle-promises.tsx b/components/mixed-battle-promises.tsx new file mode 100644 index 00000000..0788b046 --- /dev/null +++ b/components/mixed-battle-promises.tsx @@ -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
+

Your mixed battle promises

+
    {own.map(promise =>
  • + 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.'} +
  • )}
+

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.

+

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. + {' '}Mixed battle rules.

+
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 2b2ebb4d..8e068001 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -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>({ 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({ territory: 'carthag', minimum: 6 }); const [mixed, setMixed] = useState({ 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({ ))} + ) : w.question!.kind === 'mixedBattlePlan' ? ( + <> +

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.

+ {(g.truthMixedBattleAnswers ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })} +
)} + ) : w.question!.kind === 'mixedBid' ? ( <>

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.

@@ -538,6 +554,7 @@ export function Truthtrance({ Combine current facts and this-turn shipment + {g.battle && } {g.battle && (