diff --git a/components/game-table.tsx b/components/game-table.tsx index 665738e0..1da8ca49 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -4523,7 +4523,7 @@ export function GameTable({ g.shipmentPromises, me.id, g.turn, - ).some((promise) => promise.answer) + ).some(promise => matchesShipment(promise,null) !== promise.answer) ? 'Complete the promised shipment before moving.' : null } @@ -4589,7 +4589,7 @@ export function GameTable({ g.shipmentPromises, me.id, g.turn, - ).some((promise) => promise.answer) + ).some(promise => matchesShipment(promise,null) !== promise.answer) ? 'Complete the promised shipment before moving.' : null } diff --git a/components/mixed-shipment-fields.tsx b/components/mixed-shipment-fields.tsx new file mode 100644 index 00000000..5796c798 --- /dev/null +++ b/components/mixed-shipment-fields.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { useId, type ReactElement } from 'react'; +import type { GameView } from '@/game/engine'; +import type { MixedShipmentExpression } from '@/game/mixed-shipment-question'; +import { TRUTH_CARD_NAMES, type TruthFact } from '@/game/truthtrance'; +import { SHIPMENT_EXPRESSION_MAX_DEPTH, SHIPMENT_EXPRESSION_MAX_LEAVES } from '@/game/shipment-promises'; +import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge'; +import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; +import { CardCountFields } from './truthtrance-card-count'; +import { KnowledgeFactFields } from './truthtrance-knowledge'; +import { ForceCountFields, forceCountInputError } from './truthtrance-force-count'; +import { ShipmentClauseFields, invalidShipmentClause } from './shipment-claim-fields'; + +export function invalidTruthFact(fact: TruthFact, game: GameView, target: string): boolean { + if (fact.kind === 'and' || fact.kind === 'or') { + return fact.terms.some(term => invalidTruthFact(term, game, target)); + } + if (fact.kind === 'spice' || fact.kind === 'handCount' || fact.kind === 'handInventory') { + return !Number.isSafeInteger(fact.value) || fact.value < 0; + } + if (isKnowledgeFact(fact)) return !!knowledgeFactInputError(fact); + if (fact.kind === 'forceCount') { + return !!forceCountInputError(fact, game, game.players.find(player => player.id === target)!); + } + return false; +} + +/** The same current-fact fields serve standalone and mixed questions. */ +export function TruthFactFields({ value: fact, game, target, onChange }: { + value: TruthFact; + game: GameView; + target: string; + onChange: (fact: TruthFact) => void; +}) { + const id = useId(); + const defaults: Record = { + hand: { kind: 'hand', name: 'Shield' }, + handCount: { kind: 'handCount', name: 'Shield', compare: 'gte', value: 2 }, + handInventory: { kind: 'handInventory', category: 'all', compare: 'gte', value: 2 }, + traitor: { kind: 'traitor', leader: game.allLeaders[0].id }, + predictionFaction: { kind: 'prediction', field: 'faction', faction: 'atreides' }, + predictionTurn: { kind: 'prediction', field: 'turn', compare: 'gte', value: 1 }, + stormDial: { kind: 'stormDial', compare: 'gte', value: 0 }, + stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 }, + spice: { kind: 'spice', compare: 'gte', value: 6 }, + forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 }, + }; + return <> + + {fact.kind === 'hand' ? : fact.kind === 'handCount' || fact.kind === 'handInventory' ? + : fact.kind === 'forceCount' ? + player.id === target)!} onChange={onChange} /> : + fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' ? + : fact.kind === 'spice' ? <> + + + onChange({ ...fact, value: event.currentTarget.valueAsNumber })} /> +

+ Counts spice the player personally holds now. Excludes pledged aid and incoming payments. + This asks about the current balance; it does not promise future holdings or spending. +

+ {invalidTruthFact(fact, game, target) && } + : fact.kind === 'traitor' ? : null} + ; +} + +const newFact = (): MixedShipmentExpression => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } }); +const newShipment = (): MixedShipmentExpression => ({ kind: 'shipment', territory: 'carthag', minimum: 6 }); +function factSize(fact: TruthFact): { leaves: number; depth: number } { + if (fact.kind !== 'and' && fact.kind !== 'or') return { leaves: 1, depth: 0 }; + const children = fact.terms.map(factSize); + return { leaves: children.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(...children.map(child => child.depth)) }; +} +function expressionSize(expression: MixedShipmentExpression): { leaves: number; depth: number } { + if (expression.kind === 'fact') return factSize(expression.fact); + if (expression.kind === 'shipment') return { leaves: 1, depth: 0 }; + const children = expression.terms.map(expressionSize); + return { leaves: children.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(...children.map(child => child.depth)) }; +} + +export function mixedShipmentInputError(expression: MixedShipmentExpression, game: GameView, target: string): string | null { + const size = expressionSize(expression); + if (size.leaves > SHIPMENT_EXPRESSION_MAX_LEAVES || size.depth > SHIPMENT_EXPRESSION_MAX_DEPTH) { + return 'Use at most sixteen conditions and four nested groups.'; + } + let facts = 0; + let shipments = 0; + let invalid = false; + const visit = (node: MixedShipmentExpression) => { + if (node.kind === 'fact') { + facts++; + invalid ||= invalidTruthFact(node.fact, game, target); + } else if (node.kind === 'shipment') { + shipments++; + invalid ||= invalidShipmentClause(node); + } else { + invalid ||= node.terms.length < 2; + node.terms.forEach(visit); + } + }; + visit(expression); + if (!facts || !shipments) return 'Include both a current fact and a shipment-from-reserves condition.'; + return invalid ? 'Resolve the marked fields; shipment minima must be whole numbers from one to twenty.' : null; +} + +export function MixedShipmentFields({ id, value, game, target, disabled, onChange }: { + id: string; + value: MixedShipmentExpression; + game: GameView; + target: string; + disabled: boolean; + onChange: (expression: MixedShipmentExpression) => void; +}) { + const size = expressionSize(value); + const render = (node: MixedShipmentExpression, path: string, depth: number, + update: (next: MixedShipmentExpression) => void, remove?: () => void): ReactElement => { + const group = node.kind === 'and' || node.kind === 'or'; + const wrapAllowed = size.leaves < SHIPMENT_EXPRESSION_MAX_LEAVES && + depth + expressionSize(node).depth < SHIPMENT_EXPRESSION_MAX_DEPTH; + return
+ {group ? `Group ${path} · ${node.kind.toUpperCase()}` : `Condition ${path}`} + {group ? <> + + {node.terms.map((term, index) => render(term, `${path}.${index + 1}`, depth + 1, + next => update({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }), + () => { + const terms = node.terms.filter((_, i) => i !== index); + update(terms.length === 1 ? terms[0] : { ...node, terms }); + }))} +
+ + + +
+ : <> + + {node.kind === 'fact' ? update({ kind: 'fact', fact })} /> : node.kind === 'shipment' ? + update({ kind: 'shipment', ...clause })} /> : null} + } +
+ + + {remove && } +
+
; + }; + return
+ Mixed current-fact / this-turn shipment claim +

+ Current facts are evaluated when the player answers, not after shipment. They do not require keeping + a card, spice or forces later. Every shipment condition refers to the same shipment from reserves this turn. + No makes the whole grouped claim false, not necessarily every condition. +

+

{size.leaves} / {SHIPMENT_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {SHIPMENT_EXPRESSION_MAX_DEPTH} nested groups

+ {render(value, '1', 0, onChange)} +
; +} diff --git a/components/shipment-claim-fields.tsx b/components/shipment-claim-fields.tsx index 972eac05..e8bcbb9f 100644 --- a/components/shipment-claim-fields.tsx +++ b/components/shipment-claim-fields.tsx @@ -7,6 +7,30 @@ export function invalidShipmentClause(claim: ShipmentClauseInput) { return !Number.isSafeInteger(claim.minimum) || claim.minimum < 1 || claim.minimum > 20; } +/** Destination and physical minimum shared by flat and nested question builders. */ +export function ShipmentClauseFields({ id, value, onChange }: { + id: string; + value: ShipmentClauseInput; + onChange: (claim: ShipmentClauseInput) => void; +}) { + return <> + + + + onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} /> +

+ A whole minimum of one to twenty physical forces in the same shipment from reserves this turn. + Skipping makes this shipment condition false. +

+ ; +} + export function ShipmentClaimFields({ id, clauses, join, onClauses, onJoin }: { id: string; clauses: ShipmentClauseInput[]; @@ -25,15 +49,7 @@ export function ShipmentClaimFields({ id, clauses, join, onClauses, onJoin }: { const update = (next: ShipmentClauseInput) => onClauses(clauses.map((previous, i) => i === index ? next : previous)); return
Shipment condition {index + 1} - - - - update({ ...claim, minimum: event.currentTarget.valueAsNumber })} /> +
; })}

diff --git a/components/shipment-promises.tsx b/components/shipment-promises.tsx index 5e852a22..42a26fe0 100644 --- a/components/shipment-promises.tsx +++ b/components/shipment-promises.tsx @@ -1,6 +1,8 @@ 'use client'; import type { Action, GameView } from '@/game/engine'; -import { liveShipmentPromises, shipmentClaimText } from '@/game/shipment-promises'; +import { liveShipmentPromises, matchesShipment, shipmentClaimText } from '@/game/shipment-promises'; +import { truthQuestionText } from '@/game/truthtrance'; +import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; import { territory } from '@/game/board'; import { reserveShipmentCost, guildShipmentCost } from '@/game/shipment-price'; import { Button } from './ui/button'; @@ -63,16 +65,30 @@ export function ShipmentPromises({

    {promises.map((p, index) => (
  • - You answered {p.answer ? 'Yes' : 'No'} to: Will you{' '} - {shipmentClaimText(p)} this turn? + You answered {p.answer ? 'Yes' : 'No'} to:{' '} + {p.mixed + ? truthQuestionText( + { kind: 'mixedShipment', target: p.player, mixed: p.mixed }, + id => id === CHEAP_HERO_TRAITOR + ? 'Cheap Hero / Heroine' + : g.allLeaders.find(leader => leader.id === id)?.name ?? id, + ) + : `Will you ${shipmentClaimText(p)} this turn?`}
  • ))}
- {promises.some((p) => p.answer) && ( + {promises.some((p) => matchesShipment(p, null) !== p.answer) ? (

Complete a qualifying shipment before ground movement or finishing - your turn. You can choose any legal count, sector and funding that - honor your answers. + your turn: skipping would not honor the whole claim you answered. + You can choose any legal count, sector and funding that honor all + your answers. Current facts remain fixed at the time of your answer. +

+ ) : ( +

+ Skipping shipment honors your whole claims. If you ship, choose a + legal shipment that honors every answer. Current facts are historical + and create no later card, spice or force holding obligation.

)} {next && ( diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index fd5266b4..17e8ed7e 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,18 +1,20 @@ 'use client'; import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput } from './shipment-claim-fields'; import { BattleClaimFields } from './battle-promises'; -import { CardCountFields } from './truthtrance-card-count'; -import { KnowledgeFactFields } from './truthtrance-knowledge'; -import { ForceCountFields, forceCountInputError } from './truthtrance-force-count'; -import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge'; +import { + MixedShipmentFields, + TruthFactFields, + invalidTruthFact, + mixedShipmentInputError, +} from './mixed-shipment-fields'; +import type { MixedShipmentExpression } from '@/game/mixed-shipment-question'; import type { PlanClaim } from '@/game/battle-promises'; import { useId, useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +import { shipmentAvailable as targetShipmentAvailable } from '@/game/shipment-opportunity'; import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; import { shipmentPromiseModeSupported } from '@/game/shipment-promises'; import { - TRUTH_CARD_NAMES, truthQuestionText, type TruthFact, type TruthQuestion, @@ -50,19 +52,27 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); const [kind, setKind] = useState< - 'fact' | 'freeform' | 'battlePlan' | 'shipment' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' >('fact'); const [shipmentJoin, setShipmentJoin] = useState<'single' | 'and' | 'or'>('single'); const [shipmentClauses, setShipmentClauses] = useState([ { territory: 'carthag', minimum: 6 }, { territory: 'arrakeen', minimum: 4 }, ]); + const [mixed, setMixed] = useState({ + kind: 'and', + terms: [ + { kind: 'fact', fact: { kind: 'hand', name: 'Shield' } }, + { kind: 'shipment', territory: 'carthag', minimum: 6 }, + ], + }); + const invalidMixed = mixedShipmentInputError(mixed, g, target); const invalidShipment = shipmentClauses.slice(0, shipmentJoin === 'single' ? 1 : 2).some(invalidShipmentClause); const shipmentAvailable = shipmentPromiseModeSupported(g) && g.phase === 5 && g.active === target && - !g.players.find((p) => p.id === target)?.shipped && + targetShipmentAvailable(g, {id:target,shipped:g.players.find(p => p.id === target)?.shipped ?? false}) && !g.response && !g.decision && !g.phaseOpening; @@ -78,15 +88,9 @@ export function Truthtrance({ { kind: 'hand', name: 'Snooper' }, ]); const factId = useId(); - const invalidSpice = (fact: TruthFact) => - (fact.kind === 'spice' || - fact.kind === 'handCount' || - fact.kind === 'handInventory') && - (!Number.isSafeInteger(fact.value) || fact.value < 0); const invalidFact = clauses .slice(0, join === 'single' ? 1 : 2) - .some((fact) => invalidSpice(fact) || (isKnowledgeFact(fact) && !!knowledgeFactInputError(fact)) || - (fact.kind === 'forceCount' && !!forceCountInputError(fact, g, g.players.find(p => p.id === target)!))); + .some((fact) => invalidTruthFact(fact, g, target)); const leaderName = (id: string) => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' @@ -115,7 +119,9 @@ export function Truthtrance({ target, ...(shipmentJoin === 'single' ? shipmentClauses[0] : { claim: { op: shipmentJoin, terms: shipmentClauses } }), } - : { kind, target, text, scope }; + : kind === 'mixedShipment' + ? { kind, target, mixed } + : { kind, target, text, scope }; const button = (label: string, action: Action, disabled = false) => (