From f5ae61f382f182da318ca84c639f45e082e86e0c Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:16:32 +1100 Subject: [PATCH] Support grouped Truthtrance revival commitments --- components/game-table.tsx | 2 + components/revival-expression-fields.tsx | 132 ++++++++++ components/revival-expression-promises.tsx | 97 +++++++ components/truthtrance.tsx | 33 ++- docs/CURRENT_STATUS.md | 31 +++ docs/IMPLEMENTATION_STATUS.md | 35 +++ docs/RULE_DECISIONS.md | 14 + docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md | 4 + docs/TRUTHTRANCE_REVIVAL_EXPRESSIONS.md | 111 ++++++++ docs/TRUTHTRANCE_REVIVAL_PROMISES.md | 5 + game/bots.ts | 5 + game/engine.ts | 161 ++++++++++-- game/reference.ts | 28 +- game/revival-expression-promises.ts | 227 ++++++++++++++++ game/truthtrance.ts | 22 ++ tests/fixture-truth-revival-expression.ts | 82 ++++++ tests/revival-expression-promises.test.ts | 226 ++++++++++++++++ ...truth-revival-expression-controls.test.tsx | 242 ++++++++++++++++++ .../truth-revival-expression-funding.test.ts | 104 ++++++++ .../truth-revival-expression-runtime.test.ts | 199 ++++++++++++++ 20 files changed, 1730 insertions(+), 30 deletions(-) create mode 100644 components/revival-expression-fields.tsx create mode 100644 components/revival-expression-promises.tsx create mode 100644 docs/TRUTHTRANCE_REVIVAL_EXPRESSIONS.md create mode 100644 game/revival-expression-promises.ts create mode 100644 tests/fixture-truth-revival-expression.ts create mode 100644 tests/revival-expression-promises.test.ts create mode 100644 tests/truth-revival-expression-controls.test.tsx create mode 100644 tests/truth-revival-expression-funding.test.ts create mode 100644 tests/truth-revival-expression-runtime.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 559a1b3e..249bd96b 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -27,6 +27,7 @@ import { ShipmentPromises } from './shipment-promises'; import { AlliancePromises } from './alliance-promises'; import { BribePromises } from './bribe-promises'; import { RevivalPromises } from './revival-promises'; +import { RevivalExpressionPromises } from './revival-expression-promises'; import { LeaderRevivalPromises } from './leader-revival-promises'; import { BidPromises } from './bid-promises'; import { HomeworldShipment } from './homeworld-shipment'; @@ -2037,6 +2038,7 @@ export function GameTable({ )} {g.status === 'playing' && } {g.status === 'playing' && } + {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } diff --git a/components/revival-expression-fields.tsx b/components/revival-expression-fields.tsx new file mode 100644 index 00000000..56094fc4 --- /dev/null +++ b/components/revival-expression-fields.tsx @@ -0,0 +1,132 @@ +'use client'; + +import type { ReactElement } from 'react'; +import { + parseRevivalExpression, REVIVAL_EXPRESSION_MAX_DEPTH, REVIVAL_EXPRESSION_MAX_LEAVES, + type RevivalExpression, +} from '@/game/revival-expression-promises'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +type LeaderChoice = { id: string; name: string }; +const newLeaf = (): RevivalExpression => ({ kind: 'forces', counter: 'total', minimum: 1 }); +export function revivalExpressionInputError(value: unknown): string | null { + try { parseRevivalExpression(value); return null; } + catch (error) { return error instanceof Error ? error.message : 'Choose a valid ordinary Revival expression.'; } +} + +// Numeric drafts remain visible and repairable. Only structural controls use +// this detached placeholder tree; the user's original numbers are retained. +const structuralDraft = (node: RevivalExpression): RevivalExpression => node.kind === 'and' || node.kind === 'or' + ? { ...node, terms: node.terms.map(structuralDraft) } + : node.kind === 'forces' ? { ...node, minimum: 1 } : node; +const inputSize = (node: RevivalExpression): { leaves: number; depth: number } => { + if (node.kind !== 'and' && node.kind !== 'or') return { leaves: 1, depth: 0 }; + const sizes = node.terms.map(inputSize); + return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) }; +}; + +/** Hook-free controlled tree, including invalid numeric drafts. Eligibility + * and the whole-expression answer remain authoritative on the server. */ +export function RevivalExpressionFields({ value, leaders, disabled, onChange }: { + value: RevivalExpression; + leaders: readonly LeaderChoice[]; + disabled: boolean; + onChange: (expression: RevivalExpression) => void; +}) { + const size = inputSize(value), error = revivalExpressionInputError(value); + const commit = (next: RevivalExpression) => { + if (!disabled && !revivalExpressionInputError(structuralDraft(next))) onChange(next); + }; + const render = (node: RevivalExpression, path: string, replace: (next: RevivalExpression) => RevivalExpression, + remove?: () => RevivalExpression): ReactElement => { + const group = node.kind === 'and' || node.kind === 'or'; + const id = `truth-revival-expression-${path}`; + const allowed = (next: RevivalExpression) => !disabled && !revivalExpressionInputError(structuralDraft(replace(next))); + const change = (next: RevivalExpression) => commit(replace(next)); + const wrap = (kind: 'and' | 'or'): RevivalExpression => ({ kind, terms: [node, newLeaf()] }); + return
+ {group ? `Revival group ${path} · ${node.kind.toUpperCase()}` : `Revival condition ${path}`} + {group ? <> + + + {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 }); + }))} +
+ + {(['and', 'or'] as const).map(kind => { + const next: RevivalExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] }; + return ; + })} +
+ : <> + + + {node.kind === 'forces' ? <> + + + + change({ ...node, minimum: event.currentTarget.valueAsNumber })} /> + : node.kind === 'leader' ? <> + + + : null} + } +
+ {(['and', 'or'] as const).map(kind => )} + {remove && } +
+
; + }; + return
+ Ordinary Revival expression +

+ AND requires every condition; OR requires at least one. No makes the whole expression false, + not necessarily every condition. Legal alternatives remain available; the question does not pin a fixed branch. + Count only future ordinary native force and named-leader returns after the answer this turn. +

+

App input limits: {REVIVAL_EXPRESSION_MAX_LEAVES} conditions and {REVIVAL_EXPRESSION_MAX_DEPTH} levels of grouping. + Groups do not count as conditions. These are input limits, not additional printed rules.

+

{size.leaves} / {REVIVAL_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {REVIVAL_EXPRESSION_MAX_DEPTH} levels of grouping

+ {error &&

{error}

} + {render(value, '1', next => next)} +
; +} diff --git a/components/revival-expression-promises.tsx b/components/revival-expression-promises.tsx new file mode 100644 index 00000000..a5f248c9 --- /dev/null +++ b/components/revival-expression-promises.tsx @@ -0,0 +1,97 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { liveRevivalExpressionPromises, revivalExpressionPromiseModeSupported, revivalExpressionText } from '@/game/revival-expression-promises'; +import { Button } from './ui/button'; + +type RevivalExpressionPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void }; +const leaderName = (game: GameView, id: string) => id === 'kwisatz' ? 'Kwisatz Haderach' + : game.allLeaders.find(leader => leader.id === id)?.name ?? 'the named leader'; + +/** Preserve the authoritative whole-expression preparation or return quote. + * A guide suggests a legal next action without committing to a fixed branch. */ +export function RevivalExpressionPromiseGuideButton(props: RevivalExpressionPromiseProps) { + const game = props.game, guide = game.revivalExpressionPromiseGuide; + if (!guide) return null; + const actor = game.me, turn = game.turn, phase = guide.phase, order = game.revivalOrder; + const action = guide.action, event = action.revivalExpressionPromiseEvent, signature = JSON.stringify(action); + const canAct = () => { + const current = props.game, me = current.players.find(player => player.id === current.me); + const payment = phase === 3 && action.type === 'decision' && + current.decision?.kind === 'auctionPayment' && current.decision.player === actor; + const native = current.richeseAuction; + const nativePass = phase === 3 && action.type === 'richeseBid' && native && + native.event === action.event && !native.outcome && native.eligible.includes(actor) && + (native.method === 'silent' ? !native.submitted.includes(actor) && action.amount === 0 + : native.active === actor && action.amount === null) && action.allyPayment === 0; + const request = current.revivalRequests[actor]; + const acceptedQuote = action.type === 'acceptLeaderRevival' && !!request && !request.declined && + typeof request.price === 'number' && request.price === action.leaderRevivalPrice; + const opportunity = phase === 2 + ? !current.ready.includes(actor) && (action.type === 'charity' || action.type === 'ready') + : phase === 3 + ? payment || nativePass || action.type === 'passBid' && !native && current.auction?.active === actor + : phase === 4 && !current.ready.includes(actor) && + (action.type === 'revive' || action.type === 'reviveLeader' || action.type === 'reviveKwisatz' || acceptedQuote || action.type === 'ready') && + current.revivalOrder === order && (!current.revivalOrder || current.revivalOrder.turn === turn && + current.revivalOrder.remaining[0] === actor && current.active === actor); + return !props.busy && current === game && revivalExpressionPromiseModeSupported(current) && + current.status === 'playing' && current.phase === phase && current.me === actor && current.turn === turn && opportunity && + current.revivalExpressionPromiseGuide === guide && current.revivalExpressionPromiseGuide.phase === phase && + current.revivalExpressionPromiseGuide.action === action && JSON.stringify(action) === signature && typeof event === 'string' && + liveRevivalExpressionPromises(current.revivalExpressionPromises, actor, turn).some(promise => promise.event === event) && !!me && !me.autopilot && + !current.truthtrance && !current.response && (!current.decision || payment) && !current.phaseOpening && + !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; + }; + return ; +} + +export function RevivalExpressionPromises(props: RevivalExpressionPromiseProps) { + const own = props.game.revivalExpressionPromises.filter(promise => promise.player === props.game.me); + if (!own.length) return null; + const live = liveRevivalExpressionPromises(own, props.game.me, props.game.turn).length > 0; + const other = props.game.revivalOrder?.remaining[0]; + const waiting = live && (props.game.phase < 4 || props.game.phase === 4 && other && other !== props.game.me); + return
+

Your Revival expression promises

+
    {own.map(promise =>
  • + Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {revivalExpressionText(promise.claim, id => leaderName(props.game, id))}. + {' '}Future ordinary progress: {promise.progress.total} physical forces ({promise.progress.normal} normal, {promise.progress.elite} elite); + {' '}returned leaders: {promise.progress.leaders.length ? promise.progress.leaders.map(id => leaderName(props.game, id)).join(', ') : 'none'}. + {promise.released ? ' Released: this commitment is no longer possible.' + : promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This whole answer is binding for this turn.'} +
  • )}
+

+ AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately. + Legal alternatives remain available; no fixed branch is selected for you. + Only future actual ordinary native force and named-leader returns after the answer count. + Prior returns, requests alone, Ghola, foreign leaders and special card returns do not count. +

+

+ Physical force progress is cumulative across smaller returns; each normal or elite counter counts once. + Original costs, revival cycles, limits and turn order still apply. + A guide may accept an existing current Tleilaxu quote; it does not arrange new consent. + {' '}Revival expression rules. +

+ {waiting &&

Your Revival opportunity is still ahead; {props.game.phase < 4 + ? 'continue the current phase and preserve the resources for your whole answer.' + : 'this promise remains visible while the current actor finishes.'}

} + +
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 0efc38bf..68466e6f 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,4 +1,6 @@ 'use client'; +import { RevivalExpressionFields, revivalExpressionInputError } from './revival-expression-fields'; +import type { RevivalExpression } from '@/game/revival-expression-promises'; import { LeaderRevivalClaimFields, leaderRevivalClaimInputError } from './leader-revival-claim-fields'; import type { LeaderRevivalClaim } from '@/game/leader-revival-promises'; import { MovementClaimFields } from './movement-claim-fields'; @@ -63,7 +65,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' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' >('fact'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); @@ -71,6 +73,13 @@ export function Truthtrance({ const leaderRevivalFaction = g.players.find(player => player.id === target)?.faction; const leaderRevivalChoices = [...g.allLeaders.filter(leader => leader.faction === leaderRevivalFaction), ...(g.advanced && leaderRevivalFaction === 'atreides' ? [{ id: 'kwisatz', name: 'Kwisatz Haderach' }] : [])]; + const [revivalExpression, setRevivalExpression] = useState({ kind: 'and', terms: [ + { kind: 'forces', counter: 'total', minimum: 1 }, { kind: 'leader', leader: revivalLeader }, + ] }); + const revivalExpressionNamesValid = (node: RevivalExpression): boolean => node.kind === 'and' || node.kind === 'or' + ? node.terms.every(revivalExpressionNamesValid) : node.kind === 'forces' || + node.kind === 'leader' && leaderRevivalChoices.some(leader => leader.id === node.leader); + const validRevivalExpressionNames = revivalExpressionNamesValid(revivalExpression); const [alliancePartner, setAlliancePartner] = useState(g.me); const allianceClaim: AllianceClaim = { turn: g.turn, partner: alliancePartner }; const alliancePartners = g.players.filter(player => player.id !== target); @@ -121,6 +130,7 @@ export function Truthtrance({ } : kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' } : kind === 'movement' ? { kind, target, ...movement } + : kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression } : kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim } : kind === 'alliance' ? { kind, target, ...allianceClaim } : kind === 'bribe' ? { kind, target, ...bribeClaim } @@ -233,6 +243,13 @@ export function Truthtrance({ ))} + ) : w.question!.kind === 'revivalExpression' ? ( + <> +

Answer the whole AND/OR expression. Yes requires the complete statement to become true through actual future ordinary returns; No keeps the whole statement false. Legal alternatives remain available and no branch is fixed by this answer.

+ {(g.truthRevivalExpressionAnswers ?? []).map(answer =>
+ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'unknown'}`, { type: 'truthAnswer', answer })} +
)} + ) : w.question!.kind === 'leaderRevival' ? ( <>

This concerns a future actual ordinary return of the named leader before your Revival opportunity ends. Native cost, eligibility, cycle and order still apply. A request alone, Ghola card, special or foreign-ghola return does not fulfill it.

@@ -387,6 +404,7 @@ export function Truthtrance({ + @@ -405,7 +423,15 @@ export function Truthtrance({ )} - {kind === 'leaderRevival' ? ( + {kind === 'revivalExpression' ? ( + <> + + {!validRevivalExpressionNames &&

Choose each named leader from the respondent’s public roster.

} + {!g.truthRevivalExpressionTargets.includes(target) &&

Choose a supported participant with an ordinary Revival opportunity still ahead this turn.

} +

{truthQuestionText(question, leaderName, locationName, name)}

+ + ) : kind === 'leaderRevival' ? ( <> Truthtrance answers · Turn {g.turn}

- Supported battle-plan, Nexus-alliance, bid, force/leader revival, bribe, movement and reserve-shipment + Supported battle-plan, Nexus-alliance, bid, force/leader and grouped revival, bribe, movement and reserve-shipment answers are enforced. Current-turn freeform promises remain binding when possible, but their automatic enforcement is unfinished.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f991ea3d..31d5bc4b 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,37 @@ open mode gates. ## Current checkpoint and work +**Truthtrance / grouped native Revival — 26 October. Prototyped / Partial.** +Whole AND/OR questions now combine future physical force minima and named +personal native leader/Kwisatz returns in quiet bare native phases0–4. One +event/progress ledger and lazy private alternatives reuse the native quote, +shared purse/allowance and accepted order. No negates the whole expression; +no quoted branch is stored. Full16-leaf/depth4 editing, persistent progress, +exact phase/event guides and minimal legal AI are connected. Earlier scalar, +bid/bribe/shipment/movement obligations retain funding. Provisional future +interval/source/release meaning and all mode/publication gates remain. + +Root integration with disjoint pure/native/control lanes passes **17 files, +165/165**, types/lint/build and **eight native paths**,29 GameTable SSR renders/ +11 actual guide callbacks plus one real editor callback followed by native Ask. +Inherited H/A/T Ix47 Basic102/Advanced108-action prefixes add13/12 actions: +actual free force1/bank1 leaves T5 and partial progress; native leader2 ends T3 +and fulfills the whole AND. OR accepts the real edited tree and a manual leader +route instead of the suggested force. No AND permits either first leaf and +rejects completing both; No OR rejects either qualifying return. An83-action +post-Storm prefix adds34 through actual phases. Actual bribe3 leaves T1; owned +free income then pays leader2 and completes the AND at T0. All47 cards remain. + +Twelve pure,14 native,six controlled UI and five native funding cases cover +invalid decisive subtrees, lazy alternatives, prior/Ghola exclusion, joint +scalar claims, exact ownership and bribe/shipment budgets in either order. +Frozen v2 report/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-revival-expression-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-revival-expression-runtime/`. +No saved-room/D1/server/reset/restart/deployment operation is performed. +Other families, browser/mobile/deployed and final assurance remain due. +[Source and boundaries](TRUTHTRANCE_REVIVAL_EXPRESSIONS.md). + **Truthtrance / named ordinary leader revival — 26 October. Prototyped / Partial.** Quiet phases0–4 now support future personal native leader/Kwisatz return questions in bare native Basic/Advanced. Original identity/custody, cohort, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index c8703d4f..a7d7b83c 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,40 @@ # Dune implementation status +## 26 October 2026 — Whole AND/OR native Revival commitments + +**Prototyped / Partial.** Force minima and named personal native leader/KH +returns now compose in full grouped questions. The entire parser validates +16 leaves/depth4 before evaluation. One future-event ledger records actual +ordinary returns; lazy signed alternatives reuse native quotes and earlier +constraints. No negates the whole expression, and every real action rebuilds +choices without pinning an OR route or an AND-No reason. Shared funding, +allowance, accepted order, native deadlines, exact controls and minimal legal +AI are connected. Provisional source/interval/release composition and mode gates +remain. Special/card/foreign/prior returns do not fulfill this ordinary claim. + +Root integration with disjoint pure/native/control lanes passes **17 files, +165/165**, types/lint/build and **eight native paths**,29 GameTable SSR renders/ +11 actual guide callbacks plus one real expression-edit callback followed by +native Ask. Genuine inherited H/A/T Ix47 Basic102/Advanced108 prefixes add13/12 +actions: actual free force1/bank1 then native leader2 completes the AND at T3. +Edited OR permits manual alternate leader fulfillment; No AND accepts either +first return and blocks only completing both, while No OR blocks either. +An83-action post-Storm prefix adds34 real steps to completion. Actual bribe3 +leaves T1; force income then funds leader2 and fulfills the full AND at T0. +All47 original cards remain, with no new physical source staging or lotteries. + +Twelve pure,14 native,six controlled UI and five native funding cases retain +whole-tree validation, dynamic alternatives, prior/Ghola exclusions, two +original Truths, waiting and earlier scalar/financial/movement budgets. +Type feedback makes leader/group branches explicit in editor and identity +checks. Frozen v2 proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-revival-expression-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-revival-expression-runtime/`. +No saved-room/D1/server/reset/restart/deployment operation is performed. +Other future families, cross-opportunity expressions, optional compositions, +browser/mobile/deployed and final assurance remain required. +[Contract](TRUTHTRANCE_REVIVAL_EXPRESSIONS.md). + ## 26 October 2026 — Truthtrance named personal native leader returns **Prototyped / Partial.** Named leader/Kwisatz future-return questions now work diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 2468861c..784724d0 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Truthtrance grouped native Revival — 26 October 2026 + +Apply recorded publisher FAQp8 AND/OR/current-turn authority and supplied +Advanced physical39 general compliance to cumulative ordinary force and named +personal native leader/KH returns. Whole No negates the tree, not each leaf. +Yes fulfills on actual monotone whole-expression progress; No lasts through +the existing native deadline. Under standing14October priority, preserve all +owned legal alternatives dynamically rather than pinning a quoted OR route or +an AND-No reason. Shared purse, allowance and accepted-order release retain +earlier scalar/financial/movement obligations. Source/interval/owned solver +composition remains provisional; no dedicated publisher solver ruling is +claimed. Bare native phases0–4 and existing gates remain. +[Contract](TRUTHTRANCE_REVIVAL_EXPRESSIONS.md). + ## Truthtrance named native leader returns — 26 October 2026 Compose general current-turn Truthtrance authority (FAQp8; supplied Advanced diff --git a/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md b/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md index 8b61a219..7c7b19e9 100644 --- a/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md +++ b/docs/TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md @@ -1,5 +1,9 @@ # Truthtrance named leader revival promises +The [grouped follow-up](TRUTHTRANCE_REVIVAL_EXPRESSIONS.md) combines these named +returns and ordinary force minima with whole AND/OR, preserving dynamic choices +and the shared native planner. Scalar questions keep their existing meaning. + ## Native future-return prototype — 26 October 2026 **Prototyped / Partial.** Registered bare native Basic/Advanced tables support diff --git a/docs/TRUTHTRANCE_REVIVAL_EXPRESSIONS.md b/docs/TRUTHTRANCE_REVIVAL_EXPRESSIONS.md new file mode 100644 index 00000000..35c848b9 --- /dev/null +++ b/docs/TRUTHTRANCE_REVIVAL_EXPRESSIONS.md @@ -0,0 +1,111 @@ +# Truthtrance grouped native Revival promises + +## Whole AND/OR prototype — 26 October 2026 + +**Prototyped / Partial.** A current-turn question can combine ordinary physical +force minima and named personal native leader/Kwisatz returns in AND/OR groups. +The packet is `{kind:'revivalExpression',target,turn,claim}`. Registered bare +native Basic/Advanced tables use quiet phases0–4 and the same remaining Revival +opportunities as the [force](TRUTHTRANCE_REVIVAL_PROMISES.md) and +[leader](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md) questions. No start gate opens. + +## Authority and meaning + +The recorded [nonbattle source audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) +identifies publisher FAQp8 authority for combined AND/OR questions and current- +turn compliance. Supplied Advanced physical39 supplies general compliance, +later impossibility and unknown/save authority. Neither gives a complete online +solver. The existing cumulative ordinary-return interval, native source and +accepted-order release composition remain explicitly provisional under the +standing14October choose-recommended instruction. + +Every force leaf describes cumulative future ordinary physical returns after +this answer. Each elite counts once; normal is total minus elites. A leader +leaf means at least one actual future personal native return of that exact +identity. Existing priced negotiations may count when accepted and completed; +unaccepted quotes, requests, prior returns, Ghola/cards, special returns and +foreign gholas do not count. Native cost, custody, cohort, slots and order remain. + +AND requires every child; OR at least one. **No negates the whole expression.** +No to `(force1 AND leader)` can permit either first return while forbidding +completing both. No to `(force1 OR leader)` forbids either qualifying return. +A Yes becomes fulfilled only when actual future progress makes the complete +positive expression true. No stays live through the actual opportunity deadline: +Basic phase close, or the stamped Advanced participant's close. + +## Dynamic alternatives and controls + +`game/revival-expression-promises.ts` validates the entire tree, evaluates typed +progress, records immutable actual returns and lazily generates signed native +constraints. No invalid leaf can hide under a decisive branch. The existing +shipment software bounds of16 leaves/depth4 apply; these are input limits, not +printed restrictions on game questions. + +The engine tries compatible alternatives through its existing native quote and +shared force/leader planner. It stores the expression and observed progress, +never a selected branch. Every later action rebuilds alternatives, so a prior +guide does not force one OR route or one reason for keeping an AND false. +Several questions and earlier scalar commitments share the actual purse, +native slots and accepted order. Existing bid/bribe/shipment/movement funding +and owned preparation remain. No future foreign choice or unknown resource is +invented; completed opposing impossibility may release an impossible Yes. + +Only completed normal force and personal native leader events update this +ledger. Actual and detached quote paths use the same typed accounting. Own +spending, requests, Ghola disposal of a needed native return, or Finish cannot +erase every legal completion. Rejected choices leave the original state intact. + +The full controlled editor adds/wraps/removes/collapses groups and changes +AND/OR, force counter/minimum or public printed leader identity. Invalid numeric +drafts remain editable; the whole question must validate before Ask. Private +answers, own full expression/progress and exact event/phase guides connect to +the actual table and all four minimal policies. A persistent panel explains +whole No and remaining alternatives. AI strategy/calibration remain deferred. + +## Evidence and remaining gates + +Pure, controlled UI and inherited original battle/death runtime evidence lives +in `tests/revival-expression-promises.test.ts`, +`tests/truth-revival-expression-controls.test.tsx`, +`tests/fixture-truth-revival-expression.ts` and +`tests/truth-revival-expression-runtime.test.ts`; native funding cases are in +`tests/truth-revival-expression-funding.test.ts`. Twelve pure,14 native,six +controlled UI and five native funding cases are included in the frozen +**17-file,165/165** union. `npm run check:quick`, `npm run build` and eight +native runtime paths pass on the same unchanged source. Actual GameTable SSR +renders29 times;11 hook-free guide callbacks submit native packets. One actual +editor meaning-change callback retains the complete tree through native Ask. + +| Inherited original source | Prefix actions | Further actions | Actual result | +| --- | ---: | ---: | --- | +| Basic H/A/T Ix47 AND | 102 | 13 | Free force1/bank1 is partial; native leader2 completes AND at T3 | +| Advanced H/A/T Ix47 AND | 108 | 12 | Same actual progress under native ordered Revival | +| Basic edited OR | 102 | 12 | Real AND→OR editor callback, actual Ask, manual leader return instead of suggested force route | +| Basic No AND, forces first | 102 | 12 | Force return allowed; completing named return rejects; No settles at phase close | +| Basic No AND, leader first | 102 | 12 | Leader return allowed; completing force return rejects; No settles at phase close | +| Basic No OR | 102 | 11 | Either qualifying return rejects; actual Finish closes the No | +| Advanced post-Storm AND | 83 | 34 | Whole claim remains pending through actual Spice/Charity/Bidding and owned Revival | +| Basic owned-income AND | 102 | 14 | Actual bribe3 leaves T1; force bank1 then native leader2 fulfills AND at T0 | + +The wrapper adds no deal/lottery selections or installed clock, purse, death +or promise to the inherited genuine projectile source. Both original Truths +and all47 original cards retain physical inventory. Four minimal policies +complete the AND in both source modes. Cases also retain same-turn prior/Ghola +exclusions, nested whole No, joint scalar obligations, stale/foreign packets, +typed partial progress and bribe/shipment funding in both answer orders. +Controlled projections remain UI evidence, not original acquisition histories. +The explicit leader/group narrowing repair precedes the passing v2 proof. + +Frozen v2 report/verified manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-revival-expression-proof-v2/`. +`report.json` records exact commands/logs; native journeys, SSR HTML and harness +are in adjacent `20261026-truth-revival-expression-runtime/`. Final evidence +prose is a separately recorded three-document overlay. Editor/guide callbacks +and SSR are local engine/control evidence, not browser clicks or hydration. + +Other future-action/fact families, cross-opportunity expressions, card/special +revival, optional compositions, dedicated normal-cohort/Auditor/Duke/KH and +opposing-release journeys remain required. Local SSR/callbacks do not establish +browser/mobile/deployed acceptance. No complete mode or final assurance is +certified. No saved-game/database/server/reset/restart/deployment operation is +performed; full rules and publication gates remain open. diff --git a/docs/TRUTHTRANCE_REVIVAL_PROMISES.md b/docs/TRUTHTRANCE_REVIVAL_PROMISES.md index 02ba52da..061cb081 100644 --- a/docs/TRUTHTRANCE_REVIVAL_PROMISES.md +++ b/docs/TRUTHTRANCE_REVIVAL_PROMISES.md @@ -1,5 +1,10 @@ # Truthtrance native force-revival promises +The [grouped follow-up](TRUTHTRANCE_REVIVAL_EXPRESSIONS.md) adds whole AND/OR +questions across future ordinary force minima and named native leader returns. +It retains dynamic alternatives and the shared native purse/allowance rather +than changing this scalar question's meaning. Broader families remain due. + The [named-leader follow-up](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md) adds a separate future personal native leader/KH return question sharing the ordinary force planner's purse and native slots. Leaders still do not count toward the diff --git a/game/bots.ts b/game/bots.ts index 9ba8fc0e..b894d6d8 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1309,6 +1309,7 @@ function policyActions(g: GameView): Action[] { } if (g.bribePromiseGuide) return [g.bribePromiseGuide.action]; if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action]; + if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action]; if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action]; if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action]; if (g.bidPromiseGuide) return [g.bidPromiseGuide.action]; @@ -1318,6 +1319,10 @@ function policyActions(g: GameView): Action[] { return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }]; if (w.stage === 'answer') { if (w.question?.target !== me.id) return []; + if (w.question.kind === 'revivalExpression') { + const answer = g.truthRevivalExpressionAnswers?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'leaderRevival') { const answer = g.truthLeaderRevivalAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; diff --git a/game/engine.ts b/game/engine.ts index fecd8404..375f4c4f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,6 @@ +import { RevivalExpressionPromiseError, liveRevivalExpressionPromises, revivalExpressionBranches, + evaluateRevivalExpression, recordRevivalExpressionReturn, validateRevivalExpressionPromises, + type RevivalExpression, type RevivalExpressionClaim, type RevivalExpressionPromise, type RevivalExpressionReturn } from './revival-expression-promises'; import { LeaderRevivalPromiseError, liveLeaderRevivalPromises, leaderRevivalRequirements, leaderRevivalPromisesSatisfied, recordPromisedLeaderRevival, validateLeaderRevivalPromises, type LeaderRevivalClaim, type LeaderRevivalPromise } from './leader-revival-promises'; @@ -2147,6 +2150,7 @@ export type Game = { bidPromises?: BidPromise[]; revivalPromises?: RevivalPromise[]; leaderRevivalPromises?: LeaderRevivalPromise[]; + revivalExpressionPromises?: RevivalExpressionPromise[]; /** Shared accepted order for typed force and named leader answers. */ revivalPromiseSequence?: string[]; bribePromises?: BribePromise[]; @@ -15106,7 +15110,7 @@ function bribePromiseCompletion(g: Game, p: Player, promises = g.bribePromises ? } catch (error) { if (error instanceof RuleError) return null; throw error; } } function bribeCompatibleLaterPromises(g: Game, p: Player, - later: Pick, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'battle'>): boolean { + later: Pick, 'shipmentPromises' | 'movementPromises' | 'revivalPromises' | 'leaderRevivalPromises' | 'revivalExpressionPromises' | 'battle'>): boolean { if (!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return true; return !!bribePromiseCompletion({ ...g, ...later }, p); } @@ -15177,7 +15181,8 @@ function projectedBribes(g: Game, p: Player): BribeOptions { const shipmentPromises = liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length > 0; const movementPromises = liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer); const revivalPromises = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || - liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer); + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || + liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer); const bribePromises = liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length > 0; // Reuse the authoritative promise checks on an isolated resource-only copy. // No payment, receipt or live continuation is created by this projection. @@ -15411,6 +15416,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? [] liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length || liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length || liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length || + liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length || liveBribePromises(g.bribePromises ?? [], p.id, g.turn).length) { const trial = structuredClone(g); trial.truthtrance = null; @@ -30034,6 +30040,7 @@ function finishRevival( const group = { amount: n, elite, free: revival.free }; if (source === 'normal' && liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length) g.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(g.revivalPromises ?? [], p.id, g.turn, { amount: n, elite })); + if (source === 'normal') recordRevivalExpressionEvent(g, p.id, { kind: 'forces', amount: n, elite }); commitRevivalForcePieces(g, p, revival); finishNexusEmperorRevival(g, revival, 'returned'); if (quote.techIncome) { @@ -30055,6 +30062,8 @@ function finishRevival( liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length) g.leaderRevivalPromises = leaderRevivalPromiseRule(() => recordPromisedLeaderRevival( g.leaderRevivalPromises ?? [], p.id, g.turn, revival.kind === 'kwisatz' ? 'kwisatz' : revival.leader!)); + if (revival.kind === 'leader' || revival.kind === 'kwisatz') + recordRevivalExpressionEvent(g, p.id, { kind: 'leader', leader: revival.kind === 'kwisatz' ? 'kwisatz' : revival.leader! }); const leader = commitRevivalLeaderPieces(g, p, revival); if (revival.kind === 'kwisatz') { log(g, `${p.name} revived Kwisatz Haderach.`, { faction: p.faction, name: 'Revival' }); @@ -30243,7 +30252,17 @@ function leaderRevivalPromiseRule(calculate: () => T): T { try { return calculate(); } catch (error) { if (error instanceof LeaderRevivalPromiseError) throw new RuleError(error.message); throw error; } } +function revivalExpressionRule(calculate: () => T): T { + try { return calculate(); } + catch (error) { if (error instanceof RevivalExpressionPromiseError) throw new RuleError(error.message); throw error; } +} +function recordRevivalExpressionEvent(g: Game, player: string, event: RevivalExpressionReturn) { + if (liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player, g.turn).length) + g.revivalExpressionPromises = revivalExpressionRule(() => recordRevivalExpressionReturn( + g.revivalExpressionPromises ?? [], player, g.turn, event)); +} function revivalPromiseIntegrity(g: Game) { + revivalExpressionRule(() => validateRevivalExpressionPromises(g.revivalExpressionPromises ?? [], g.players.map(player => player.id))); leaderRevivalPromiseRule(() => validateLeaderRevivalPromises(g.leaderRevivalPromises ?? [], g.players.map(player => player.id))); revivalPromiseRule(() => validateRevivalPromises(g.revivalPromises ?? [], g.players.map(player => player.id))); } @@ -30263,6 +30282,7 @@ function stagePromisedForceReturn(g: Game, p: Player, revival: PendingRevival) { requireRule(quote.outcome === 'revive', 'This ordinary force return cannot complete.'); g.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(g.revivalPromises ?? [], p.id, g.turn, { amount: revival.amount!, elite: revival.elite ?? 0 })); + recordRevivalExpressionEvent(g, p.id, { kind: 'forces', amount: revival.amount!, elite: revival.elite ?? 0 }); p.spice -= revival.cost; commitRevivalForcePieces(g, p, revival); if (quote.freeIncome) { @@ -30291,7 +30311,7 @@ function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival) /** A final aggregate can be requested together, including Ixian free-Cyborg * selection. The native quote owns prices, typed Tanks, free quota and limits; * no future gift, card draw or foreign decision is invented. */ -function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPromise[]): Action | null { +function currentAtomicRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPromise[]): Action | null { if (g.phase !== 4) return null; if (!revivalOpportunity(g, p.id)) return null; const leaders = leaderRevivalRequirements(g.leaderRevivalPromises ?? [], p.id, g.turn); @@ -30311,7 +30331,7 @@ function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPr const quote = action.type === 'acceptLeaderRevival' ? negotiatedLeaderRevivalQuote(trial, actor) : normalLeaderRevivalQuote(trial, actor, action); stagePromisedLeaderReturn(trial, actor, quote); - if (currentRevivalPromiseCompletion(trial, actor, promises)) return action; + if (currentAtomicRevivalPromiseCompletion(trial, actor, promises)) return action; } catch (error) { if (!(error instanceof RuleError)) throw error; } } if (p.faction === 'tleilaxu' && g.revivalFreeIncome?.[p.id] !== g.turn && !revivalPrevented(g, p.id)) { @@ -30325,7 +30345,7 @@ function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPr const quote = normalForceRevivalQuote(trial, actor, action); if (quote.free <= 0) continue; stagePromisedForceReturn(trial, actor, quote); - if (currentRevivalPromiseCompletion(trial, actor, trial.revivalPromises ?? [])) return action; + if (currentAtomicRevivalPromiseCompletion(trial, actor, trial.revivalPromises ?? [])) return action; } catch (error) { if (!(error instanceof RuleError)) throw error; } } } @@ -30351,6 +30371,34 @@ function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPr } return null; } +/** Lazily try all whole-expression completions against the same native + * quotes and existing atomic promises. Rebuild on every real action so an + * earlier guide never pins an OR route or one reason for an AND No. */ +function currentRevivalPromiseCompletion(g: Game, p: Player, promises: RevivalPromise[]): Action | null { + const expressions = liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn); + if (!expressions.length) return currentAtomicRevivalPromiseCompletion(g, p, promises); + const search = (index: number, forces: RevivalPromise[], leaders: LeaderRevivalPromise[]): Action | null => { + if (index === expressions.length) { + const trial = { ...g, revivalPromises: forces, leaderRevivalPromises: leaders }; + return currentAtomicRevivalPromiseCompletion(trial, getPlayer(trial, p.id), forces); + } + const expression = expressions[index]; + for (const branch of revivalExpressionBranches(expression)) { + const nextForces = [...forces], nextLeaders = [...leaders]; + branch.forEach((leaf, ordinal) => { + const common = { turn: g.turn, event: `${expression.event}:branch:${ordinal}`, player: p.id, + asker: expression.asker, answer: leaf.answer }; + if (leaf.kind === 'forces') nextForces.push({ ...common, counter: leaf.counter, + minimum: leaf.minimum, progress: leaf.progress }); + else nextLeaders.push({ ...common, leader: leaf.leader }); + }); + const action = search(index + 1, nextForces, nextLeaders); + if (action) return action; + } + return null; + }; + return search(0, promises, g.leaderRevivalPromises ?? []); +} type RevivalPromisePlan = { action: Action; early: { phase: number; action: Action }[] }; function revivalPromisePlan(g: Game, p: Player, promises = g.revivalPromises ?? []): RevivalPromisePlan | null { if (!revivalOpportunity(g, p.id)) return null; @@ -30371,7 +30419,8 @@ function revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromis } function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick, 'shipmentPromises' | 'movementPromises'>): boolean { if (!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length && - !liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length) return true; + !liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length && + !liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length) return true; const trial = { ...g, ...later }; return !!revivalPromiseCompletion(trial, getPlayer(trial, p.id)); } @@ -30402,6 +30451,8 @@ function stagePromisedLeaderReturn(g: Game, p: Player, quote: ReturnType recordPromisedLeaderRevival( g.leaderRevivalPromises ?? [], p.id, g.turn, leader)); + if (revival.kind === 'leader' || revival.kind === 'kwisatz') + recordRevivalExpressionEvent(g, p.id, { kind: 'leader', leader }); if (quote.cycle !== null) p.revivalCycle = quote.cycle; p.spice -= revival.cost; commitRevivalLeaderPieces(g, p, revival); @@ -30429,9 +30480,40 @@ function bindLeaderRevivalTruth(g: Game, p: Player, claim: LeaderRevivalClaim, a (g.leaderRevivalPromises ??= []).push(promise); (g.revivalPromiseSequence ??= []).push(promise.event); } +function revivalExpressionIdentity(g: Game, p: Player, claim: RevivalExpression): boolean { + return claim.kind === 'and' || claim.kind === 'or' ? claim.terms.every(term => revivalExpressionIdentity(g, p, term)) : + claim.kind === 'forces' || claim.kind === 'leader' && leaderRevivalIdentity(g, p, claim.leader); +} +function revivalExpressionTruthAnswers(g: Game, p: Player, claim: RevivalExpressionClaim): ('yes' | 'no' | 'unknown')[] { + if (!revivalQuestionWindow(g) || !revivalOpportunity(g, p.id) || claim.turn !== g.turn || + !revivalExpressionIdentity(g, p, claim.claim)) return []; + return (['yes', 'no'] as const).filter(answer => { + const promises = [...(g.revivalExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id, + asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', + progress: { total: 0, normal: 0, elite: 0, leaders: [] } }]; + const trial = { ...g, revivalExpressionPromises: promises }; + if (!revivalPromiseCompletion(trial, p) || !bribeCompatibleLaterPromises(trial, p, {})) return false; + const lot = bidOpportunity(trial); + return !lot || !bidRequired(trial.bidPromises ?? [], p.id, lot) || !!bidPromiseCompletion(trial, p); + }); +} +function bindRevivalExpressionTruth(g: Game, p: Player, claim: RevivalExpressionClaim, answer: boolean, asker: string) { + requireRule(revivalExpressionTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), + 'Choose a whole revival answer compatible with native returns and earlier commitments.'); + const promise: RevivalExpressionPromise = { ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, + progress: { total: 0, normal: 0, elite: 0, leaders: [] } }; + (g.revivalExpressionPromises ??= []).push(promise); + (g.revivalPromiseSequence ??= []).push(promise.event); +} +function guardRevivalExpressionReady(g: Game, p: Player) { + requireRule(liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn) + .every(promise => evaluateRevivalExpression(promise.claim, promise.progress) === promise.answer), + 'Honor your whole Truthtrance revival answer before finishing Revival.'); +} function guardPromisedLeaderRevival(g: Game, p: Player, action: Action, quote?: ReturnType) { - if (!liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length && + if (!liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length && + !liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length && !liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length && !liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length && !liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length && @@ -30451,34 +30533,39 @@ function guardPromisedLeaderRevival(g: Game, p: Player, action: Action, /** Reconcile the shared purse in actual answer order across both ledgers. * Existing records without an order stamp precede newly accepted answers. */ function reconcileRevivalPromises(g: Game, voluntary?: { actor: string; action: Action }) { - const pending = [...(g.revivalPromises ?? []), ...(g.leaderRevivalPromises ?? [])] + type Promise = RevivalPromise | LeaderRevivalPromise | RevivalExpressionPromise; + const pending: Promise[] = [...(g.revivalPromises ?? []), ...(g.leaderRevivalPromises ?? []), ...(g.revivalExpressionPromises ?? [])] .filter(promise => !promise.fulfilled && !promise.released); if (!pending.length) return; revivalPromiseIntegrity(g); if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingRevival || g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return; - const isForce = (promise: RevivalPromise | LeaderRevivalPromise): promise is RevivalPromise => 'counter' in promise; - const same = (promise: RevivalPromise | LeaderRevivalPromise) => - g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && g.turn === promise.turn; + const isForce = (promise: Promise): promise is RevivalPromise => 'counter' in promise; + const isExpression = (promise: Promise): promise is RevivalExpressionPromise => 'claim' in promise; + const isLeader = (promise: Promise): promise is LeaderRevivalPromise => !isForce(promise) && !isExpression(promise); + const same = (promise: Promise) => g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && g.turn === promise.turn; for (const promise of pending.filter(promise => !promise.answer)) if (!same(promise) || g.revivalOrder && !g.revivalOrder.remaining.includes(promise.player)) promise.fulfilled = true; - const force: RevivalPromise[] = pending.filter(isForce).filter(promise => !promise.answer && !promise.fulfilled); - const leaders: LeaderRevivalPromise[] = pending.filter(promise => !isForce(promise) && !promise.answer && !promise.fulfilled) as LeaderRevivalPromise[]; + const force = pending.filter(isForce).filter(promise => !promise.answer && !promise.fulfilled); + const leaders = pending.filter(isLeader).filter(promise => !promise.answer && !promise.fulfilled); + const expressions = pending.filter(isExpression).filter(promise => !promise.answer && !promise.fulfilled); const sequence = g.revivalPromiseSequence ?? []; const positives = pending.filter(promise => promise.answer).sort((a,b) => sequence.indexOf(a.event) - sequence.indexOf(b.event)); for (const promise of positives) { const trial = { ...g, revivalPromises: isForce(promise) ? [...force, promise] : force, - leaderRevivalPromises: isForce(promise) ? leaders : [...leaders, promise] }; + leaderRevivalPromises: isLeader(promise) ? [...leaders, promise] : leaders, + revivalExpressionPromises: isExpression(promise) ? [...expressions, promise] : expressions }; if (!same(promise) || !revivalPromiseCompletion(trial, getPlayer(trial, promise.player))) { requireRule(voluntary?.actor !== promise.player, 'You cannot voluntarily evade your Truthtrance Revival answer.'); promise.released = true; - log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance ${isForce(promise) ? 'force' : 'named leader'}-revival answer; that answer is no longer binding.`); + log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance ${isForce(promise) ? 'force' : isExpression(promise) ? 'grouped' : 'named leader'}-revival answer; that answer is no longer binding.`); } else if (isForce(promise)) force.push(promise); - else leaders.push(promise); + else if (isLeader(promise)) leaders.push(promise); + else expressions.push(promise); } } -function projectedTypedRevivalPromiseGuide(g: Game, player: string, namedLeader: boolean): { phase: number; action: Action } | null { +function projectedTypedRevivalPromiseGuide(g: Game, player: string, kind: 'forces' | 'leader' | 'expression'): { phase: number; action: Action } | null { const ownPayment = g.phase === 3 && !g.response && !g.phaseOpening && g.decision?.kind === 'auctionPayment' && g.decision.player === player; if ((!revivalQuestionWindow(g) && !ownPayment) || g.truthtrance || !revivalOpportunity(g, player) || g.pendingRevival || g.pendingKarama || g.pendingTreacheryDiscard || @@ -30487,10 +30574,13 @@ function projectedTypedRevivalPromiseGuide(g: Game, player: string, namedLeader: // cannot evade it merely because Revival comes later in this turn. const lot = g.phase === 3 && !ownPayment && !g.richeseAuction && !g.ixAuction ? bidOpportunity(g) : null; if (lot && bidRequired(g.bidPromises ?? [], player, lot)) return null; + const grouped = liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], player, g.turn); const named = liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], player, g.turn); - const promise = namedLeader ? named[0] : !named.length ? liveRevivalPromises(g.revivalPromises ?? [], player, g.turn)[0] : null; + const promise = kind === 'expression' ? grouped[0] : grouped.length ? null : kind === 'leader' ? named[0] + : !named.length ? liveRevivalPromises(g.revivalPromises ?? [], player, g.turn)[0] : null; if (!promise) return null; - const event = namedLeader ? { leaderRevivalPromiseEvent: promise.event } : { revivalPromiseEvent: promise.event }; + const event = kind === 'expression' ? { revivalExpressionPromiseEvent: promise.event } + : kind === 'leader' ? { leaderRevivalPromiseEvent: promise.event } : { revivalPromiseEvent: promise.event }; const plan = revivalPromisePlan(g, getPlayer(g, player)); if (!plan) return null; if (g.phase === 3 && g.richeseAuction && !ownPayment) { @@ -30509,17 +30599,21 @@ function projectedTypedRevivalPromiseGuide(g: Game, player: string, namedLeader: return action ? { phase: g.phase, action: { ...action, ...event } } : null; } function projectedRevivalPromiseGuide(g: Game, player: string) { - return projectedTypedRevivalPromiseGuide(g, player, false); + return projectedTypedRevivalPromiseGuide(g, player, 'forces'); } function projectedLeaderRevivalPromiseGuide(g: Game, player: string) { - return projectedTypedRevivalPromiseGuide(g, player, true); + return projectedTypedRevivalPromiseGuide(g, player, 'leader'); +} +function projectedRevivalExpressionPromiseGuide(g: Game, player: string) { + return projectedTypedRevivalPromiseGuide(g, player, 'expression'); } function guardPromisedRevival(g: Game, p: Player, action: Action, revival?: PendingRevival) { const live = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn); if (action.revivalPromiseEvent !== undefined) requireRule(revivalOpportunity(g, p.id) && typeof action.revivalPromiseEvent === 'string' && live.some(promise => promise.event === action.revivalPromiseEvent), 'This revival commitment guide is stale.'); - if (!live.length && !liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length && + if (!live.length && !liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length && + !liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length && !liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer)) return; if (!revival) { requireRule(revivalPromisesSatisfied(g.revivalPromises ?? [], p.id, g.turn, { amount: 0, elite: 0 }), @@ -35461,6 +35555,12 @@ export function applyAction(state: Game, id: string, action: Action): Game { Object.keys(guide.action).every(key => action[key] === guide.action[key]), 'This bribe commitment guide is stale or belongs to another payment.'); } + if (action.revivalExpressionPromiseEvent !== undefined) { + const guide = projectedRevivalExpressionPromiseGuide(state, id); + requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') && + Object.keys(guide.action).every(key => action[key] === guide.action[key]), + 'This grouped revival guide is stale or belongs to another opportunity.'); + } if (action.leaderRevivalPromiseEvent !== undefined) { const guide = projectedLeaderRevivalPromiseGuide(state, id); requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') && @@ -36159,6 +36259,8 @@ function applyActionInner( bindAlliance: bindAllianceTruth, bribeAnswers: bribeTruthAnswers, bindBribe: bindBribeTruth, + revivalExpressionAnswers: revivalExpressionTruthAnswers, + bindRevivalExpression: bindRevivalExpressionTruth, leaderRevivalAnswers: leaderRevivalTruthAnswers, bindLeaderRevival: bindLeaderRevivalTruth, revivalAnswers: revivalTruthAnswers, @@ -36174,7 +36276,7 @@ function applyActionInner( ) return g; } catch (error) { - if (error instanceof TruthError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError) + if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof BribePromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError) throw new RuleError(error.message); throw error; } @@ -38720,7 +38822,8 @@ function applyActionInner( if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length || liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).some(promise => promise.answer) || liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || - liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer)) { + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) || + liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer)) { // A later responder cannot erase the owner's voluntary gift/revival // invoice. Check its allowed outcome before opening the response. const afterPayment = structuredClone(g); @@ -39751,7 +39854,8 @@ function applyActionInner( } else if (c.effect === 'ghola') { applyGholaEffect(g, p, action, c.id); if (execution === 'live' && (liveRevivalPromises(g.revivalPromises ?? [], id, g.turn).some(promise => promise.answer) || - liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], id, g.turn).some(promise => promise.answer))) { + liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], id, g.turn).some(promise => promise.answer) || + liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], id, g.turn).some(promise => promise.answer))) { // The voluntary physical return must not evade an ordinary-return Yes // by opening a later income response owned by a different actor. const trial = structuredClone(g); @@ -39798,7 +39902,7 @@ function applyActionInner( if (allianceQuestionWindow(g)) guardAlliancePromiseReady(g, p); if (g.phase === 7) requireRule(!liveBribePromises(g.bribePromises ?? [], p.id, g.turn).some(promise => promise.answer), 'Complete your Truthtrance bribe payments before finishing Collection.'); - if (g.phase === 4) { guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); } + if (g.phase === 4) { guardPromisedRevival(g, p, action); guardPromisedLeaderRevival(g, p, action); guardRevivalExpressionReady(g, p); } requireRule( [1, 2, 4, 7, 8].includes(g.phase) || (g.phase === 0 && g.stormPending !== null), @@ -40016,6 +40120,11 @@ export function viewGame(state: Game, id: string) { g.truthtrance.question.kind === 'leaderRevival' ? leaderRevivalTruthAnswers(g, me, g.truthtrance.question) : null, leaderRevivalPromises: (g.leaderRevivalPromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })), leaderRevivalPromiseGuide: projectedLeaderRevivalPromiseGuide(g, id), + truthRevivalExpressionTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [], + truthRevivalExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && + g.truthtrance.question.kind === 'revivalExpression' ? revivalExpressionTruthAnswers(g, me, g.truthtrance.question) : null, + revivalExpressionPromises: (g.revivalExpressionPromises ?? []).filter(promise => promise.player === id).map(promise => structuredClone(promise)), + revivalExpressionPromiseGuide: projectedRevivalExpressionPromiseGuide(g, id), bidPromises: projectedBidPromises(g, id), bidPromiseGuide: projectedBidPromiseGuide(g, id), truthtrance: g.truthtrance ?? null, diff --git a/game/reference.ts b/game/reference.ts index 8eadaf5f..610c1c5b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3258,6 +3258,30 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] }, ], }, + { + id: 'truthtrance-revival-expression', + title: 'Truthtrance: grouped force and leader revivals', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Ask a whole AND/OR question across future ordinary force minima and named personal native leader returns, retaining every legal alternative.', + steps: [ + 'Choose Bind combined force and leader revivals. Build AND/OR groups with ordinary total/normal/elite force minima or a named personal native leader/Kwisatz return. Registered bare native Basic/Advanced tables support quiet phases0–4 with a remaining ordinary opportunity. The input supports sixteen leaves and four grouped levels; these are software bounds, not printed question limits.', + 'Each force leaf counts cumulative actual ordinary physical returns after this answer; each elite counts once. Each leader leaf means a future actual personal native return of that printed identity. Previous returns, requests, unaccepted quotes, Ghola/cards, special returns and foreign gholas do not count. Original prices, custody, cohort, slots and order remain.', + 'Yes requires the complete positive expression to become true. AND needs every child; OR at least one. No keeps the whole expression false: No to force1 AND leader may permit either first return but forbids completing both; No to force1 OR leader forbids either qualifying return.', + 'Your answer stores the full expression and actual progress, never a fixed branch. A guide suggests one legal action; another legal OR route or AND-No reason remains available. Every action rebuilds compatible alternatives against earlier scalar and grouped answers, sharing native funding and allowance.', + 'The persistent panel displays the full expression, typed forces and named future returns. Exact current phase/event guides prepare, return or finish through native controls. Yes settles only on actual whole-expression progress; No lasts to Basic phase close or the stamped Advanced opportunity close. All four minimal profiles use the same legal path.', + 'The owned current-turn interval, source and accepted-order release policy remain provisional compositions of general Truthtrance authority. Other future families, cross-opportunity expressions, special/card revival, optional modules and complete-mode/browser/deployed acceptance remain due. No start gate opens.', + ], + related: ['card-truthtrance', 'truthtrance-revival', 'truthtrance-leader-revival', 'advanced-revival-order', 'implementation-checklist', phaseRuleId(4)], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Strict full-tree parser, cumulative physical/native progress and lazy dynamic alternatives reuse the actual native planner and shared constraints without storing a branch.', evidence: ['game/revival-expression-promises.ts', 'game/engine.ts', 'game/truthtrance.ts', 'tests/revival-expression-promises.test.ts', 'tests/truth-revival-expression-runtime.test.ts', 'tests/truth-revival-expression-funding.test.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Full controlled AND/OR editor, public preview, target-only answers and persistent whole-expression progress/exact guide.', evidence: ['components/revival-expression-fields.tsx', 'components/revival-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-revival-expression-controls.test.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Four existing minimal policies answer the whole claim and use the native guide. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-revival-expression-runtime.test.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'AND/OR authority, provisional future interval/source, dynamic alternatives and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_REVIVAL_EXPRESSIONS.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Pure whole-tree logic, controlled editor/guide and inherited original H/A/T battle/death histories; broader source families, browser/deployed and final assurance remain due.', evidence: ['tests/fixture-truth-revival-expression.ts', 'tests/truth-revival-expression-runtime.test.ts', 'tests/truth-revival-expression-controls.test.tsx', 'tests/truth-revival-expression-funding.test.ts'] }, + ], + }, { id: 'truthtrance-leader-revival', title: 'Truthtrance: named leader revival this turn', @@ -3482,7 +3506,7 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Cards', coverage: 'Partial', summary: - 'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, named native leader-revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', + 'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, named/grouped native revival, ordinary bribe-payment, current-Nexus alliance, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', steps: [ 'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.', 'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.', @@ -3496,6 +3520,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.', 'A current-Nexus alliance question binds actual final membership with a named partner at that Nexus close. Yes uses existing partnership or an actual invitation; offers alone do not form alliances. No may require a later break before Ready. Unknown can save the card until consent exists. Other players retain their original withdrawal/break rights. See the alliance-promise guide for its provisional interval and scope.', 'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.', + 'A grouped Revival question combines future ordinary force minima and named native leader/Kwisatz returns with full AND/OR. No negates the whole expression, not every leaf. Legal alternatives are rebuilt after actual returns without fixing one branch. See the grouped Revival guide for input limits, source and provisional scope.', 'A named leader-revival question binds an actual future personal native return before your Revival opportunity ends, including Kwisatz where eligible. Force/leader promises share costs and slots; prior/card/special/foreign-ghola returns do not count. See the named-leader guide for provisional scope and preparation.', 'An ordinary force-revival question binds cumulative future total, normal or elite returns in native Revival later this turn, from supported phases0–4. Earlier and special returns do not count. Yes retains the original costs and limits; No also applies across smaller requests. See the revival-promise guide for exact timing and unfinished compositions.', 'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.', @@ -3517,6 +3542,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'truthtrance-bid', 'truthtrance-revival', 'truthtrance-leader-revival', + 'truthtrance-revival-expression', 'truthtrance-bribe', 'truthtrance-alliance', 'richese-no-field', diff --git a/game/revival-expression-promises.ts b/game/revival-expression-promises.ts new file mode 100644 index 00000000..d2d772ad --- /dev/null +++ b/game/revival-expression-promises.ts @@ -0,0 +1,227 @@ +export { revivalPromiseModeSupported as revivalExpressionPromiseModeSupported } from './revival-promises'; + +export type RevivalExpressionLeaf = + | { kind: 'forces'; counter: 'total' | 'normal' | 'elite'; minimum: number } + | { kind: 'leader'; leader: string }; +export type RevivalExpression = RevivalExpressionLeaf | + { kind: 'and' | 'or'; terms: RevivalExpression[] }; +export type RevivalExpressionClaim = { turn: number; claim: RevivalExpression }; +export type RevivalExpressionProgress = { + total: number; normal: number; elite: number; leaders: string[]; +}; +export type RevivalExpressionPromise = RevivalExpressionClaim & { + event: string; player: string; asker: string; answer: boolean; + progress: RevivalExpressionProgress; + fulfilled?: boolean; released?: boolean; +}; +export type RevivalExpressionReturn = + | { kind: 'forces'; amount: number; elite: number } + | { kind: 'leader'; leader: string }; +export type RevivalExpressionBranchLeaf = + | { kind: 'forces'; counter: 'total' | 'normal' | 'elite'; minimum: number; progress: number; answer: boolean } + | { kind: 'leader'; leader: string; answer: boolean }; + +export const REVIVAL_EXPRESSION_MAX_DEPTH = 4; +export const REVIVAL_EXPRESSION_MAX_LEAVES = 16; + +export class RevivalExpressionPromiseError extends Error { + constructor(message: string) { + super(message); + this.name = 'RevivalExpressionPromiseError'; + } +} + +function check(value: unknown, message: string): asserts value { + if (!value) throw new RevivalExpressionPromiseError(message); +} +function object(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value === value.trim(); +const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean'; +const keys = (value: Record, expected: string) => Object.keys(value).sort().join(',') === expected; +const count = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 20; + +/** Software input limits shared with shipment, not a printed question limit. + * Parse every subtree before any evaluation can short-circuit its answer. */ +export function parseRevivalExpression(value: unknown): RevivalExpression { + let leaves = 0; + const parse = (input: unknown, depth: number): RevivalExpression => { + check(object(input) && depth <= REVIVAL_EXPRESSION_MAX_DEPTH, + 'Use a revival expression within four nested groups.'); + if (input.kind === 'and' || input.kind === 'or') { + check(keys(input, 'kind,terms') && Array.isArray(input.terms) && input.terms.length >= 2 && + input.terms.length <= REVIVAL_EXPRESSION_MAX_LEAVES, + 'Join two or more ordinary revival statements with AND or OR.'); + return { kind: input.kind, terms: input.terms.map(term => parse(term, depth + 1)) }; + } + check(++leaves <= REVIVAL_EXPRESSION_MAX_LEAVES, + 'Use at most sixteen ordinary revival statements.'); + if (input.kind === 'forces') { + check(keys(input, 'counter,kind,minimum') && + (input.counter === 'total' || input.counter === 'normal' || input.counter === 'elite') && + count(input.minimum) && input.minimum >= 1, + 'Choose total, normal or elite physical forces and a minimum from 1 to 20.'); + return { kind: 'forces', counter: input.counter, minimum: input.minimum }; + } + check(input.kind === 'leader' && keys(input, 'kind,leader') && identifier(input.leader), + 'Choose an exact ordinary leader identity.'); + return { kind: 'leader', leader: input.leader }; + }; + return parse(value, 0); +} + +export function parseRevivalExpressionClaim(value: unknown): RevivalExpressionClaim { + check(object(value) && keys(value, 'claim,turn') && typeof value.turn === 'number' && + Number.isSafeInteger(value.turn) && value.turn >= 1, + 'Choose the current turn and an ordinary revival expression.'); + return { turn: value.turn, claim: parseRevivalExpression(value.claim) }; +} + +export function revivalExpressionText(expression: RevivalExpression, + leaderName: (id: string) => string = id => id === 'kwisatz' ? 'Kwisatz Haderach' : id): string { + const text = (node: RevivalExpression): string => { + if ('terms' in node) + return `(${node.terms.map(text).join(node.kind === 'and' ? ' AND ' : ' OR ')})`; + if (node.kind === 'leader') return `revive ${leaderName(node.leader)} through ordinary leader revival`; + const counters = node.counter === 'total' ? 'physical forces' : `${node.counter} forces`; + return `revive at least ${node.minimum} ${counters} through ordinary force revival`; + }; + return text(parseRevivalExpression(expression)); +} + +function validateProgress(value: unknown): asserts value is RevivalExpressionProgress { + check(object(value) && keys(value, 'elite,leaders,normal,total') && count(value.total) && + count(value.normal) && count(value.elite) && value.total === value.normal + value.elite && + Array.isArray(value.leaders) && value.leaders.every(identifier) && + new Set(value.leaders).size === value.leaders.length, + 'Retain cumulative typed physical returns and unique returned leader identities.'); +} + +function evaluate(expression: RevivalExpression, progress: RevivalExpressionProgress): boolean { + if ('terms' in expression) return expression.kind === 'and' ? + expression.terms.every(term => evaluate(term, progress)) : expression.terms.some(term => evaluate(term, progress)); + return expression.kind === 'leader' ? progress.leaders.includes(expression.leader) : + progress[expression.counter] >= expression.minimum; +} + +export function evaluateRevivalExpression(expression: RevivalExpression, progress: RevivalExpressionProgress): boolean { + const parsed = parseRevivalExpression(expression); + validateProgress(progress); + return evaluate(parsed, progress); +} + +export function liveRevivalExpressionPromises(promises: readonly RevivalExpressionPromise[], player: string, + turn: number): RevivalExpressionPromise[] { + return promises.filter(promise => promise.player === player && promise.turn === turn && + !promise.fulfilled && !promise.released); +} + +function validateReturn(event: RevivalExpressionReturn): void { + check(object(event) && (event.kind === 'forces' && keys(event, 'amount,elite,kind') && count(event.amount) && + count(event.elite) && event.elite <= event.amount || + event.kind === 'leader' && keys(event, 'kind,leader') && identifier(event.leader)), + 'Retain the actual typed ordinary return event.'); +} + +/** Only completed ordinary native returns call this function. Each answer has + * its own future interval; past returns and special/card returns are not added. */ +export function recordRevivalExpressionReturn(promises: readonly RevivalExpressionPromise[], player: string, + turn: number, event: RevivalExpressionReturn): RevivalExpressionPromise[] { + validateReturn(event); + const updates = new Map(); + for (const promise of liveRevivalExpressionPromises(promises, player, turn)) { + const claim = parseRevivalExpression(promise.claim); + validateProgress(promise.progress); + check(typeof promise.answer === 'boolean', 'Retain a definite revival expression answer.'); + const old = promise.progress; + if (event.kind === 'forces' && event.amount === 0 || event.kind === 'leader' && old.leaders.includes(event.leader)) + continue; + const progress: RevivalExpressionProgress = event.kind === 'forces' ? { + total: old.total + event.amount, normal: old.normal + event.amount - event.elite, + elite: old.elite + event.elite, leaders: [...old.leaders], + } : { ...old, leaders: [...old.leaders, event.leader] }; + validateProgress(progress); + const satisfied = evaluate(claim, progress); + check(promise.answer || !satisfied, + 'This ordinary return conflicts with your whole Truthtrance revival No answer.'); + updates.set(promise, { ...promise, progress, ...(promise.answer && satisfied ? { fulfilled: true } : {}) }); + } + return promises.map(promise => updates.get(promise) ?? promise); +} + +/** Keep the strongest bound for each counter/polarity and reject contradictory + * same-counter or named-disc requirements before expanding the next subtree. */ +function mergeBranches(left: RevivalExpressionBranchLeaf[], right: RevivalExpressionBranchLeaf[]): +RevivalExpressionBranchLeaf[] | null { + const merged = left.map(leaf => ({ ...leaf })); + for (const leaf of right) { + if (leaf.kind === 'leader') { + const prior = merged.find(other => other.kind === 'leader' && other.leader === leaf.leader); + if (prior && prior.answer !== leaf.answer) return null; + if (!prior) merged.push({ ...leaf }); + continue; + } + const same = merged.find(other => other.kind === 'forces' && other.counter === leaf.counter && other.answer === leaf.answer); + if (same?.kind === 'forces') same.minimum = leaf.answer ? Math.max(same.minimum, leaf.minimum) : Math.min(same.minimum, leaf.minimum); + else merged.push({ ...leaf }); + const positive = merged.find(other => other.kind === 'forces' && other.counter === leaf.counter && other.answer); + const negative = merged.find(other => other.kind === 'forces' && other.counter === leaf.counter && !other.answer); + if (positive?.kind === 'forces' && negative?.kind === 'forces' && positive.minimum >= negative.minimum) return null; + } + return merged; +} + +/** Lazy alternatives retain original minima plus this answer's counter + * progress. Whole No uses De Morgan, never a No imposed on every AND child. + * Native allowance, prices and cross-counter feasibility remain quote-owned. */ +export function* revivalExpressionBranches(promise: RevivalExpressionPromise): Generator { + const expression = parseRevivalExpression(promise.claim); + validateProgress(promise.progress); + check(typeof promise.answer === 'boolean', 'Retain a definite revival expression answer.'); + if (promise.fulfilled || promise.released) { yield []; return; } + function* alternatives(node: RevivalExpression, answer: boolean): Generator { + if (node.kind === 'forces' || node.kind === 'leader') { + if (evaluate(node, promise.progress)) { if (answer) yield []; return; } + yield [node.kind === 'forces' ? { ...node, progress: promise.progress[node.counter], answer } : { ...node, answer }]; + return; + } + const conjunction = node.kind === 'and' ? answer : !answer; + if (!conjunction) { + for (const term of node.terms) yield* alternatives(term, answer); + return; + } + const terms = node.terms; + function* combine(index: number, prefix: RevivalExpressionBranchLeaf[]): Generator { + if (index === terms.length) { yield prefix; return; } + for (const suffix of alternatives(terms[index], answer)) { + const merged = mergeBranches(prefix, suffix); + if (merged) yield* combine(index + 1, merged); + } + } + yield* combine(0, []); + } + if (promise.answer && evaluate(expression, promise.progress)) { yield []; return; } + yield* alternatives(expression, promise.answer); +} + +export function validateRevivalExpressionPromises(promises: readonly RevivalExpressionPromise[], players: readonly string[]): void { + check(Array.isArray(promises), 'The revival expression commitment record is invalid.'); + const events = new Set(); + for (const promise of promises) { + check(object(promise), 'The revival expression commitment record is invalid.'); + const { claim } = parseRevivalExpressionClaim({ turn: promise.turn, claim: promise.claim }); + validateProgress(promise.progress); + const satisfied = evaluate(claim, promise.progress); + check(identifier(promise.event) && !events.has(promise.event) && + identifier(promise.player) && identifier(promise.asker) && players.includes(promise.player) && + players.includes(promise.asker) && promise.player !== promise.asker && typeof promise.answer === 'boolean' && + optionalBoolean(promise.fulfilled) && optionalBoolean(promise.released) && !(promise.fulfilled && promise.released) && + (promise.answer || !satisfied) && + (!promise.answer || promise.released || !!promise.fulfilled === satisfied), + 'The revival expression lost its event, participants or whole-answer progress.'); + events.add(promise.event); + } +} diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 9820343c..837b658f 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,4 @@ +import { parseRevivalExpressionClaim, revivalExpressionText, revivalExpressionPromiseModeSupported, type RevivalExpressionClaim } from './revival-expression-promises'; import { parseLeaderRevivalClaim, leaderRevivalClaimText, leaderRevivalPromiseModeSupported, type LeaderRevivalClaim } from './leader-revival-promises'; import { parseAllianceClaim, allianceClaimText, alliancePromiseModeSupported, type AllianceClaim } from './alliance-promises'; import { parseBribeClaim, bribeClaimText, bribePromiseModeSupported, type BribeClaim } from './bribe-promises'; @@ -64,6 +65,7 @@ export type TruthFact = | { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number } | { kind: 'and' | 'or'; terms: TruthFact[] }; export type TruthQuestion = + | ({ kind: 'revivalExpression'; target: string } & RevivalExpressionClaim) | ({ kind: 'leaderRevival'; target: string } & LeaderRevivalClaim) | ({ kind: 'alliance'; target: string } & AllianceClaim) | ({ kind: 'bribe'; target: string } & BribeClaim) @@ -220,6 +222,15 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { g.players.some((p) => p.id === v.target), 'Ask one other player.', ); + if (v.kind === 'revivalExpression') { + check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn', 'Supply the current turn, full revival expression and respondent.'); + const claim = parseRevivalExpressionClaim({ turn: v.turn, claim: v.claim }); + check(revivalExpressionPromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && + !g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && + !g.pendingTreacheryDiscard && claim.turn === g.turn, + 'Choose a supported current-turn revival expression in a quiet window.'); + return { kind: 'revivalExpression', target: v.target, ...claim }; + } if (v.kind === 'leaderRevival') { check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn', 'Supply the current turn, exact named leader and respondent.'); @@ -500,6 +511,7 @@ export function truthQuestionText( playerName: (id: string) => string = id => id, ): string { if (q.kind === 'freeform') return q.text; + if (q.kind === 'revivalExpression') return `Will you ${revivalExpressionText(q.claim, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} from now until your Revival opportunity ends this turn?`; if (q.kind === 'leaderRevival') return `Will you ${leaderRevivalClaimText(q, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))}?`; if (q.kind === 'alliance') return `Will you ${allianceClaimText(q, playerName)}?`; if (q.kind === 'bribe') return `Will you ${bribeClaimText(q, playerName)}?`; @@ -554,6 +566,8 @@ export function resolveTruthAction( bindAlliance?: (g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) => void; bribeAnswers?: (g: Game, p: Player, claim: BribeClaim) => TruthAnswer[]; bindBribe?: (g: Game, p: Player, claim: BribeClaim, answer: boolean, asker: string) => void; + revivalExpressionAnswers?: (g: Game, p: Player, claim: RevivalExpressionClaim) => TruthAnswer[]; + bindRevivalExpression?: (g: Game, p: Player, claim: RevivalExpressionClaim, answer: boolean, asker: string) => void; leaderRevivalAnswers?: (g: Game, p: Player, claim: LeaderRevivalClaim) => TruthAnswer[]; bindLeaderRevival?: (g: Game, p: Player, claim: LeaderRevivalClaim, answer: boolean, asker: string) => void; revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[]; @@ -678,6 +692,9 @@ export function resolveTruthAction( 'Waiting for the Truthtrance holder to ask a question.', ); w.question = parseQuestion(g, p.id, a.question); + if (w.question.kind === 'revivalExpression') + check(effects.revivalExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, + 'Choose a whole revival expression with a compatible native completion.'); if (w.question.kind === 'leaderRevival') check(effects.leaderRevivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a named leader question with a compatible native completion.'); @@ -743,6 +760,11 @@ export function resolveTruthAction( 'Choose an answer consistent with legal ground movement and earlier promises.'); if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player); } + if (q.kind === 'revivalExpression') { + check(effects.revivalExpressionAnswers && effects.bindRevivalExpression && effects.revivalExpressionAnswers(g, p, q).includes(a.answer), + 'Choose a whole revival answer compatible with native returns and earlier promises.'); + if (a.answer !== 'unknown') effects.bindRevivalExpression(g, p, q, a.answer === 'yes', current.player); + } if (q.kind === 'leaderRevival') { check(effects.leaderRevivalAnswers && effects.bindLeaderRevival && effects.leaderRevivalAnswers(g, p, q).includes(a.answer), 'Choose a named leader-revival answer compatible with native eligibility and earlier promises.'); diff --git a/tests/fixture-truth-revival-expression.ts b/tests/fixture-truth-revival-expression.ts new file mode 100644 index 00000000..d9f9f8b9 --- /dev/null +++ b/tests/fixture-truth-revival-expression.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { parseRevivalExpression, type RevivalExpression } from '../game/revival-expression-promises'; +import { + actTruthLeaderRevival, createTruthLeaderRevivalFixture, declareTruthLeaderRevival, + nextTruthLeaderRevival, truthLeaderRevivalClean, truthLeaderRevivalInventory, + truthLeaderRevivalPlayer, type TruthLeaderRevivalStep, +} from './fixture-truth-leader-revival'; + +export type TruthRevivalExpressionStep = TruthLeaderRevivalStep; +export const actTruthRevivalExpression = actTruthLeaderRevival; +export const truthRevivalExpressionClean = truthLeaderRevivalClean; +export const truthRevivalExpressionInventory = truthLeaderRevivalInventory; +export const truthRevivalExpressionPlayer = truthLeaderRevivalPlayer; + +export function nextTruthRevivalExpression(game: Game): TruthRevivalExpressionStep | null { + if (game.truthtrance) return null; + for (const player of game.players) { + const guide = viewGame(game, player.id).revivalExpressionPromiseGuide; + if (guide?.phase === game.phase) return { actor: player.id, action: guide.action }; + } + return nextTruthLeaderRevival(game); +} +export function advanceTruthRevivalExpression(game: Game, until: (state: Game) => boolean, + actions: TruthRevivalExpressionStep[] = []): Game { + for (let count = 0; count < 1600; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextTruthRevivalExpression(game); assert.ok(step, 'The original grouped Revival source needs a legal native step'); + game = actTruthRevivalExpression(game, step.actor, step.action, actions); + } + throw Error('The original grouped Revival source did not reach its finite boundary'); +} +export function settleTruthRevivalExpression(game: Game, actions: TruthRevivalExpressionStep[] = []): Game { + return advanceTruthRevivalExpression(game, truthRevivalExpressionClean, actions); +} +export function finishTruthRevivalExpression(game: Game, actions: TruthRevivalExpressionStep[] = []): Game { + const turn = game.turn; + return advanceTruthRevivalExpression(game, + state => state.turn === turn && state.phase === 5 && truthRevivalExpressionClean(state), actions); +} + +/** Thin source adapter: the complete public H/A/T Ix47 setup, original deals, + * real paid shipment/projectile death and next Storm are inherited unchanged. + * The question alone changes; no physical state or promise is installed. */ +export function createTruthRevivalExpressionFixture(options: { + advanced?: boolean; window?: 'active' | 'waiting'; ghola?: boolean; bothDead?: boolean; + claim?: RevivalExpression; +} = {}) { + const { claim: suppliedClaim, ...sourceOptions } = options; + const base = createTruthLeaderRevivalFixture(sourceOptions); + const claim = parseRevivalExpression(suppliedClaim ?? { kind: 'and', terms: [ + { kind: 'forces', counter: 'total', minimum: 1 }, { kind: 'leader', leader: base.leader }, + ] }); + const question = { kind: 'revivalExpression' as const, target: base.target, turn: base.game.turn, claim }; + return { ...base, base, claim, question, questionAction: { type: 'truthAsk', question } as Action, + postStormSourceActions: structuredClone(base.sourceActions.slice(0, base.postStormActionCount)), + phase3SourceActions: structuredClone(base.sourceActions.slice(0, base.phase3ActionCount)), + sourceDisclosure: `${base.sourceDisclosure} This adapter inherits the exact original action prefix and changes only the grouped ordinary Revival question; its progress starts after the actual answer.` }; +} +export type TruthRevivalExpressionFixture = ReturnType; +export function declareTruthRevivalExpression(f: TruthRevivalExpressionFixture, state = f.game, + options: { copies?: 1 | 2 } = {}) { + return declareTruthLeaderRevival(f.base, state, options); +} +export function askTruthRevivalExpression(game: Game, asker: string, target: string, claim: RevivalExpression, + actions: TruthRevivalExpressionStep[] = []): Game { + return actTruthRevivalExpression(game, asker, { type: 'truthAsk', question: { + kind: 'revivalExpression', target, turn: game.turn, claim: parseRevivalExpression(claim), + } }, actions); +} +export function answerTruthRevivalExpression(game: Game, target: string, answer: 'yes' | 'no', + actions: TruthRevivalExpressionStep[] = []): Game { + return actTruthRevivalExpression(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthRevivalExpression(f: TruthRevivalExpressionFixture, answer: 'yes' | 'no' = 'yes', + claim = f.claim, state = f.game) { + const declared = declareTruthRevivalExpression(f, state), actions = [...declared.actions]; + const asked = askTruthRevivalExpression(declared.game, f.asker, f.target, claim, actions); + const game = answerTruthRevivalExpression(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} diff --git a/tests/revival-expression-promises.test.ts b/tests/revival-expression-promises.test.ts new file mode 100644 index 00000000..c0a29046 --- /dev/null +++ b/tests/revival-expression-promises.test.ts @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + REVIVAL_EXPRESSION_MAX_DEPTH, + REVIVAL_EXPRESSION_MAX_LEAVES, + RevivalExpressionPromiseError, + evaluateRevivalExpression, + liveRevivalExpressionPromises, + parseRevivalExpression, + parseRevivalExpressionClaim, + recordRevivalExpressionReturn, + revivalExpressionBranches, + revivalExpressionPromiseModeSupported, + revivalExpressionText, + validateRevivalExpressionPromises, + type RevivalExpression, + type RevivalExpressionProgress, + type RevivalExpressionPromise, +} from '../game/revival-expression-promises'; +import { revivalPromiseModeSupported } from '../game/revival-promises'; + +const seats = ['respondent', 'asker', 'other-player']; +const forces = (minimum = 3, counter: 'total' | 'normal' | 'elite' = 'total'): RevivalExpression => + ({ kind: 'forces', counter, minimum }); +const leader = (id = 'leader-a'): RevivalExpression => ({ kind: 'leader', leader: id }); +const group = (kind: 'and' | 'or', ...terms: RevivalExpression[]): RevivalExpression => ({ kind, terms }); +const zero = (): RevivalExpressionProgress => ({ total: 0, normal: 0, elite: 0, leaders: [] }); +const promise = (event: string, claim: RevivalExpression = forces(), extra: Partial = {}): RevivalExpressionPromise => + ({ event, turn: 2, claim, player: 'respondent', asker: 'asker', answer: true, progress: zero(), ...extra }); + +void test('revival expressions accept all sixteen leaves and four group levels with independent canonical copies', () => { + assert.equal(REVIVAL_EXPRESSION_MAX_DEPTH, 4); assert.equal(REVIVAL_EXPRESSION_MAX_LEAVES, 16); + const sixteen = group('and', ...Array.from({ length: 16 }, (_, index) => leader(`leader-${index}`))); + const parsed = parseRevivalExpression(sixteen); + assert.deepEqual(parsed, sixteen); assert.notEqual(parsed, sixteen); + if ('terms' in parsed && 'terms' in sixteen) { + assert.notEqual(parsed.terms, sixteen.terms); assert.notEqual(parsed.terms[0], sixteen.terms[0]); + } + const nested = (depth: number): RevivalExpression => depth === 0 ? forces(1) : group('or', leader(`level-${depth}`), nested(depth - 1)); + assert.deepEqual(parseRevivalExpression(nested(4)), nested(4)); + assert.throws(() => parseRevivalExpression(nested(5)), RevivalExpressionPromiseError); + assert.throws(() => parseRevivalExpression(group('or', ...Array.from({ length: 17 }, (_, index) => leader(`leader-${index}`)))), + RevivalExpressionPromiseError); + assert.deepEqual(parseRevivalExpressionClaim({ turn: Number.MAX_SAFE_INTEGER, claim: leader('kwisatz') }), + { turn: Number.MAX_SAFE_INTEGER, claim: leader('kwisatz') }); +}); + +void test('strict parsing checks malformed decisive branches, exact fields and every threshold before evaluation', () => { + const invalid: unknown[] = [null, [], {}, { kind: 'and', terms: [] }, { kind: 'or', terms: [leader()] }, + { kind: 'and', terms: [leader(), forces()], extra: true }, { kind: 'leader', leader: ' leader-a' }, + { kind: 'leader', leader: '' }, { kind: 'leader', leader: 'leader-a', cost: 2 }, + { kind: 'forces', counter: 'total', minimum: 1, progress: 0 }, + ...[0, 21, 1.5, Number.NaN, Infinity, '3'].map(minimum => ({ kind: 'forces', counter: 'total', minimum })), + { kind: 'forces', counter: 'sardaukar', minimum: 1 }, { kind: 'not', terms: [leader(), forces()] }]; + for (const input of invalid) assert.throws(() => parseRevivalExpression(input), RevivalExpressionPromiseError); + const decisive = { kind: 'or', terms: [leader(), { kind: 'forces', counter: 'total', minimum: 0 }] }; + assert.throws(() => evaluateRevivalExpression(decisive as RevivalExpression, + { ...zero(), leaders: ['leader-a'] }), RevivalExpressionPromiseError, + 'A true first branch does not hide a malformed later branch'); + for (const input of [{ turn: 0, claim: forces() }, { turn: '2', claim: forces() }, + { turn: 2, claim: forces(), target: 'respondent' }, { turn: 2 }, { claim: forces() }]) + assert.throws(() => parseRevivalExpressionClaim(input), RevivalExpressionPromiseError); +}); + +void test('evaluation and public text keep nested Boolean meaning and count each elite as one physical force', () => { + const claim = group('and', forces(3), group('or', forces(2, 'normal'), leader('kwisatz'))); + const twoElites = { total: 3, normal: 1, elite: 2, leaders: [] }; + assert.equal(evaluateRevivalExpression(claim, twoElites), false); + assert.equal(evaluateRevivalExpression(claim, { ...twoElites, leaders: ['kwisatz'] }), true); + assert.equal(evaluateRevivalExpression(claim, { total: 3, normal: 2, elite: 1, leaders: [] }), true); + assert.equal(evaluateRevivalExpression(forces(2, 'elite'), twoElites), true); + assert.equal(evaluateRevivalExpression(forces(2, 'normal'), twoElites), false); + assert.equal(revivalExpressionText(claim), + '(revive at least 3 physical forces through ordinary force revival AND (revive at least 2 normal forces through ordinary force revival OR revive Kwisatz Haderach through ordinary leader revival))'); + assert.equal(revivalExpressionText(leader(), () => 'Named Leader'), 'revive Named Leader through ordinary leader revival'); +}); + +void test('whole Yes keeps OR alternatives after partial force progress and fulfills on either actual return route', () => { + const original = promise('choice', group('or', forces(3, 'normal'), leader())); + const initial = [...revivalExpressionBranches(original)]; + assert.deepEqual(initial, [[{ kind: 'forces', counter: 'normal', minimum: 3, progress: 0, answer: true }], + [{ kind: 'leader', leader: 'leader-a', answer: true }]]); + const partial = recordRevivalExpressionReturn([original], 'respondent', 2, { kind: 'forces', amount: 2, elite: 1 }); + assert.deepEqual(partial[0].progress, { total: 2, normal: 1, elite: 1, leaders: [] }); + assert.equal(partial[0].fulfilled, undefined); + assert.deepEqual([...revivalExpressionBranches(partial[0])], + [[{ kind: 'forces', counter: 'normal', minimum: 3, progress: 1, answer: true }], + [{ kind: 'leader', leader: 'leader-a', answer: true }]], 'Partial play never pins the force alternative'); + const byLeader = recordRevivalExpressionReturn(partial, 'respondent', 2, { kind: 'leader', leader: 'leader-a' }); + assert.equal(byLeader[0].fulfilled, true); + const byForces = recordRevivalExpressionReturn(partial, 'respondent', 2, { kind: 'forces', amount: 2, elite: 0 }); + assert.equal(byForces[0].fulfilled, true); + assert.deepEqual([...revivalExpressionBranches(byLeader[0])], [[]]); + assert.deepEqual(original.progress, zero()); +}); + +void test('whole No to AND permits one completed conjunct and only prevents completing the whole statement', () => { + const original = promise('not-both', group('and', forces(2, 'elite'), leader()), { answer: false }); + assert.deepEqual([...revivalExpressionBranches(original)], + [[{ kind: 'forces', counter: 'elite', minimum: 2, progress: 0, answer: false }], + [{ kind: 'leader', leader: 'leader-a', answer: false }]]); + const first = recordRevivalExpressionReturn([original], 'respondent', 2, { kind: 'forces', amount: 2, elite: 2 }); + assert.equal(first[0].fulfilled, undefined); + assert.deepEqual([...revivalExpressionBranches(first[0])], [[{ kind: 'leader', leader: 'leader-a', answer: false }]]); + const before = structuredClone(first); Object.freeze(first[0].progress.leaders); Object.freeze(first[0].progress); Object.freeze(first[0]); Object.freeze(first); + assert.throws(() => recordRevivalExpressionReturn(first, 'respondent', 2, { kind: 'leader', leader: 'leader-a' }), RevivalExpressionPromiseError); + assert.deepEqual(first, before); + const other = recordRevivalExpressionReturn(first, 'respondent', 2, { kind: 'leader', leader: 'leader-b' }); + assert.deepEqual(other[0].progress.leaders, ['leader-b']); assert.equal(other[0].fulfilled, undefined); +}); + +void test('whole No to OR retains every cap across split requests and rejects before another Yes can change', () => { + const no = promise('neither', group('or', forces(3), leader()), { answer: false }); + assert.deepEqual([...revivalExpressionBranches(no)], [[ + { kind: 'forces', counter: 'total', minimum: 3, progress: 0, answer: false }, + { kind: 'leader', leader: 'leader-a', answer: false }, + ]]); + const first = recordRevivalExpressionReturn([no], 'respondent', 2, { kind: 'forces', amount: 1, elite: 0 }); + const second = recordRevivalExpressionReturn(first, 'respondent', 2, { kind: 'forces', amount: 1, elite: 1 }); + const together = [promise('yes-third-force', forces(1)), second[0]], before = structuredClone(together); + assert.throws(() => recordRevivalExpressionReturn(together, 'respondent', 2, { kind: 'forces', amount: 1, elite: 0 }), RevivalExpressionPromiseError); + assert.deepEqual(together, before); + assert.throws(() => recordRevivalExpressionReturn(second, 'respondent', 2, { kind: 'leader', leader: 'leader-a' }), RevivalExpressionPromiseError); + assert.equal(second[0].progress.total, 2); assert.equal(second[0].fulfilled, undefined); +}); + +void test('lazy branch products retain sixteen-leaf choices and prune redundant same-counter bounds', () => { + const sixteen = group('and', ...Array.from({ length: 8 }, (_, index) => + group('or', leader(`left-${index}`), leader(`right-${index}`)))); + const iterator = revivalExpressionBranches(promise('sixteen', sixteen)); + assert.equal(iterator[Symbol.iterator](), iterator); + const first = iterator.next(); assert.equal(first.done, false); assert.equal(first.value?.length, 8); + const firstSnapshot = structuredClone(first.value); + const second = iterator.next(); assert.equal(second.done, false); assert.equal(second.value?.length, 8); + assert.deepEqual(first.value, firstSnapshot, 'Later products cannot mutate previously returned alternatives'); + assert.notDeepEqual(first.value, second.value); + assert.equal([...revivalExpressionBranches(promise('all', sixteen))].length, 256); + assert.deepEqual([...revivalExpressionBranches(promise('strongest-yes', group('and', forces(2), forces(5), forces(3))))], + [[{ kind: 'forces', counter: 'total', minimum: 5, progress: 0, answer: true }]]); + assert.deepEqual([...revivalExpressionBranches(promise('strongest-no', group('or', forces(2), forces(5)), { answer: false }))], + [[{ kind: 'forces', counter: 'total', minimum: 2, progress: 0, answer: false }]]); + assert.deepEqual([...revivalExpressionBranches(promise('same-disc', group('and', leader(), leader())))], + [[{ kind: 'leader', leader: 'leader-a', answer: true }]]); +}); + +void test('candidate branches simplify completed leaves without allowing a currently true whole No', () => { + const progress = { total: 3, normal: 2, elite: 1, leaders: ['leader-a'] }; + const yes = promise('already-true', group('or', forces(3), leader('leader-b')), { progress }); + assert.deepEqual([...revivalExpressionBranches(yes)], [[]]); + const no = promise('impossible-no', yes.claim, { progress, answer: false }); + assert.deepEqual([...revivalExpressionBranches(no)], []); + const and = promise('remaining-leader', group('and', forces(3), leader('leader-b')), { progress }); + assert.deepEqual([...revivalExpressionBranches(and)], [[{ kind: 'leader', leader: 'leader-b', answer: true }]]); + assert.deepEqual([...revivalExpressionBranches({ ...and, answer: false })], + [[{ kind: 'leader', leader: 'leader-b', answer: false }]]); +}); + +void test('new answer intervals, terminal entries, players and turns isolate immutable physical progress', () => { + const completed = recordRevivalExpressionReturn([promise('first', leader())], 'respondent', 2, + { kind: 'leader', leader: 'leader-a' })[0]; + const released = promise('released', forces(1), { released: true }); + const otherTurn = promise('other-turn', forces(1), { turn: 1 }); + const otherPlayer = promise('other-player', forces(1), { player: 'other-player' }); + const later = promise('later', leader()); + const records = [completed, released, otherTurn, otherPlayer, later]; + assert.deepEqual(liveRevivalExpressionPromises(records, 'respondent', 2), [later]); + assert.deepEqual([...revivalExpressionBranches(later)], [[{ kind: 'leader', leader: 'leader-a', answer: true }]], + 'An earlier physical return is excluded from a later answer'); + const returned = recordRevivalExpressionReturn(records, 'respondent', 2, { kind: 'leader', leader: 'leader-a' }); + for (let index = 0; index < 4; index++) assert.equal(returned[index], records[index]); + assert.equal(returned[4].fulfilled, true); assert.deepEqual(later.progress, zero()); + const duplicate = recordRevivalExpressionReturn([promise('same-id', forces(20), { progress: { ...zero(), leaders: ['leader-a'] } })], + 'respondent', 2, { kind: 'leader', leader: 'leader-a' }); + assert.deepEqual(duplicate[0].progress.leaders, ['leader-a']); + const absent = recordRevivalExpressionReturn(records, 'absent', 2, { kind: 'forces', amount: 1, elite: 0 }); + records.forEach((record, index) => assert.equal(absent[index], record)); +}); + +void test('physical progress validates typed totals, the twenty-counter bound, exact events and unique leader identities', () => { + const invalidProgress: unknown[] = [{ ...zero(), total: 1 }, { ...zero(), normal: -1 }, + { total: 21, normal: 20, elite: 1, leaders: [] }, { total: 1, normal: 0.5, elite: 0.5, leaders: [] }, + { ...zero(), leaders: ['leader-a', 'leader-a'] }, { ...zero(), leaders: [' leader-a'] }, + { ...zero(), extra: 1 }]; + for (const progress of invalidProgress) + assert.throws(() => evaluateRevivalExpression(forces(), progress as RevivalExpressionProgress), RevivalExpressionPromiseError); + const nearLimit = promise('twenty', forces(20, 'elite'), { progress: { total: 20, normal: 20, elite: 0, leaders: [] } }); + assert.throws(() => recordRevivalExpressionReturn([nearLimit], 'respondent', 2, + { kind: 'forces', amount: 1, elite: 1 }), RevivalExpressionPromiseError); + for (const event of [{ kind: 'forces', amount: 1, elite: 2 }, { kind: 'forces', amount: 21, elite: 0 }, + { kind: 'forces', amount: 1, elite: -1 }, { kind: 'forces', amount: 1.5, elite: 0 }, + { kind: 'leader', leader: ' leader-a' }, { kind: 'leader', leader: 'leader-a', special: true }]) + assert.throws(() => recordRevivalExpressionReturn([], 'respondent', 2, event as Parameters[3]), + RevivalExpressionPromiseError); + const original = promise('zero'); + assert.equal(recordRevivalExpressionReturn([original], 'respondent', 2, { kind: 'forces', amount: 0, elite: 0 })[0], original); +}); + +void test('ledger validation binds event participants and whole-answer terminal consistency including JSON records', () => { + const valid = [promise('live'), promise('fulfilled', forces(2), + { progress: { total: 2, normal: 1, elite: 1, leaders: [] }, fulfilled: true }), + promise('closed-no', forces(2), { answer: false, fulfilled: true }), + promise('released', leader(), { released: true })]; + const before = structuredClone(valid); + assert.doesNotThrow(() => validateRevivalExpressionPromises(valid, seats)); + assert.doesNotThrow(() => validateRevivalExpressionPromises(JSON.parse(JSON.stringify(valid)) as RevivalExpressionPromise[], seats)); + const base = promise('event'); + const invalid: unknown[] = [null, {}, [null], [base, { ...base }], + ...[{ event: '' }, { player: 'absent' }, { asker: 'absent' }, { asker: 'respondent' }, { answer: 'yes' }, + { fulfilled: 1 }, { fulfilled: true }, { fulfilled: true, released: true }, + { progress: { total: 3, normal: 3, elite: 0, leaders: [] } }, + { answer: false, progress: { total: 3, normal: 3, elite: 0, leaders: [] } }, { turn: 0 }] + .map(extra => [{ ...base, ...extra }])]; + for (const records of invalid) + assert.throws(() => validateRevivalExpressionPromises(records as RevivalExpressionPromise[], seats), RevivalExpressionPromiseError); + assert.deepEqual(valid, before); +}); + +void test('expression mode is exactly the existing native revival mode with no new module or start permission', () => { + assert.equal(revivalExpressionPromiseModeSupported, revivalPromiseModeSupported); + const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] }; + assert.equal(revivalExpressionPromiseModeSupported(classic), true); + assert.equal(revivalExpressionPromiseModeSupported({ ...classic, advanced: true }), true); + for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards', + 'discoveryEnabled', 'ecazTreachery', 'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const) + assert.equal(revivalExpressionPromiseModeSupported({ ...classic, [option]: true }), false, option); +}); diff --git a/tests/truth-revival-expression-controls.test.tsx b/tests/truth-revival-expression-controls.test.tsx new file mode 100644 index 00000000..1b86f1f6 --- /dev/null +++ b/tests/truth-revival-expression-controls.test.tsx @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { RevivalExpressionFields, revivalExpressionInputError } from '../components/revival-expression-fields'; +import { RevivalExpressionPromises, RevivalExpressionPromiseGuideButton } from '../components/revival-expression-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import { parseRevivalExpression, type RevivalExpression } from '../game/revival-expression-promises'; +import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction'; +import { createTechTokens } from '../game/tech-tokens'; + +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +const leaders = [{ id: 'named-leader', name: 'Named Commander' }, { id: 'kwisatz', name: 'Kwisatz Haderach' }]; +const force = (minimum = 1, counter: 'total' | 'normal' | 'elite' = 'total'): RevivalExpression => ({ kind: 'forces', counter, minimum }); +const named = (leader = 'named-leader'): RevivalExpression => ({ kind: 'leader', leader }); +function elements(node: ReactNode): Element[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function find(tree: ReactNode, key: string, value: string): Element { + const found = elements(tree).find(element => element.props[key] === value); assert.ok(found, `${key}=${value}`); return found; +} +const control = (tree: ReactNode, key: string) => find(tree, 'data-revival-expression-control', key); +const click = (button: Element) => (button.props.onClick as () => void)(); +function select(tree: ReactNode, path: string, field: string, value: string) { + const input = find(tree, 'id', `truth-revival-expression-${path}-${field}`); + (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); +} +function minimum(tree: ReactNode, path: string, valueAsNumber: number) { + const input = find(tree, 'id', `truth-revival-expression-${path}-minimum`); + (input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } }); +} +function editor(initial: RevivalExpression, disabled = false, choices = leaders) { + let value = initial, changes = 0; + return { + get value() { return value; }, get changes() { return changes; }, + tree: () => RevivalExpressionFields({ value, leaders: choices, disabled, onChange(next) { value = next; changes++; } }), + html: () => renderToStaticMarkup(createElement(RevivalExpressionFields, { value, leaders: choices, disabled, onChange() {} })), + }; +} +function nested(depth: number): RevivalExpression { + let value = force(); + for (let i = 0; i < depth; i++) value = { kind: 'and', terms: [value, named()] }; + return value; +} +function guide(props: Parameters[0]) { + const button = RevivalExpressionPromiseGuideButton(props); assert.ok(button); return button; +} +const press = (button: ReturnType) => button.props.onClick(); +const markup = (game: GameView) => renderToStaticMarkup(createElement(RevivalExpressionPromises, { game, busy: false, send() {} })); + +/** Controlled personalized expressions and quotes test UI callbacks only. + * They do not represent source-earned cards, casualties, payments or returns. */ +function projected(phase = 4, type = 'revive', advanced = false): GameView { + const source = createGame('TRUTHREVIVALEXPRESSIONCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced); + joinGame(source, newPlayer('a', 'Asker', 'atreides')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.turn = 2; game.phase = phase; game.ready = []; + game.allLeaders = [{ id: 'named-leader', name: 'Named Commander', faction: 'emperor', strength: 3 }]; + game.revivalExpressionPromises = [{ event: 'controlled-revival-expression-event', player: 'p', asker: 'a', turn: 2, + claim: { kind: 'or', terms: [force(3), { kind: 'and', terms: [named(), force(1, 'elite')] }] }, + answer: type !== 'ready', progress: { total: 1, normal: 1, elite: 0, leaders: [] } }]; + game.revivalOrder = advanced ? { version: 1, turn: 2, original: ['p', 'a'], remaining: ['p', 'a'], completed: [], started: false } : null; + game.active = advanced ? 'p' : null; + game.auction = phase === 3 ? { bid: 4, bidder: type === 'decision' ? 'p' : 'a', active: type === 'decision' ? 'a' : 'p', + remaining: 2, inspectionBlocked: null, card: null } : null; + game.decision = type === 'decision' ? { kind: 'auctionPayment', player: 'p' } : null; + if (type === 'acceptLeaderRevival') game.revivalRequests.p = { leader: 'named-leader', price: 3 }; + game.revivalExpressionPromiseGuide = { phase, action: { type, revivalExpressionPromiseEvent: 'controlled-revival-expression-event', + ...(type === 'revive' ? { amount: 2, elite: 1, freeElite: 0 } : {}), + ...(type === 'reviveLeader' ? { leader: 'named-leader' } : {}), + ...(type === 'acceptLeaderRevival' ? { leaderRevivalPrice: 3 } : {}), + ...(type === 'decision' ? { karama: true, card: 'projected-original-karama' } : {}) } }; + return game; +} +function nativeProjected(method: 'silent' | 'onceAround') { + const game = projected(3, 'richeseBid'); + const native = createRicheseAuction({ event: 'controlled-expression-auction-event', cardId: 'controlled-card', owner: 'a', + method, source: 'cache', eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] }); + game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null, + ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null }; + game.revivalExpressionPromiseGuide = { phase: 3, action: { type: 'richeseBid', event: native.event, + amount: method === 'silent' ? 0 : null, allyPayment: 0, revivalExpressionPromiseEvent: 'controlled-revival-expression-event' } }; + return game; +} + +void test('whole-tree callbacks author sixteen leaves, nested AND or OR, named counters and singleton collapse without truncation', () => { + const e = editor(force(2)); click(control(e.tree(), 'or-1')); + for (let count = 3; count <= 16; count++) { + click(control(e.tree(), 'add-1')); minimum(e.tree(), `1.${count}`, count); + if (count % 3 === 0) select(e.tree(), `1.${count}`, 'counter', 'elite'); + } + assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); assert.deepEqual(parseRevivalExpression(e.value), e.value); + select(e.tree(), '1.2', 'kind', 'leader'); select(e.tree(), '1.2', 'leader', 'kwisatz'); + assert.deepEqual(e.value.terms[1], named('kwisatz')); assert.match(e.html(), /16 \/ 16 conditions/); + assert.equal((e.html().match(/Revival condition type/g) ?? []).length, 16); + const before = structuredClone(e.value), changes = e.changes; + for (const key of ['add-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'or-1']) { + assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key)); + } + assert.equal(e.changes, changes); assert.deepEqual(e.value, before); + const grouped = editor({ kind: 'and', terms: [force(3), named()] }); + click(control(grouped.tree(), 'or-1.2')); select(grouped.tree(), '1.2', 'meaning', 'and'); + click(control(grouped.tree(), 'nested-or-1')); click(control(grouped.tree(), 'nested-and-1')); + assert.ok(grouped.value.kind === 'and'); assert.equal(grouped.value.terms.length, 4); + click(control(grouped.tree(), 'remove-1.2.2')); assert.deepEqual(grouped.value.terms[1], named()); + click(control(grouped.tree(), 'remove-1.4')); click(control(grouped.tree(), 'remove-1.3')); + click(control(grouped.tree(), 'remove-1.1')); assert.deepEqual(grouped.value, named()); +}); + +void test('depth-four guards and busy callbacks preserve the tree while invalid numeric drafts remain editable and parser-visible', () => { + const deep = editor(nested(4)); assert.equal(revivalExpressionInputError(deep.value), null); + assert.match(deep.html(), /4 \/ 4 levels of grouping/); + for (const key of ['and-1', 'or-1.1.1.1.1', 'nested-and-1.1.1.1']) { + assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key)); + } + assert.equal(deep.changes, 0); minimum(deep.tree(), '1.1.1.1.1', 20); assert.equal(deep.changes, 1); + for (const invalid of [NaN, -1, 0, 0.5, 21, Infinity]) { + const e = editor(force()); minimum(e.tree(), '1', invalid); + const draft = e.value; assert.ok(draft.kind === 'forces'); assert.ok(Object.is(draft.minimum, invalid)); + assert.ok(revivalExpressionInputError(e.value)); assert.match(e.html(), /role="alert"/); + click(control(e.tree(), 'or-1')); const wrapped = e.value; assert.ok(wrapped.kind === 'or'); + assert.deepEqual(wrapped.terms[0], force(invalid)); + minimum(e.tree(), '1.1', 2); assert.equal(revivalExpressionInputError(e.value), null); + } + for (const invalid of [nested(5), { kind: 'and', terms: [force()] }, { kind: 'or', terms: Array.from({ length: 17 }, () => force()) }, + { kind: 'forces', counter: 'strength', minimum: 1 }, { kind: 'leader', leader: '' }]) assert.ok(revivalExpressionInputError(invalid)); + const frozen = editor({ kind: 'and', terms: [force(), named()] }, true), before = structuredClone(frozen.value); + for (const key of ['add-1', 'nested-and-1', 'or-1', 'remove-1.2']) click(control(frozen.tree(), key)); + minimum(frozen.tree(), '1.1', 4); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'leader', 'kwisatz'); + assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before); + const empty = editor(force(), false, []); select(empty.tree(), '1', 'kind', 'leader'); assert.equal(empty.changes, 0); +}); + +void test('own panels show the entire unpinned expression, cumulative typed and named progress, whole-No meaning and waiting', () => { + const game = projected(); game.revivalExpressionPromiseGuide = null; + game.revivalExpressionPromises[0].progress = { total: 2, normal: 1, elite: 1, leaders: ['kwisatz'] }; + game.revivalExpressionPromises.push( + { ...game.revivalExpressionPromises[0], event: 'no-expression', answer: false, claim: { kind: 'and', terms: [force(3), named()] } }, + { ...game.revivalExpressionPromises[0], event: 'fulfilled-expression', fulfilled: true }, + { ...game.revivalExpressionPromises[0], event: 'released-expression', released: true }, + { ...game.revivalExpressionPromises[0], event: 'foreign-expression', player: 'a', claim: named('hidden-foreign-name') }, + ); + const before = structuredClone(game), html = markup(game); + assert.match(html, /Your Revival expression promises/); assert.match(html, / OR /); assert.match(html, / AND /); + assert.match(html, /Named Commander/); assert.match(html, /Future ordinary progress: 2 physical forces \(1 normal, 1 elite\)/); + assert.match(html, /returned leaders: Kwisatz Haderach/); + assert.match(html, /No negates the whole expression, not each condition separately/); + assert.match(html, /Legal alternatives remain available; no fixed branch is selected for you/); + assert.match(html, /Prior returns, requests alone, Ghola, foreign leaders and special card returns do not count/); + assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/); + assert.match(html, /href="\/rules#truthtrance-revival-expression"/); + assert.doesNotMatch(html, /hidden-foreign-name|controlled-revival-expression-event/); assert.deepEqual(game, before); + for (const phase of [0, 1, 2, 3]) { game.phase = phase; assert.match(markup(game), /Your Revival opportunity is still ahead/); } + game.phase = 4; game.revivalOrder = { version: 1, turn: 2, original: ['a', 'p'], remaining: ['a', 'p'], completed: [], started: false }; + assert.match(markup(game), /while the current actor finishes/); + assert.equal(RevivalExpressionPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null); +}); + +void test('actual Basic and ordered Advanced whole-expression return guides preserve every original action field', () => { + for (const advanced of [false, true]) for (const type of ['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready']) { + const game = projected(4, type, advanced), sent: Action[] = []; + const props = { game, busy: false, send(action: Action) { sent.push(action); } }; + const button = guide(props); assert.equal(button.props.disabled, false); press(button); + assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]); assert.equal(sent[0], game.revivalExpressionPromiseGuide!.action); + assert.match(markup(game), /honoring the whole expression/); press(guide({ ...props, busy: true })); assert.equal(sent.length, 1); + if (type === 'revive') assert.deepEqual(sent[0], { type: 'revive', amount: 2, elite: 1, freeElite: 0, revivalExpressionPromiseEvent: 'controlled-revival-expression-event' }); + if (type === 'acceptLeaderRevival') { + assert.equal(sent[0].leaderRevivalPrice, 3); game.revivalRequests.p.price = 4; press(button); assert.equal(sent.length, 1); + game.revivalRequests.p.price = 3; game.revivalRequests.p.declined = true; press(button); assert.equal(sent.length, 1); + } + } +}); + +void test('actual preparation guides preserve Charity, ordinary pass or payment, and owned native silent-zero or pass packets', () => { + for (const [phase, type] of [[2, 'charity'], [2, 'ready'], [3, 'passBid'], [3, 'decision']] as const) { + const game = projected(phase, type), sent: Action[] = []; + const button = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]); + if (type === 'decision') { + assert.equal(sent[0].card, 'projected-original-karama'); assert.equal(sent[0].karama, true); + assert.doesNotMatch(markup(game), /projected-original-karama/); + game.decision!.player = 'a'; press(button); assert.equal(sent.length, 1); + } else if (phase === 2) { game.ready.push('p'); press(button); assert.equal(sent.length, 1); } + else { game.auction!.active = 'a'; press(button); assert.equal(sent.length, 1); } + } + for (const method of ['silent', 'onceAround'] as const) { + const game = nativeProjected(method), sent: Action[] = []; + const button = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]); + assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/); + game.richeseAuction!.event = 'later-event'; press(button); assert.equal(sent.length, 1); + game.richeseAuction!.event = 'controlled-expression-auction-event'; + if (method === 'silent') game.richeseAuction!.submitted.push('p'); else game.richeseAuction!.active = 'a'; + press(button); assert.equal(sent.length, 1); + } +}); + +void test('stale whole-expression callbacks guard current own event, quote, phase, frame and quiet windows using no private eligibility', () => { + const changes: ((game: GameView) => void)[] = [ + game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 5; }, game => { game.status = 'setup'; }, + game => { game.ready = ['p']; }, game => { game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; }, + game => { game.revivalOrder!.turn = 1; }, game => { game.active = null; }, game => { game.revivalOrder = null; }, + game => { game.revivalOrder = { ...game.revivalOrder!, remaining: ['p', 'a'] }; }, + game => { game.revivalExpressionPromiseGuide = { phase: 4, action: { ...game.revivalExpressionPromiseGuide!.action } }; }, + game => { game.revivalExpressionPromiseGuide = null; }, game => { game.revivalExpressionPromiseGuide!.phase = 3; }, + game => { game.revivalExpressionPromiseGuide!.action.amount = 3; }, + game => { game.revivalExpressionPromises[0].event = 'expired'; }, game => { game.revivalExpressionPromises[0].player = 'a'; }, + game => { game.revivalExpressionPromises[0].turn = 1; }, game => { game.revivalExpressionPromises[0].fulfilled = true; }, + game => { game.revivalExpressionPromises[0].released = true; }, game => { game.revivalExpressionPromises = []; }, + game => { game.phaseOpening = { passed: [] }; }, game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; }, + game => { game.decision = { kind: 'auctionPayment', player: 'p' }; }, + game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; }, + game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; }, + game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; }, + game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; }, + game => { game.techTokens = createTechTokens(); }, + ]; + for (const change of changes) { + const game = projected(4, 'revive', true), sent: Action[] = []; + const button = guide({ game, busy: false, send(action) { sent.push(action); } }); change(game); press(button); assert.deepEqual(sent, []); + } + const props = { game: projected(), busy: false, send() { assert.fail('A now-busy expression guide cannot dispatch'); } }; + const button = guide(props); props.busy = true; press(button); + for (const [phase, type] of [[0, 'ready'], [1, 'ready'], [2, 'revive'], [3, 'revive'], [4, 'charity']] as const) { + const game = projected(phase, type), sent: Action[] = []; + press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []); + } + for (const type of ['revive', 'reviveLeader', 'reviveKwisatz', 'decision']) { + const game = projected(type === 'decision' ? 3 : 4, type), sent: Action[] = []; + for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'reserves', 'tanks', 'elites']) + Object.defineProperty(seat, key, { get() { throw Error(`No private or unneeded ${key} inspection`); }, configurable: true }); + if (game.auction) Object.defineProperty(game.auction, 'card', { get() { throw Error('No unrevealed lot inspection'); } }); + for (const key of ['spiceDeck', 'deck', 'discard', 'revival']) Object.defineProperty(game, key, { + get() { throw Error(`No private eligibility or ${key} inspection`); }, configurable: true, + }); + assert.match(markup(game), /Your Revival expression promises/); + press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]); + } +}); diff --git a/tests/truth-revival-expression-funding.test.ts b/tests/truth-revival-expression-funding.test.ts new file mode 100644 index 00000000..212c8d6e --- /dev/null +++ b/tests/truth-revival-expression-funding.test.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { + createTruthRevivalExpressionFixture as fixture, declareTruthRevivalExpression as declare, + askTruthRevivalExpression as ask, answerTruthRevivalExpression as answer, + bindTruthRevivalExpression as bind, actTruthRevivalExpression as act, + settleTruthRevivalExpression as settle, finishTruthRevivalExpression as finish, + truthRevivalExpressionPlayer as player, truthRevivalExpressionInventory as inventory, +} from './fixture-truth-revival-expression'; + +void test('grouped Yes retains whole progress through actual bribe and owned income preparation', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = answer(ask(game, f.asker, f.target, f.claim), f.target, 'yes'); + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'bribe', target: f.target, turn: game.turn, recipient: f.opponent, minimum: 4, + } }); + assert.deepEqual(viewGame(game, f.target).truthBribeAnswers, ['no']); + game = answer(game, f.target, 'no'); + const before = structuredClone(game); + assert.throws(() => act(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 })); + assert.deepEqual(game, before); + game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 3 }); + let guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'revive'); + game = settle(act(game, f.target, guide.action)); + assert.equal(player(game, f.target).spice, 2); + assert.equal(game.revivalExpressionPromises![0].progress.total, 1); + assert.ok(!game.revivalExpressionPromises![0].fulfilled); + guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'reviveLeader'); + game = settle(act(game, f.target, guide.action)); + assert.equal(player(game, f.target).spice, 0); + assert.equal(game.revivalExpressionPromises![0].fulfilled, true); + assert.equal(player(game, f.opponent).bribes, 3); + game = finish(game); assert.ok(!game.bribePromises![0].fulfilled); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('prior funded Bribe Yes excludes an impossible later grouped AND Yes', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'bribe', target: f.target, turn: game.turn, recipient: f.opponent, minimum: 4, + } }); + game = answer(game, f.target, 'yes'); game = ask(game, f.asker, f.target, f.claim); + assert.deepEqual(viewGame(game, f.target).truthRevivalExpressionAnswers, ['no']); + const before = structuredClone(game); + assert.throws(() => answer(game, f.target, 'yes')); assert.deepEqual(game, before); + game = answer(game, f.target, 'no'); + const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); + game = act(game, f.target, guide.action); assert.equal(player(game, f.target).spice, 0); + game = finish(game); assert.equal(game.revivalExpressionPromises![0].fulfilled, true); + assert.equal(game.bribePromises![0].fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('partial ordinary forces preserve a grouped OR until the alternate real leader return', () => { + const f = fixture(), claim = { kind: 'or' as const, terms: [ + { kind: 'forces' as const, counter: 'normal' as const, minimum: 2 }, { kind: 'leader' as const, leader: f.leader }, + ] }; + let game = bind(f, 'yes', claim).game; + game = settle(act(game, f.target, { type: 'revive', amount: 1, elite: 0 })); + assert.equal(game.revivalExpressionPromises![0].progress.normal, 1); + assert.ok(!game.revivalExpressionPromises![0].fulfilled); + const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'reviveLeader'); + game = settle(act(game, f.target, guide.action)); + assert.equal(game.revivalExpressionPromises![0].fulfilled, true); + assert.deepEqual(game.revivalExpressionPromises![0].progress.leaders, [f.leader]); + assert.deepEqual(inventory(finish(game)), f.originalCards); +}); + +void test('earlier native shipment funding excludes a later grouped AND beyond the shared purse', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'shipment', target: f.target, claim: { territory: 'arrakeen', minimum: 4 }, + } }); + game = answer(game, f.target, 'yes'); game = ask(game, f.asker, f.target, f.claim); + assert.deepEqual(viewGame(game, f.target).truthRevivalExpressionAnswers, ['no']); + const before = structuredClone(game); + assert.throws(() => answer(game, f.target, 'yes')); assert.deepEqual(game, before); + game = answer(game, f.target, 'no'); + assert.equal(game.shipmentPromises![0].answer, true); + assert.ok(!game.shipmentPromises![0].released); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('earlier grouped AND reserves its real native return budget before a later shipment answer', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = answer(ask(game, f.asker, f.target, f.claim), f.target, 'yes'); + game = act(game, f.asker, { type: 'truthAsk', question: { + kind: 'shipment', target: f.target, claim: { territory: 'arrakeen', minimum: 4 }, + } }); + assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']); + game = answer(game, f.target, 'no'); + for (let count = 0; count < 3 && !game.revivalExpressionPromises![0].fulfilled; count++) { + const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); + game = settle(act(game, f.target, guide.action)); + } + assert.equal(game.revivalExpressionPromises![0].fulfilled, true); + assert.equal(player(game, f.target).spice, 3); + assert.ok(!game.shipmentPromises![0].fulfilled); + assert.deepEqual(inventory(game), f.originalCards); +}); diff --git a/tests/truth-revival-expression-runtime.test.ts b/tests/truth-revival-expression-runtime.test.ts new file mode 100644 index 00000000..f4d1acd9 --- /dev/null +++ b/tests/truth-revival-expression-runtime.test.ts @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import type { RevivalExpression } from '../game/revival-expression-promises'; +import { + actTruthRevivalExpression as act, advanceTruthRevivalExpression as advance, + answerTruthRevivalExpression as answer, askTruthRevivalExpression as ask, + bindTruthRevivalExpression as bind, createTruthRevivalExpressionFixture as fixture, + declareTruthRevivalExpression as declare, finishTruthRevivalExpression as finish, + settleTruthRevivalExpression as settle, truthRevivalExpressionClean as clean, + truthRevivalExpressionInventory as inventory, truthRevivalExpressionPlayer as player, +} from './fixture-truth-revival-expression'; + +const force = (minimum = 1): RevivalExpression => ({ kind: 'forces', counter: 'total', minimum }); +const leader = (id: string): RevivalExpression => ({ kind: 'leader', leader: id }); +const both = (id: string): RevivalExpression => ({ kind: 'and', terms: [force(), leader(id)] }); +const either = (id: string): RevivalExpression => ({ kind: 'or', terms: [force(), leader(id)] }); +const progress0 = { total: 0, normal: 0, elite: 0, leaders: [] }; +const promise = (game: Game, target: string) => game.revivalExpressionPromises!.find(row => row.player === target)!; +function rejected(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before); +} +function complete(game: Game, target: string): Game { + for (let count = 0; count < 4 && !promise(game, target).fulfilled; count++) { + const guide = viewGame(game, target).revivalExpressionPromiseGuide; assert.ok(guide); + assert.equal(guide.phase, 4); game = settle(act(game, target, guide.action)); + } + assert.equal(promise(game, target).fulfilled, true); return game; +} + +for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} grouped Revival AND follows actual force then named leader returns`, () => { + const f = fixture({ advanced }); assert.equal(f.setup.status, 'setup'); assert.deepEqual(f.game.expansions, ['ix']); + assert.equal(f.originalCards.length, 47); assert.deepEqual(f.staging, []); + assert.equal(f.sourceCount, f.sourceActions.length); assert.equal(f.sourceActionCount, f.sourceActions.length); + assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal); + assert.equal(f.base.question.kind, 'leaderRevival'); assert.equal(f.question.kind, 'revivalExpression'); + assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 1); + assert.equal(player(f.afterBattle, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true); + assert.equal(player(f.game, f.target).tanks, 1); assert.equal(player(f.game, f.target).spice, 4); + const before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before); + assert.deepEqual(promise(bound.game, f.target).progress, progress0); + rejected(bound.game, f.target, { type: 'ready' }); + let game = act(bound.game, f.target, { type: 'revive', amount: 1, elite: 0 }); + if (game.pendingRevival) assert.deepEqual(promise(game, f.target).progress, progress0); + game = settle(game); + assert.deepEqual(promise(game, f.target).progress, { total: 1, normal: 1, elite: 0, leaders: [] }); + assert.ok(!promise(game, f.target).fulfilled); assert.equal(player(game, f.target).spice, 5); + assert.equal(player(game, f.target).freeForcesRevived, 1); assert.equal(player(game, f.target).tanks, 0); + rejected(game, f.target, { type: 'ready' }); + const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); + assert.equal(guide.phase, 4); assert.equal(guide.action.type, 'reviveLeader'); assert.equal(guide.action.leader, f.leader); + game = settle(act(game, f.target, guide.action)); + assert.deepEqual(promise(game, f.target).progress, { total: 1, normal: 1, elite: 0, leaders: [f.leader] }); + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 3); + assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.deaths, 1); + assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false); + if (advanced) assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original); + assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); + assert.deepEqual(inventory(finish(game)), f.originalCards); +}); + +void test('native grouped OR Yes accepts a manual ordinary route different from its guide', () => { + const f = fixture(), claim = either(f.leader); let game = bind(f, 'yes', claim).game; + const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); + assert.ok(guide.action.type === 'revive' || guide.action.type === 'reviveLeader'); + const manual: Action = guide.action.type === 'revive' ? f.revivalAction : { type: 'revive', amount: 1, elite: 0 }; + assert.notEqual(manual.type, guide.action.type); game = settle(act(game, f.target, manual)); + assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(promise(game, f.target).progress.total, manual.type === 'revive' ? 1 : 0); + assert.deepEqual(promise(game, f.target).progress.leaders, manual.type === 'reviveLeader' ? [f.leader] : []); + assert.deepEqual(inventory(finish(game)), f.originalCards); +}); + +for (const first of ['forces', 'leader'] as const) void test(`native grouped AND No permits ${first} first and rejects only completion of the whole expression`, () => { + const f = fixture(); let game = bind(f, 'no').game; + const forceAction: Action = { type: 'revive', amount: 1, elite: 0 }; + game = settle(act(game, f.target, first === 'forces' ? forceAction : f.revivalAction)); + assert.ok(!promise(game, f.target).fulfilled); + assert.equal(promise(game, f.target).progress.total, first === 'forces' ? 1 : 0); + assert.deepEqual(promise(game, f.target).progress.leaders, first === 'leader' ? [f.leader] : []); + rejected(game, f.target, first === 'forces' ? f.revivalAction : forceAction); + game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native grouped OR No rejects either ordinary return while permitting native phase completion', () => { + const f = fixture(); let game = bind(f, 'no', either(f.leader)).game; + rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 }); rejected(game, f.target, f.revivalAction); + assert.deepEqual(promise(game, f.target).progress, progress0); + game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(player(game, f.target).spice, 4); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native nested whole No retains its outer AND after an inner OR becomes true', () => { + const f = fixture(), claim: RevivalExpression = { kind: 'and', terms: [either(f.leader), leader(f.leader)] }; + let game = bind(f, 'no', claim).game; + game = settle(act(game, f.target, { type: 'revive', amount: 1, elite: 0 })); + assert.equal(promise(game, f.target).progress.total, 1); assert.ok(!promise(game, f.target).fulfilled); + rejected(game, f.target, f.revivalAction); + game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native prior ordinary force return is excluded from a newly answered grouped expression', () => { + const f = fixture(); let game = settle(act(f.game, f.target, { type: 'revive', amount: 1, elite: 0 })); + assert.equal(player(game, f.target).revived, 1); assert.equal(player(game, f.target).spice, 5); + game = bind(f, 'yes', either(f.leader), game).game; + assert.deepEqual(promise(game, f.target).progress, progress0); assert.ok(!promise(game, f.target).fulfilled); + game = settle(act(game, f.target, f.revivalAction)); + assert.deepEqual(promise(game, f.target).progress, { ...progress0, leaders: [f.leader] }); + assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native original Ghola return is excluded from grouped ordinary leader progress and No', () => { + const f = fixture({ ghola: true }); assert.ok(f.ghola); + let game = bind(f, 'no', either(f.leader)).game; + game = settle(act(game, f.target, { type: 'card', card: f.ghola, leader: f.leader })); + assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false); + assert.equal(player(game, f.target).leaderRevived, false); assert.equal(player(game, f.target).spice, 5); + assert.deepEqual(promise(game, f.target).progress, progress0); assert.ok(!promise(game, f.target).fulfilled); + rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 }); + game = finish(game); assert.equal(promise(game, f.target).fulfilled, true); + assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truths keep scalar force No and a dynamic grouped OR Yes jointly legal', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 1 } }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'no' }); + game = ask(game, f.asker, f.target, either(f.leader)); + assert.deepEqual(viewGame(game, f.target).truthRevivalExpressionAnswers, ['yes', 'no']); + game = answer(game, f.target, 'yes'); + const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); + assert.equal(guide.action.type, 'reviveLeader'); game = settle(act(game, f.target, guide.action)); + assert.equal(promise(game, f.target).fulfilled, true); assert.equal(game.revivalPromises![0].progress, 0); + assert.equal(player(game, f.target).tanks, 1); assert.equal(player(game, f.target).spice, 2); + game = finish(game); assert.equal(game.revivalPromises![0].fulfilled, true); + for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).length, 1); + assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('two original Truths reject a scalar No contradicting an existing grouped AND Yes', () => { + const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game; + game = answer(ask(game, f.asker, f.target, both(f.leader)), f.target, 'yes'); + game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'revival', target: f.target, turn: game.turn, counter: 'total', minimum: 1 } }); + assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['yes']); + rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); + game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = complete(game, f.target); + assert.equal(game.revivalPromises![0].fulfilled, true); assert.equal(game.revivalPromises![0].progress, 1); + assert.equal(player(game, f.target).spice, 3); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('native post-Storm grouped Yes persists through real Spice charity bidding and ordered Revival', () => { + const f = fixture({ advanced: true, window: 'waiting' }); + assert.equal(f.postStorm.phase, 1); assert.equal(f.postStorm.turn, 2); + assert.equal(f.postStormSourceActions.length, f.postStormActionCount); assert.deepEqual(f.postStorm, f.storm.after); + assert.deepEqual(f.sourceActions.slice(f.postStormActionCount, f.phase3ActionCount), f.bidding.steps); + let game = bind(f, 'yes', f.claim, f.postStorm).game; + assert.deepEqual(promise(game, f.target).progress, progress0); + game = advance(game, state => state.phase === 4 && clean(state) && state.revivalOrder?.remaining[0] === f.target); + assert.deepEqual(promise(game, f.target).progress, progress0); assert.equal(player(game, f.target).spice, 4); + game = complete(game, f.target); assert.equal(player(game, f.target).spice, 3); + assert.deepEqual(inventory(finish(game)), f.originalCards); +}); + +void test('native waiting grouped promise retains original order and rejects stale foreign question and guide packets', () => { + const f = fixture({ advanced: true, window: 'waiting' }), declared = declare(f); + rejected(declared.game, f.opponent, f.questionAction); + rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } }); + let game = ask(declared.game, f.asker, f.target, f.claim); + assert.equal(viewGame(game, f.asker).truthRevivalExpressionAnswers, null); + assert.deepEqual(viewGame(game, f.target).truthRevivalExpressionAnswers, ['yes', 'no']); + rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes'); + assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null); + game = advance(game, state => clean(state) && state.revivalOrder?.remaining[0] === f.target); + assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original); + const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); + assert.ok(guide.action.revivalExpressionPromiseEvent); + rejected(game, f.target, { ...guide.action, revivalExpressionPromiseEvent: 'stale-grouped-revival-event' }); + rejected(game, f.opponent, guide.action); + game = complete(game, f.target); rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards); +}); + +void test('all four minimal policies offer legal native grouped returns until the whole AND is fulfilled', () => { + const f = fixture(), bound = bind(f).game; + for (const difficulty of DIFFICULTIES) { + let game = structuredClone(bound); + for (let count = 0; count < 4 && !promise(game, f.target).fulfilled; count++) { + const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty; + const guide = view.revivalExpressionPromiseGuide; assert.ok(guide, difficulty); + const action = botActions(view).find(action => action.revivalExpressionPromiseEvent === guide.action.revivalExpressionPromiseEvent); + assert.ok(action, difficulty); game = settle(act(game, f.target, action)); + } + assert.equal(promise(game, f.target).fulfilled, true, difficulty); + assert.equal(player(game, f.target).spice, 3); assert.equal(player(game, f.target).tanks, 0); + assert.deepEqual(inventory(game), f.originalCards); + } +});