From 957d5a172f7d9744e6efb02ac5d43208deb10c24 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:53:27 +1100 Subject: [PATCH] Combine current facts with native Revival --- components/game-table.tsx | 2 + components/mixed-revival-fields.tsx | 91 +++++ components/mixed-revival-promises.tsx | 47 +++ components/revival-expression-promises.tsx | 6 +- components/truthtrance.tsx | 28 +- docs/CURRENT_STATUS.md | 33 ++ docs/IMPLEMENTATION_STATUS.md | 36 ++ docs/RULE_DECISIONS.md | 14 + docs/TRUTHTRANCE_MIXED_REVIVAL.md | 122 +++++++ ...RUTHTRANCE_NONBATTLE_READINESS_20260907.md | 5 + game/bots.ts | 4 + game/engine.ts | 70 +++- game/mixed-revival-question.ts | 13 + game/reference.ts | 24 ++ game/truthtrance.ts | 23 ++ tests/fixture-truth-mixed-revival.ts | 93 +++++ tests/truth-mixed-revival-controls.test.tsx | 325 ++++++++++++++++++ tests/truth-mixed-revival-funding.test.ts | 140 ++++++++ tests/truth-mixed-revival-runtime.test.ts | 315 +++++++++++++++++ 19 files changed, 1386 insertions(+), 5 deletions(-) create mode 100644 components/mixed-revival-fields.tsx create mode 100644 components/mixed-revival-promises.tsx create mode 100644 docs/TRUTHTRANCE_MIXED_REVIVAL.md create mode 100644 game/mixed-revival-question.ts create mode 100644 tests/fixture-truth-mixed-revival.ts create mode 100644 tests/truth-mixed-revival-controls.test.tsx create mode 100644 tests/truth-mixed-revival-funding.test.ts create mode 100644 tests/truth-mixed-revival-runtime.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 28ab5b7c..9d52a00c 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -30,6 +30,7 @@ import { AlliancePromises } from './alliance-promises'; import { BribePromises } from './bribe-promises'; import { BribeExpressionPromises } from './bribe-expression-promises'; import { MixedBribePromises } from './mixed-bribe-promises'; +import { MixedRevivalPromises } from './mixed-revival-promises'; import { RevivalPromises } from './revival-promises'; import { RevivalExpressionPromises } from './revival-expression-promises'; import { LeaderRevivalPromises } from './leader-revival-promises'; @@ -2047,6 +2048,7 @@ export function GameTable({ {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } + {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } diff --git a/components/mixed-revival-fields.tsx b/components/mixed-revival-fields.tsx new file mode 100644 index 00000000..42a12390 --- /dev/null +++ b/components/mixed-revival-fields.tsx @@ -0,0 +1,91 @@ +'use client'; + +import type { GameView } from '@/game/engine'; +import type { MixedActionExpression } from '@/game/mixed-action-question'; +import { parseRevivalActionClaim, type RevivalActionClaim } from '@/game/mixed-revival-question'; +import { REVIVAL_EXPRESSION_MAX_DEPTH, REVIVAL_EXPRESSION_MAX_LEAVES } from '@/game/revival-expression-promises'; +import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields'; +import { Input } from './ui/input'; + +/** Names depend only on the respondent's public faction. Current casualties, + * custody and eligibility never determine which questions can be authored. */ +const leaders = (game: GameView, target: string) => { + const faction = game.players.find(player => player.id === target)?.faction; + return [...game.allLeaders.filter(leader => !!faction && leader.faction === faction), + ...(game.advanced && faction === 'atreides' ? [{ id: 'kwisatz', name: 'Kwisatz Haderach' }] : [])]; +}; +const newForces = (): RevivalActionClaim => ({ kind: 'forces', counter: 'total', minimum: 1 }); +const config: MixedActionConfig = { + prefix: 'mixed-revival', + legend: 'Mixed current-fact / future ordinary Revival claim', + actionLabel: 'ordinary Revival condition', + actionTiming: 'Future ordinary Revival · this turn', + actionHelp: <>Count cumulative future ordinary native force and named-leader returns after the answer this turn. + Smaller force returns add together; each normal or elite physical counter counts once. + Prior returns, requests alone, Ghola, foreign leaders and special card returns do not count. + Original costs, revival cycles, limits and turn order still apply. The claim grants no extra Revival opportunity. + An unknown current fact uses the conservative future commitment that guarantees the whole answer. + If no such value is available, the answer is I don’t know., + fallbackError: 'Choose a current fact and a future ordinary Revival condition.', + maxLeaves: REVIVAL_EXPRESSION_MAX_LEAVES, + maxDepth: REVIVAL_EXPRESSION_MAX_DEPTH, + newAction: newForces, + parseAction: (value, game, target) => { + const action = parseRevivalActionClaim(value); + if (action.kind === 'leader' && !leaders(game, target).some(leader => leader.id === action.leader)) + throw new Error('Choose a public native leader for the respondent’s faction.'); + return action; + }, + renderAction: ({ value, id, game, target, disabled, onChange }) => { + const choices = leaders(game, target); + return <> + + + {value.kind === 'forces' ? <> + + + + 20} + onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} /> + : <> + + + } + ; + }, +}; + +export function mixedRevivalInputError(value: unknown, game: GameView, target: string): string | null { + return mixedActionInputError(value, game, target, config); +} +export function MixedRevivalFields(props: { + value: MixedActionExpression; + game: GameView; + target: string; + disabled: boolean; + onChange: (expression: MixedActionExpression) => void; +}) { + return MixedActionFields({ ...props, config }); +} diff --git a/components/mixed-revival-promises.tsx b/components/mixed-revival-promises.tsx new file mode 100644 index 00000000..51c70f4e --- /dev/null +++ b/components/mixed-revival-promises.tsx @@ -0,0 +1,47 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { liveRevivalExpressionPromises } from '@/game/revival-expression-promises'; +import { truthQuestionText } from '@/game/truthtrance'; +import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; +import { RevivalExpressionPromiseGuideButton } from './revival-expression-promises'; + +/** Only the original question, aggregate answer and ordinary return progress + * are projected. Never evaluate facts or inspect a compiled future branch. */ +export function MixedRevivalPromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) { + const game = props.game, own = game.mixedRevivalPromises.filter(promise => promise.player === game.me); + if (!own.length) return null; + const leaderName = (id: string) => id === 'kwisatz' ? 'Kwisatz Haderach' + : id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : game.allLeaders.find(leader => leader.id === id)?.name ?? id; + const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released); + const other = game.revivalOrder?.remaining[0]; + const waiting = live && (game.phase < 4 || game.phase === 4 && other && other !== game.me); + const sharedGuideElsewhere = liveRevivalExpressionPromises(game.revivalExpressionPromises, game.me, game.turn).length > 0; + return
+

Your mixed Revival promises

+
    {own.map(promise =>
  • + Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedRevival', target: promise.player, + turn: promise.turn, mixed: promise.mixed }, leaderName, + id => game.combatLocations.find(location => location.id === id)?.name ?? id, + id => game.players.find(player => player.id === id)?.name ?? 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(leaderName).join(', ') : 'none'}. + {promise.released ? ' Released: this whole commitment is no longer possible.' + : promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding for this turn.'} +
  • )}
+

Current facts are evaluated at the answer. They create no promise to keep cards, spice or forces afterward. + Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private. + 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.

+

Physical force progress is cumulative across smaller future ordinary returns; each normal or elite counter counts once. + Only 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. + Original costs, revival cycles, limits and turn order still apply. The claim grants no extra Revival opportunity. + A guide may accept an existing current Tleilaxu quote; it does not arrange new consent. + {' '}Mixed Revival rules.

+ {waiting &&

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

} + {live && !sharedGuideElsewhere && } +
; +} diff --git a/components/revival-expression-promises.tsx b/components/revival-expression-promises.tsx index a5f248c9..3db1be35 100644 --- a/components/revival-expression-promises.tsx +++ b/components/revival-expression-promises.tsx @@ -39,7 +39,9 @@ export function RevivalExpressionPromiseGuideButton(props: RevivalExpressionProm 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 && + (liveRevivalExpressionPromises(current.revivalExpressionPromises, actor, turn).some(promise => promise.event === event) || + (current.mixedRevivalPromises ?? []).some(promise => promise.event === event && promise.player === actor && + promise.turn === turn && !promise.fulfilled && !promise.released)) && !!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || payment) && !current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; }; @@ -92,6 +94,6 @@ export function RevivalExpressionPromises(props: RevivalExpressionPromiseProps) {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.'}

} - + {live && } ; } diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 4c5486b0..407aeed2 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,6 +1,8 @@ 'use client'; import { MixedBribeFields, mixedBribeInputError } from './mixed-bribe-fields'; +import { MixedRevivalFields, mixedRevivalInputError } from './mixed-revival-fields'; +import type { RevivalActionClaim } from '@/game/mixed-revival-question'; import type { BribePaymentClaim } from '@/game/mixed-bribe-question'; import { MixedMovementFields, mixedMovementInputError } from './mixed-movement-fields'; import type { MixedActionExpression } from '@/game/mixed-action-question'; @@ -75,7 +77,7 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); const [kind, setKind] = useState< - 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' >('fact'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); @@ -123,6 +125,11 @@ export function Truthtrance({ { kind: 'action', action: { recipient: g.me, minimum: 3 } }, ] }); const mixedBribeError = mixedBribeInputError(mixedBribe, g, target); + const [mixedRevival, setMixedRevival] = useState>({ kind: 'and', terms: [ + { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 4 } }, + { kind: 'action', action: { kind: 'forces', counter: 'total', minimum: 1 } }, + ] }); + const mixedRevivalError = mixedRevivalInputError(mixedRevival, g, target); const [shipment, setShipment] = useState({ territory: 'carthag', minimum: 6 }); const [mixed, setMixed] = useState({ kind: 'and', @@ -162,6 +169,7 @@ export function Truthtrance({ : kind === 'mixedMovement' ? { kind, target, turn: g.turn, mixed: mixedMovement } : kind === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression } : kind === 'movement' ? { kind, target, ...movement } + : kind === 'mixedRevival' ? { kind, target, turn: g.turn, mixed: mixedRevival } : kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression } : kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim } : kind === 'alliance' ? { kind, target, ...allianceClaim } @@ -277,6 +285,13 @@ export function Truthtrance({ ))} + ) : w.question!.kind === 'mixedRevival' ? ( + <> +

Answer the complete mixed question. Current facts freeze now; only future ordinary returns can fulfill any remaining Revival conditions. No keeps the whole expression false. Native eligibility, costs, limits and turn order still apply. An unknown fact requires a future commitment that guarantees your answer; otherwise choose I don’t know.

+ {(g.truthMixedRevivalAnswers ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })} +
)} + ) : 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.

@@ -470,6 +485,7 @@ export function Truthtrance({ + @@ -489,7 +505,14 @@ export function Truthtrance({ )} - {kind === 'revivalExpression' ? ( + {kind === 'mixedRevival' ? ( + <> + + {!g.truthMixedRevivalTargets.includes(target) &&

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

} +

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

+ + ) : kind === 'revivalExpression' ? ( <> @@ -674,6 +697,7 @@ export function Truthtrance({ (kind === 'mixedMovement' && (!!mixedMovementError || !g.truthMixedMovementTargets.includes(target))) || (kind === 'movementExpression' && (!!movementExpressionError || !g.truthMovementExpressionTargets.includes(target))) || (kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) || + (kind === 'mixedRevival' && (!!mixedRevivalError || !g.truthMixedRevivalTargets.includes(target))) || (kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) || (kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) || (kind === 'alliance' && (!!allianceClaimInputError(allianceClaim, alliancePartners) || !g.truthAllianceTargets.includes(target))) || diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 23476a22..f9f335a4 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,39 @@ open mode gates. ## Current checkpoint and work +**Truthtrance / current facts and ordinary Revival — 26 October. Prototyped / Partial.** +Full mixed AND/OR questions now freeze facts at the answer and bind only the +necessary native force/personal-leader obligation. Constants need no return; +unknowns retain the conservative whole-answer guarantee. Original costs, +cycles/slots/order, owned preparations/income, accepted funding and actual +Basic/Advanced deadlines remain. Own raw question/aggregate/typed progress +hide frozen fact values and compiled branches. Full shared editor, guarded +Ask/private answers, one exact live guide and four minimal policies connect. + +Root integration with disjoint controls/native lanes passes **21 files,202/202**, +types/lint/build and **nine native paths**,25 GameTable SSR renders/12 actual +guide callbacks plus one editor callback followed by native Ask. Original H/A/T +Ix47 Basic102/Advanced108 prefixes add13/12: native guide leader2 then free-force +bank1 ends T3 without changing the original T4 fact. Unit journeys retain the +manual force-first T4→T5→T3 alternative. Edited true OR has no binding; false AND +allows a real return despite the later fact change. Native unknown OR Yes must +return; AND No forbids it until actual phase close. Advanced83 post-Storm adds37 +through real phases/ordered returns. Two original Truths preserve earlier native +budgets: bribe3/freeforce bank1/leader2 ends T0; an actual personal quoted price3 +is accepted/debited/transferred once. All47 original cards remain physical. + +Twenty-three native,eight original-funding and nine controlled cases supplement +existing shared/scalar/grouped/early coverage. V1 passes quick/202 cases/build; +only its external smoke assumption of force-first guidance is corrected. V2 +reuses the identical-source passed checks and passes corrected runtime; explicit +combined provenance is in `verified-proof.json`. Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-revival-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-mixed-revival-runtime/`. +No saved-game/database/server/reset/restart/deployment operation is performed. +Other families, correlated unknowns, further preparations, wider compositions, +browser/mobile/deployed and final rules/assurance remain required. +[Authority, ordinary-return boundaries and evidence](TRUTHTRANCE_MIXED_REVIVAL.md). + **Truthtrance / current facts and ordinary payments — 26 October. Prototyped / Partial.** Full mixed AND/OR questions now freeze facts before real payer debits, then bind only the native cumulative payment obligation needed for the whole answer. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 237a8b6a..32e266d0 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,41 @@ # Dune implementation status +## 26 October 2026 — Current facts with ordinary force and named-leader Revival + +**Prototyped / Partial.** +Full mixed AND/OR questions now freeze facts at the answer and bind only the +necessary native force/personal-leader obligation. Constants need no return; +unknowns retain the conservative whole-answer guarantee. Original costs, +cycles/slots/order, owned preparations/income, accepted funding and actual +Basic/Advanced deadlines remain. Own raw question/aggregate/typed progress +hide frozen fact values and compiled branches. Full shared editor, guarded +Ask/private answers, one exact live guide and four minimal policies connect. + +Root integration with disjoint controls/native lanes passes **21 files,202/202**, +types/lint/build and **nine native paths**,25 GameTable SSR renders/12 actual +guide callbacks plus one editor callback followed by native Ask. Original H/A/T +Ix47 Basic102/Advanced108 prefixes add13/12: native guide leader2 then free-force +bank1 ends T3 without changing the original T4 fact. Unit journeys retain the +manual force-first T4→T5→T3 alternative. Edited true OR has no binding; false AND +allows a real return despite the later fact change. Native unknown OR Yes must +return; AND No forbids it until actual phase close. Advanced83 post-Storm adds37 +through real phases/ordered returns. Two original Truths preserve earlier native +budgets: bribe3/freeforce bank1/leader2 ends T0; an actual personal quoted price3 +is accepted/debited/transferred once. All47 original cards remain physical. + +Twenty-three native,eight original-funding and nine controlled cases supplement +existing shared/scalar/grouped/early coverage. V1 passes quick/202 cases/build; +only its external smoke assumption of force-first guidance is corrected. V2 +reuses the identical-source passed checks and passes corrected runtime; explicit +combined provenance is in `verified-proof.json`. Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-revival-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-mixed-revival-runtime/`. +No saved-game/database/server/reset/restart/deployment operation is performed. +Other families, correlated unknowns, further preparations, wider compositions, +browser/mobile/deployed and final rules/assurance remain required. +[Authority, ordinary-return boundaries and evidence](TRUTHTRANCE_MIXED_REVIVAL.md). + + ## 26 October 2026 — Current facts with ordinary payments and shared mixed authoring **Prototyped / Partial.** Full mixed fact/payment AND/OR questions freeze current diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 01c5d9dd..bf6211cc 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,19 @@ # Rule decision index +## Current facts combined with ordinary Revival — 26 October 2026 + +Reuse the recorded publisher FAQp8 current-turn/AND/OR and supplied Advanced +physical39 compliance/release/unknown authority, existing mixed snapshot +composition and native whole Revival interval. Facts freeze before actual +returns, owned income or payments. Unknown is false for definite Yes and true +for No; only a necessary action residual binds the existing force/personal +leader ledger. Constants need no return. Raw question/aggregate/progress omit +frozen per-fact values and compiled branches. Native costs, eligibility, slots, +order, preparations, accepted funding and actual deadlines remain. This online +composition stays provisional; no separate publisher mixed-Revival algorithm +or correlated-unknown solver is claimed. No mode/save/server/publication change. +[Contract and evidence](TRUTHTRANCE_MIXED_REVIVAL.md). + ## Current facts combined with ordinary payments — 26 October 2026 Reuse the current-fact/movement snapshot composition and recorded publisher diff --git a/docs/TRUTHTRANCE_MIXED_REVIVAL.md b/docs/TRUTHTRANCE_MIXED_REVIVAL.md new file mode 100644 index 00000000..9e4ef0ef --- /dev/null +++ b/docs/TRUTHTRANCE_MIXED_REVIVAL.md @@ -0,0 +1,122 @@ +# Truthtrance current facts and ordinary Revival + +## Mixed aggregate prototype — 26 October 2026 + +**Prototyped / Partial.** The packet is +`{kind:'mixedRevival',target,turn,mixed}`. Fact/action/AND/OR clauses reuse +`MixedActionExpression`; an action is one existing native +`{kind:'forces',counter,minimum}` or `{kind:'leader',leader}` leaf. Quiet native +Basic/Advanced phases0–4 retain [whole Revival](TRUTHTRANCE_REVIVAL_EXPRESSIONS.md) +opportunities, mode limits and original costs/eligibility/order. + +## Authority and snapshot meaning + +Reuse [mixed movement](TRUTHTRANCE_MIXED_MOVEMENT.md), the recorded publisher +November2020 FAQp8 current-turn/AND/OR authority and supplied Advanced physical39/ +printed37 compliance/release/unknown text. [Named leaders](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md) +retain personal ordinary return scope. No source specifies a complete online +solver; owned witnesses, conservative unknown composition and accepted-order +release stay provisional under standing14October priority. + +All facts freeze at the actual answer before native returns or payments. +A current-spice fact at4 stays that answer-time fact after force bank income1 +and a personal leader cost2 leave3. Later spending/custody does not create a +new fact answer. No negates the complete expression. Unknown takes false for +Yes and true for No; the remaining native return obligation must guarantee the +selected aggregate. Correlations between unknown predicates are not inferred. +If neither definite completion is known, unknown/save/change remains available. + +A true fact OR return settles Yes immediately; false fact AND return settles +No immediately. Constants create no native return binding. Otherwise the +existing whole Revival ledger owns the action residual. Smaller ordinary +force returns after the answer add by total/normal/elite physical counters. +An actual ordinary personal named leader return counts; requests alone, prior +returns, Ghola, foreign leaders and special card returns do not. Different +questions retain their own future intervals. Alternatives remain dynamic. + +## Shared implementation and controls + +`game/mixed-revival-question.ts` strictly decodes one native force/leader leaf; +outer groups use the shared mixed grammar. Every subtree is validated under +one16-leaf/depth4 budget including nested current facts. Engine identity checks +all raw leader leaves even below decisive facts. Fields use only the public +respondent faction/all-leader roster, including Advanced Atreides KH; they do +not inspect casualties, custody, hands or private affordability. + +The shared compiler eagerly reads all facts, then folds constants/identities/ +singletons. Only a remaining action tree enters `revivalExpressionPromises`; +raw internal `mixedRevivalQuestions` links its original question to the native +event. No second physical return, price, force-counter or funding solver exists. +Native cycles/slots/opportunity/order, owned Charity/auction/free-force income, +current accepted foreign price, earlier promises and release priority remain. +Basic No ends at the actual phase close; Advanced No ends at the owner's actual +ordered finish. Existing readiness behavior is inherited uniformly. + +Own projection exposes original raw question, aggregate answer, actual typed +progress and fulfilled/released status. It hides binding metadata, frozen fact +values and the residual from the public pure-expression ledger. Shared exact +event/turn/phase/price guards accept either a live pure or mixed record; history +panels remain passive. One guide belongs to the live native group obligation. +New-record discard signatures bind metadata/residual; old signatures stay. + +The full shared editor supports nested AND/OR, wrapping/switching/removal, +repairable numeric/leader drafts, unique IDs, raw preview and Ask. Private +answer choices and persistent own aggregate guidance are connected through the +table. Four minimal profiles answer and execute native guides; full strategy +and difficulty calibration remain deferred. + +## Evidence and remaining gates + +Native source: `tests/fixture-truth-mixed-revival.ts`, +`tests/truth-mixed-revival-runtime.test.ts` and +`tests/truth-mixed-revival-funding.test.ts`. +Controlled editing/guide evidence: `tests/truth-mixed-revival-controls.test.tsx`. +Twenty-three native, eight original-funding and nine controlled cases supplement +existing shared compiler/scalar/grouped/early Revival and payment coverage. The +frozen **21-file,202/202** union, types/lint, production build and nine native +paths pass on the same unchanged repository source. Actual GameTable SSR renders +25 times;12 exact guide callbacks and one editor callback submit native packets. +The four minimal profiles actually answer and complete returns on both original +Basic/Advanced sources. + +| Original source | Prefix actions | Further actions | Actual result | +| --- | ---: | ---: | --- | +| Basic frozen spice | 102 | 13 | Native guide leader2 then force bank1 ends T3; answer-time fact stays | +| Advanced frozen spice | 108 | 12 | Same native guide/typed progress under actual order | +| Basic edited true OR | 102 | 11 | Actual AND→OR editor/Ask; immediate Yes, no native binding | +| Basic false AND | 102 | 12 | Immediate No permits actual free force; later fact change has no effect | +| Basic unknown OR Yes | 102 | 12 | Unknown forecast requires an actual ordinary force return | +| Basic unknown AND No | 102 | 11 | Qualifying return stays forbidden through actual phase close | +| Advanced post-Storm | 83 | 37 | Real Spice/Charity/Bidding/preparations, then ordered native returns | +| Basic mixed before bribe | 102 | 16 | Bribe3, force bank1 and leader2 end T0 while preserving whole answer | +| Advanced priced personal leader | 108 | 9 | Actual request/price3, exact accepted quote, native payer/payee transfer | + +The native unit journeys also manually return the free force first: T4→T5→T3. +No route is fixed by a guide. Original two-Truth cases retain scalar force/leader +No, shared invoice Yes, later contradiction, prior bribe4 and shipment budgets +in either accepted order. All47 original cards remain physical. + +The first frozen v1 batch passes types/lint,202 cases and build. Its private +smoke harness incorrectly assumed force-first guidance; native guidance legally +chooses the leader first. Only that external assumption is repaired. V2 reuses +those passed checks on the identical snapshot and reruns the corrected runtime; +`verified-proof.json` records both reports explicitly. The verified source tree +is `7d66ab6af5056cd33c7f736fdcaf60cde4c62de7410b80a45346dd8a29889537` +(3170 files). Final evidence prose is a separate three-document overlay. +Proof/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-revival-proof-v2/`; +journeys/HTML: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-revival-runtime/`. +SSR/editor/guide callbacks are local engine/control evidence, not DOM, +hydration, browser/mobile or deployed acceptance. + +The inherited original H/A/T Ix47 Basic102/Advanced108 sources earn T4, one +physical casualty and a real personal early cost2 leader after actual setup, +shipment and projectile battle. The adapter changes only the raw question; +it installs no played clock/purse/hand/leader death/answer/promise/progress. +Advanced83 post-Storm and original priced revival sources retain their labels. +Controlled fixtures remain controlled evidence. + +Other action families, correlated unknowns, further owned preparations, +optional modules, wider faction/card/funding compositions and final rules/ +recovery/privacy/custody/browser/mobile/deployed acceptance remain required. +No complete mode is certified. No saved-game/database/server/reset/restart/ +deployment operation is performed by this batch. diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md index 1ae6c736..613676ba 100644 --- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md +++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md @@ -1,5 +1,10 @@ # Truthtrance nonbattle readiness and first implementation slice +The [mixed ordinary-Revival follow-up](TRUTHTRANCE_MIXED_REVIVAL.md) composes +answer-time facts with future native force/personal-leader AND/OR returns. +It supersedes that mixed-family absence only, using the existing whole Revival +ledger; broader future-action families and combinations remain unfinished. + The [named-leader follow-up](TRUTHTRANCE_LEADER_REVIVAL_PROMISES.md) adds actual personal native leader/KH return commitments alongside the separate force ledger. It supersedes that named-return absence only; broader promises remain. diff --git a/game/bots.ts b/game/bots.ts index c534246d..ba9d3bb2 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1320,6 +1320,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 === 'mixedRevival') { + const answer = g.truthMixedRevivalAnswers?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'revivalExpression') { const answer = g.truthRevivalExpressionAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; diff --git a/game/engine.ts b/game/engine.ts index 79db16f6..143dc2e1 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,4 @@ +import type { RevivalActionClaim, MixedRevivalClaim } from './mixed-revival-question'; import type { BribePaymentClaim, MixedBribeClaim } from './mixed-bribe-question'; import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question'; import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent, @@ -2159,6 +2160,10 @@ export type Game = { revivalPromises?: RevivalPromise[]; leaderRevivalPromises?: LeaderRevivalPromise[]; revivalExpressionPromises?: RevivalExpressionPromise[]; + /** Original mixed questions link to the existing ordinary-return ledger. */ + mixedRevivalQuestions?: (MixedRevivalClaim & { + event: string; player: string; asker: string; answer: boolean; binding: boolean; + })[]; /** Shared accepted order for typed force and named leader answers. */ revivalPromiseSequence?: string[]; bribePromises?: BribePromise[]; @@ -6913,6 +6918,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { battle: g.battle, shipmentPromises: g.shipmentPromises, movementPromises: g.movementPromises, + ...(next.record.question.kind === 'mixedRevival' ? { + mixedRevivalQuestions: g.mixedRevivalQuestions, revivalExpressionPromises: g.revivalExpressionPromises, + } : {}), ...(next.record.question.kind === 'mixedBribe' ? { mixedBribeQuestions: g.mixedBribeQuestions, bribeExpressionPromises: g.bribeExpressionPromises, } : {}), @@ -30746,6 +30754,60 @@ function bindRevivalExpressionTruth(g: Game, p: Player, claim: RevivalExpression (g.revivalExpressionPromises ??= []).push(promise); (g.revivalPromiseSequence ??= []).push(promise.event); } +function mixedRevivalIdentities(g: Game, p: Player, mixed: MixedRevivalClaim['mixed']): boolean { + if (mixed.kind === 'fact') return true; + if (mixed.kind === 'action') return revivalExpressionIdentity(g, p, mixed.action); + return mixed.terms.every(term => mixedRevivalIdentities(g, p, term)); +} +function compileMixedRevival(g: Game, p: Player, claim: MixedRevivalClaim, answer: boolean) { + const compiled = compileMixedActionExpression(claim.mixed, + fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer); + if (compiled.kind === 'constant') return { constant: compiled.value }; + const returns = (node: CompiledMixedAction): RevivalExpression => { + if (node.kind === 'action') return { ...node.action }; + requireRule(node.kind !== 'constant', 'Retain the complete privately compiled mixed-Revival obligation.'); + return { kind: node.kind, terms: node.terms.map(returns) }; + }; + return { residual: { turn: claim.turn, claim: returns(compiled) } }; +} +function mixedRevivalTruthAnswers(g: Game, p: Player, claim: MixedRevivalClaim): TruthAnswer[] { + if (claim.turn !== g.turn || !revivalQuestionWindow(g) || !revivalOpportunity(g, p.id) || + !mixedRevivalIdentities(g, p, claim.mixed)) return []; + const answers: TruthAnswer[] = []; + for (const answer of [true, false]) { + const compiled = compileMixedRevival(g, p, claim, answer); + if (compiled.constant !== undefined ? compiled.constant === answer : + revivalExpressionTruthAnswers(g, p, compiled.residual!).includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no'); + } + return answers.length ? answers : ['unknown']; +} +function bindMixedRevivalTruth(g: Game, p: Player, claim: MixedRevivalClaim, answer: boolean, asker: string) { + requireRule(mixedRevivalTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), + 'Choose a mixed Revival answer compatible with the whole current-fact and ordinary-return question.'); + const compiled = compileMixedRevival(g, p, claim, answer); + let event: string; + if (compiled.residual) { + bindRevivalExpressionTruth(g, p, compiled.residual, answer, asker); + event = g.revivalExpressionPromises!.at(-1)!.event; + } else { + requireRule(compiled.constant === answer, 'Retain the truthful whole mixed-Revival answer.'); + event = crypto.randomUUID(); + } + (g.mixedRevivalQuestions ??= []).push({ turn: claim.turn, mixed: structuredClone(claim.mixed), + event, player: p.id, asker, answer, binding: !!compiled.residual }); +} +function isMixedRevivalEvent(g: Game, event: string): boolean { + return !!g.mixedRevivalQuestions?.some(question => question.event === event); +} +function projectedMixedRevivalPromises(g: Game, player: string) { + return (g.mixedRevivalQuestions ?? []).filter(question => question.player === player).map(question => { + const bound = g.revivalExpressionPromises?.find(promise => promise.event === question.event); + return { event: question.event, player: question.player, asker: question.asker, turn: question.turn, + mixed: structuredClone(question.mixed), answer: question.answer, + progress: structuredClone(bound?.progress ?? { total: 0, normal: 0, elite: 0, leaders: [] }), + fulfilled: !question.binding || bound?.fulfilled === true, released: bound?.released === true }; + }); +} function guardRevivalExpressionReady(g: Game, p: Player) { requireRule(liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn) .every(promise => evaluateRevivalExpression(promise.claim, promise.progress) === promise.answer), @@ -36515,6 +36577,8 @@ function applyActionInner( bindMixedBribe: bindMixedBribeTruth, revivalExpressionAnswers: revivalExpressionTruthAnswers, bindRevivalExpression: bindRevivalExpressionTruth, + mixedRevivalAnswers: mixedRevivalTruthAnswers, + bindMixedRevival: bindMixedRevivalTruth, leaderRevivalAnswers: leaderRevivalTruthAnswers, bindLeaderRevival: bindLeaderRevivalTruth, revivalAnswers: revivalTruthAnswers, @@ -40395,7 +40459,11 @@ export function viewGame(state: Game, id: string) { 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)), + revivalExpressionPromises: (g.revivalExpressionPromises ?? []).filter(promise => promise.player === id && !isMixedRevivalEvent(g, promise.event)).map(promise => structuredClone(promise)), + truthMixedRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(p => revivalOpportunity(g, p.id)).map(p => p.id) : [], + truthMixedRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && + g.truthtrance.question.kind === 'mixedRevival' ? mixedRevivalTruthAnswers(g, me, g.truthtrance.question) : null, + mixedRevivalPromises: projectedMixedRevivalPromises(g, id), revivalExpressionPromiseGuide: projectedRevivalExpressionPromiseGuide(g, id), bidPromises: projectedBidPromises(g, id), bidPromiseGuide: projectedBidPromiseGuide(g, id), diff --git a/game/mixed-revival-question.ts b/game/mixed-revival-question.ts new file mode 100644 index 00000000..9a85ff68 --- /dev/null +++ b/game/mixed-revival-question.ts @@ -0,0 +1,13 @@ +import { parseRevivalExpression, RevivalExpressionPromiseError, type RevivalExpressionLeaf } from './revival-expression-promises'; +import type { MixedActionExpression } from './mixed-action-question'; + +export type RevivalActionClaim = RevivalExpressionLeaf; +export type MixedRevivalClaim = { turn: number; mixed: MixedActionExpression }; + +/** Only one exact native force or personal-leader leaf belongs in an action. + * Mixed groups use the outer shared grammar and budget instead. */ +export function parseRevivalActionClaim(value: unknown): RevivalActionClaim { + const action = parseRevivalExpression(value); + if (action.kind === 'forces' || action.kind === 'leader') return action; + throw new RevivalExpressionPromiseError('Supply one ordinary force or named-leader return condition.'); +} diff --git a/game/reference.ts b/game/reference.ts index d1f6fb1e..0cc17620 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/next-turn runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-mentat-bribes.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] }, ], }, + { + id: 'truthtrance-mixed-revival', + title: 'Truthtrance: current facts and ordinary Revival', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Combine answer-time facts with actual future ordinary force and personal named-leader returns using the existing Revival rules.', + steps: [ + 'Choose Combine current facts and ordinary Revival. Build full AND/OR groups with at least one current fact and one force minimum or named native leader condition. Nested current facts share the16-leaf/depth4 software limit.', + 'Current facts freeze at the actual answer. A fact that you hold at least4 spice stays that answer-time fact after actual force income and leader payment change your spice. This creates no promise to retain the same cards, spice or forces afterward.', + 'Yes makes the complete expression true; No keeps the whole expression false. A definite Yes cannot rely on an unknown fact being true, and No cannot rely on it being false. If neither guaranteed answer is currently available, the holder may save the unanswered card or change the question.', + 'A fact may already settle the aggregate, requiring no later return. Otherwise actual ordinary physical force returns add cumulatively by total, normal and elite counters; personal named-leader returns count after the answer. Prior returns, requests alone, Ghola, foreign leaders and special returns do not fulfill the claim.', + 'The original Revival system retains eligibility, costs, cycles, slots, turn order, owned preparations and earlier accepted funding. The answer grants no extra opportunity. A current accepted leader price can be paid; future foreign consent is not assumed. OR alternatives stay available.', + 'Raw preview/Ask, private answer choices, own aggregate progress and one exact shared native guide connect to four minimal policies. Basic settles at actual phase close; Advanced settles when your ordered opportunity ends. Quiet native phases0–4 retain their supported scope; other action families, correlated unknowns and broader compositions remain unfinished.', + ], + related: ['card-truthtrance', 'truthtrance-revival-expression', 'truthtrance-leader-revival', 'truthtrance-mixed-bribe', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Shared answer-time compilation binds only the required native Revival expression; own raw aggregate projection hides reduced clauses and retains native eligibility/funding/deadlines.', evidence: ['game/mixed-action-question.ts', 'game/mixed-revival-question.ts', 'game/truthtrance.ts', 'game/engine.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Full shared mixed editor, public faction leader choices, repairable drafts, raw preview/Ask, private answers and one exact live event guide are connected.', evidence: ['components/mixed-revival-fields.tsx', 'components/mixed-revival-promises.tsx', 'components/revival-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-mixed-revival-controls.test.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and use native ordinary-return guides. Strategic refinement/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-revival-runtime.test.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'Snapshot/unknown meaning, native return boundaries and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_REVIVAL.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Original native return and funding journeys plus controlled shared editing/guides; broader combinations and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-mixed-revival.ts', 'tests/truth-mixed-revival-runtime.test.ts', 'tests/truth-mixed-revival-funding.test.ts', 'tests/truth-mixed-revival-controls.test.tsx'] }, + ], + }, { id: 'truthtrance-mixed-bribe', title: 'Truthtrance: current facts and ordinary payments', diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 06d43ac0..1f9ca2b8 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,4 @@ +import { parseRevivalActionClaim, type MixedRevivalClaim } from './mixed-revival-question'; import { parseBribePaymentClaim, type MixedBribeClaim } from './mixed-bribe-question'; import { parseMixedActionExpression, mixedActionExpressionText, type MixedActionExpression } from './mixed-action-question'; import { parseMovementExpressionClaim, movementExpressionText, type MovementExpressionClaim } from './movement-expression-promises'; @@ -71,6 +72,7 @@ export type TruthFact = | { kind: 'and' | 'or'; terms: TruthFact[] }; export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression }; export type TruthQuestion = + | ({ kind: 'mixedRevival'; target: string } & MixedRevivalClaim) | ({ kind: 'mixedBribe'; target: string } & MixedBribeClaim) | ({ kind: 'mixedMovement'; target: string } & MixedMovementClaim) | ({ kind: 'movementExpression'; target: string } & MovementExpressionClaim) @@ -232,6 +234,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 === 'mixedRevival') { + check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn && + revivalExpressionPromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 4 && + !g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard, + 'Supply the current turn, complete mixed Revival question and a quiet supported native window.'); + const respondent = g.players.find(player => player.id === v.target)!; + return { kind: 'mixedRevival', target: v.target as string, turn: g.turn, + mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseRevivalActionClaim) }; + } 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 }); @@ -594,6 +605,8 @@ export function truthQuestionText( return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`; return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`; }; + if (q.kind === 'mixedRevival') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, + action => `you ${revivalExpressionText(action, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} before your Revival opportunity ends`)}?`; if (q.kind === 'mixedBribe') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, action => `you ${bribeExpressionText({ kind: 'payment', ...action }, playerName)} from now until this turn ends`)}?`; if (q.kind === 'mixedMovement') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, @@ -636,6 +649,8 @@ export function resolveTruthAction( bindBribeExpression?: (g: Game, p: Player, claim: BribeExpressionClaim, 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; + mixedRevivalAnswers?: (g: Game, p: Player, claim: MixedRevivalClaim) => TruthAnswer[]; + bindMixedRevival?: (g: Game, p: Player, claim: MixedRevivalClaim, 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[]; @@ -762,6 +777,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 === 'mixedRevival') + check(effects.mixedRevivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, + 'Choose a mixed current-fact and ordinary-return question compatible with the native opportunity.'); 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.'); @@ -853,6 +871,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 === 'mixedRevival') { + check(effects.mixedRevivalAnswers && effects.bindMixedRevival && effects.mixedRevivalAnswers(g, p, q).includes(a.answer), + 'Choose a whole mixed-Revival answer compatible with current facts and owned native returns.'); + if (a.answer !== 'unknown') effects.bindMixedRevival(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.'); diff --git a/tests/fixture-truth-mixed-revival.ts b/tests/fixture-truth-mixed-revival.ts new file mode 100644 index 00000000..e36cf0fe --- /dev/null +++ b/tests/fixture-truth-mixed-revival.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Action, type Game } from '../game/engine'; +import type { MixedActionExpression } from '../game/mixed-action-question'; +import type { RevivalExpressionLeaf } from '../game/revival-expression-promises'; +import type { TruthFact } from '../game/truthtrance'; +import { + actTruthRevivalExpression, advanceTruthRevivalExpression, createTruthRevivalExpressionFixture, + declareTruthRevivalExpression, finishTruthRevivalExpression, nextTruthRevivalExpression, + settleTruthRevivalExpression, truthRevivalExpressionClean, truthRevivalExpressionInventory, + truthRevivalExpressionPlayer, type TruthRevivalExpressionStep, +} from './fixture-truth-revival-expression'; + +export type TruthMixedRevivalExpression = MixedActionExpression; +export type TruthMixedRevivalStep = TruthRevivalExpressionStep; +export const actTruthMixedRevival = actTruthRevivalExpression; +export const advanceTruthMixedRevival = advanceTruthRevivalExpression; +export const finishTruthMixedRevival = finishTruthRevivalExpression; +export const nextTruthMixedRevival = nextTruthRevivalExpression; +export const settleTruthMixedRevival = settleTruthRevivalExpression; +export const truthMixedRevivalClean = truthRevivalExpressionClean; +export const truthMixedRevivalInventory = truthRevivalExpressionInventory; +export const truthMixedRevivalPlayer = truthRevivalExpressionPlayer; + +export const truthMixedRevivalFact = (fact: TruthFact): TruthMixedRevivalExpression => ({ kind: 'fact', fact }); +export const truthMixedRevivalForce = (minimum = 1, counter: 'total' | 'normal' | 'elite' = 'total'): +TruthMixedRevivalExpression => ({ kind: 'action', action: { kind: 'forces', counter, minimum } }); +export const truthMixedRevivalLeader = (leader: string): TruthMixedRevivalExpression => + ({ kind: 'action', action: { kind: 'leader', leader } }); +export const truthMixedRevivalGroup = (kind: 'and' | 'or', ...terms: TruthMixedRevivalExpression[]): +TruthMixedRevivalExpression => ({ kind, terms }); + +/** Original public H/A/T Ix47: the inherited paid shipment and actual projectile + * kill reach T4, one tank counter and a cost2 personal early Hidar return. This + * adapter changes only the raw question. It installs no played state or binding. */ +export function createTruthMixedRevivalFixture(options: { + advanced?: boolean; window?: 'active' | 'waiting'; ghola?: boolean; bothDead?: boolean; + mixed?: TruthMixedRevivalExpression; +} = {}) { + const { mixed: suppliedMixed, ...sourceOptions } = options; + const base = createTruthRevivalExpressionFixture(sourceOptions); + const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 4 }; + const falseSpiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 5 }; + const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 }; + const mixed = structuredClone(suppliedMixed ?? truthMixedRevivalGroup('and', truthMixedRevivalFact(spiceFact), + truthMixedRevivalForce(), truthMixedRevivalLeader(base.leader))); + const question = { kind: 'mixedRevival' as const, target: base.target, turn: base.game.turn, mixed }; + return { ...base, revivalSource: base, mixed, question, spiceFact, falseSpiceFact, forecastFact, + questionAction: { type: 'truthAsk', question } satisfies Action, + sourceCounts: { inherited: base.sourceActions.length, postStorm: base.postStormSourceActions.length, + phase3: base.phase3SourceActions.length }, + sourceDisclosure: `${base.sourceDisclosure} This thin mixed adapter changes only the original raw question: current facts and future ordinary typed force/named-leader returns share one AND/OR tree. It inherits every original action and freezes no fact or residual itself.` }; +} +export type TruthMixedRevivalFixture = ReturnType; + +export function declareTruthMixedRevival(f: TruthMixedRevivalFixture, source = f.game, + options: { copies?: 1 | 2 } = {}) { + return declareTruthRevivalExpression(f.revivalSource, source, options); +} +export function askTruthMixedRevival(game: Game, asker: string, target: string, mixed: TruthMixedRevivalExpression, + actions: TruthMixedRevivalStep[] = []): Game { + return actTruthMixedRevival(game, asker, { type: 'truthAsk', question: { + kind: 'mixedRevival', target, turn: game.turn, mixed: structuredClone(mixed), + } }, actions); +} +export function answerTruthMixedRevival(game: Game, target: string, answer: 'yes' | 'no', + actions: TruthMixedRevivalStep[] = []): Game { + return actTruthMixedRevival(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthMixedRevival(f: TruthMixedRevivalFixture, answer: 'yes' | 'no' = 'yes', + mixed = f.mixed, source = f.game) { + const declared = declareTruthMixedRevival(f, source), actions = [...declared.actions]; + const asked = askTruthMixedRevival(declared.game, f.asker, f.target, mixed, actions); + const game = answerTruthMixedRevival(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} + +/** Own public raw aggregate only; no private frozen clause or residual access. */ +export function truthMixedRevivalPromise(game: Game, + target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) { + const row = viewGame(game, target).mixedRevivalPromises.find(row => row.player === target); + assert.ok(row, 'The respondent retains the original mixed question and whole-answer status'); + return row; +} +export function completeTruthMixedRevival(game: Game, target: string, actions: TruthMixedRevivalStep[] = []): Game { + for (let count = 0; count < 4 && !truthMixedRevivalPromise(game, target).fulfilled; count++) { + const guide = viewGame(game, target).revivalExpressionPromiseGuide; + assert.ok(guide, 'The owned mixed Yes has an exact native ordinary return guide'); + assert.equal(guide.phase, game.phase); + game = settleTruthMixedRevival(actTruthMixedRevival(game, target, guide.action, actions), actions); + } + assert.equal(truthMixedRevivalPromise(game, target).fulfilled, true); + return game; +} diff --git a/tests/truth-mixed-revival-controls.test.tsx b/tests/truth-mixed-revival-controls.test.tsx new file mode 100644 index 00000000..11ce4ded --- /dev/null +++ b/tests/truth-mixed-revival-controls.test.tsx @@ -0,0 +1,325 @@ +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 { MixedRevivalFields, mixedRevivalInputError } from '../components/mixed-revival-fields'; +import { MixedRevivalPromises } from '../components/mixed-revival-promises'; +import { TruthFactFields } from '../components/mixed-shipment-fields'; +import { RevivalExpressionPromiseGuideButton, RevivalExpressionPromises } from '../components/revival-expression-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import type { MixedActionExpression } from '../game/mixed-action-question'; +import type { RevivalActionClaim } from '../game/mixed-revival-question'; +import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction'; +import { createTechTokens } from '../game/tech-tokens'; +import type { TruthFact } from '../game/truthtrance'; + +type Expression = MixedActionExpression; +type ElementProps = { children?: ReactNode; [key: string]: unknown }; +type Element = ReactElement; +const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value }); +const force = (minimum = 1, counter: 'total' | 'normal' | 'elite' = 'total'): Expression => + ({ kind: 'action', action: { kind: 'forces', counter, minimum } }); +const named = (leader = 'named-leader'): Expression => ({ kind: 'action', action: { kind: 'leader', leader } }); +const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [force(3), named()] }] }); +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-mixed-revival-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-mixed-revival-${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-mixed-revival-${path}-minimum`); + (input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } }); +} +function editFact(tree: ReactNode, path: string, next: TruthFact) { + const scope = find(tree, 'data-mixed-revival-fact-path', path); + const fields = elements(scope).find(element => element.type === TruthFactFields); + assert.ok(fields, `Current fact fields ${path}`); + (fields.props.onChange as (fact: TruthFact) => void)(next); +} + +/** Controlled projections prove field and exact callback contracts. Earned + * cards, actual casualties, quotes and native returns have separate proof. */ +function projected(phase = 4, type = 'revive', advanced = false): GameView { + const source = createGame('TRUTHMIXEDREVIVALCONTROLS', 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 }, + { id: 'second-leader', name: 'Second Commander', faction: 'emperor', strength: 4 }, + { id: 'foreign-leader', name: 'Foreign Commander', faction: 'atreides', strength: 5 }, + ]; + game.revivalExpressionPromises = []; + game.mixedRevivalPromises = [{ event: 'controlled-mixed-revival-event', player: 'p', asker: 'a', turn: 2, + mixed: claim(), answer: type !== 'ready', progress: { total: 2, normal: 1, elite: 1, leaders: ['named-leader'] }, + fulfilled: false, released: false }]; + 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-mixed-revival-event', + ...(type === 'revive' ? { amount: 2, elite: 1, freeElite: 0 } : {}), + ...(type === 'reviveLeader' ? { leader: 'named-leader' } : {}), + ...(type === 'acceptLeaderRevival' ? { leaderRevivalPrice: 3 } : {}), + ...(type === 'decision' ? { karama: true, card: 'controlled-original-karama' } : {}) } }; + return game; +} +function editor(initial: Expression, disabled = false, game = projected(), target = 'p') { + let value = initial, changes = 0; + return { + get value() { return value; }, get changes() { return changes; }, + tree: () => MixedRevivalFields({ value, game, target, disabled, onChange(next) { value = next; changes++; } }), + html: () => renderToStaticMarkup(createElement(MixedRevivalFields, { value, game, target, disabled, onChange() {} })), + error: () => mixedRevivalInputError(value, game, target), + }; +} +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(MixedRevivalPromises, { game, busy: false, send() {} })); +const pureMarkup = (game: GameView) => renderToStaticMarkup(createElement(RevivalExpressionPromises, { game, busy: false, send() {} })); + +void test('shared mixed editor keeps sixteen original conditions, typed force counters and unique per-leaf IDs without overflow', () => { + const e = editor(fact()); click(control(e.tree(), 'or-1')); + for (let count = 3; count <= 16; count++) { + click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count); + if (count % 3 === 0) select(e.tree(), `1.${count}`, 'counter', 'elite'); + } + select(e.tree(), '1.2', 'kind', 'leader'); select(e.tree(), '1.2', 'leader', 'second-leader'); + const full = e.value; assert.ok(full.kind === 'or'); assert.equal(full.terms.length, 16); + assert.deepEqual(full.terms[0], fact()); assert.deepEqual(full.terms[1], named('second-leader')); + assert.deepEqual(full.terms[2], force(3, 'elite')); assert.equal(e.error(), null); + const html = e.html(), ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); + assert.match(html, /16 \/ 16 conditions/); assert.equal((html.match(/Revival condition type/g) ?? []).length, 15); + assert.equal(new Set(ids).size, ids.length); + const before = structuredClone(full), changes = e.changes; + for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-and-1.1', 'or-1']) { + assert.equal(control(e.tree(), key).props.disabled, true, key); click(control(e.tree(), key)); + } + assert.equal(e.changes, changes); assert.deepEqual(e.value, before); +}); + +void test('mixed and embedded fact groups retain every branch through edits, collapse and the shared depth-four envelope', () => { + const e = editor({ kind: 'and', terms: [fact(), force(3)] }); + click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and'); + click(control(e.tree(), 'nested-or-1')); click(control(e.tree(), 'nested-and-1')); + assert.ok(e.value.kind === 'and'); assert.equal(e.value.terms.length, 4); + click(control(e.tree(), 'fact-or-1.1')); editFact(e.tree(), '1.1.2', { kind: 'stormForecast', compare: 'gte', value: 4 }); + select(e.tree(), '1.1', 'fact-meaning', 'and'); click(control(e.tree(), 'fact-add-1.1')); + editFact(e.tree(), '1.1.3', { kind: 'spice', compare: 'gte', value: 7 }); + assert.match(e.html(), /Current-fact group 1\.1/); assert.equal(e.error(), null); + click(control(e.tree(), 'fact-remove-1.1.3')); click(control(e.tree(), 'fact-remove-1.1.2')); + click(control(e.tree(), 'remove-1.2.2')); click(control(e.tree(), 'remove-1.4')); click(control(e.tree(), 'remove-1.3')); + assert.deepEqual(e.value, { kind: 'and', terms: [fact(), force(3)] }); + select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error()); + select(e.tree(), '1.2', 'timing', 'action'); assert.equal(e.error(), null); + click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, force()); assert.ok(e.error()); + click(control(e.tree(), 'and-1')); assert.equal(e.error(), null); + let nested = fact(); + for (let depth = 0; depth < 4; depth++) nested = { kind: 'and', terms: [nested, force()] }; + const deep = editor(nested); assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/); + for (const key of ['and-1', 'or-1.1.1.1.1', 'fact-and-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); + assert.ok(mixedRevivalInputError({ kind: 'and', terms: [nested, force()] }, projected(), 'p')); +}); + +void test('numeric and named drafts stay repairable while exact action leaves reject groups or metadata and busy callbacks preserve the tree', () => { + for (const invalid of [NaN, -1, 0, 0.5, 21, Infinity]) { + const e = editor({ kind: 'and', terms: [fact(), force()] }); + minimum(e.tree(), '1.2', invalid); assert.ok(e.error()); click(control(e.tree(), 'or-1.2')); + const raw = e.value; assert.ok(raw.kind === 'and'); + assert.deepEqual(raw.terms[1], { kind: 'or', terms: [force(invalid), fact()] }); + assert.match(e.html(), /role="alert"/); + minimum(e.tree(), '1.2.1', 2); assert.equal(e.error(), null); + select(e.tree(), '1.2.1', 'kind', 'leader'); select(e.tree(), '1.2.1', 'leader', ''); assert.ok(e.error()); + select(e.tree(), '1.2.1', 'leader', 'named-leader'); assert.equal(e.error(), null); + } + for (const action of [{ kind: 'and', terms: [{ kind: 'forces', counter: 'total', minimum: 1 }] }, + { kind: 'forces', counter: 'total', minimum: 1, turn: 2 }, { kind: 'leader', leader: 'named-leader', fulfilled: true }, + { kind: 'forces', counter: 'strength', minimum: 1 }, { kind: 'leader', leader: 'foreign-leader' }]) + assert.ok(mixedRevivalInputError({ kind: 'and', terms: [fact(), { kind: 'action', action }] }, projected(), 'p')); + const frozen = editor({ kind: 'and', terms: [fact(), force()] }, true), before = structuredClone(frozen.value); + for (const key of ['and-1', 'add-action-1', 'nested-or-1', 'remove-1.2', 'fact-or-1.1']) click(control(frozen.tree(), key)); + minimum(frozen.tree(), '1.2', 3); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'kind', 'leader'); + editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 }); + assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before); +}); + +void test('leader choices use public respondent faction and Advanced Atreides identity while current facts inspect no secret eligibility', () => { + for (const advanced of [false, true]) { + const game = projected(4, 'revive', advanced); + for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'forces', 'reserves', 'tanks', 'elites']) + Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true }); + for (const key of ['revival', 'deck', 'spiceDeck', 'discard']) Object.defineProperty(game, key, { + get() { throw Error(`No private ${key} inspection`); }, configurable: true, + }); + const own = editor({ kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), named()] }, false, game); + assert.equal(own.error(), null); + const ownHTML = own.html(); assert.match(ownHTML, /Named Commander/); assert.match(ownHTML, /Second Commander/); + assert.doesNotMatch(ownHTML, /Foreign Commander|Kwisatz Haderach/); + assert.match(ownHTML, /Current facts are evaluated when the player answers/); + assert.match(ownHTML, /unknown current fact can still allow a definite whole answer/); + assert.match(ownHTML, /conservative future commitment/); assert.match(ownHTML, /If no such value is available, the answer is I don’t know/); + const other = editor({ kind: 'and', terms: [fact(), named('foreign-leader')] }, false, game, 'a'); + assert.equal(other.error(), null); assert.match(other.html(), /Foreign Commander/); assert.doesNotMatch(other.html(), /Named Commander|Second Commander/); + if (advanced) assert.match(other.html(), /Kwisatz Haderach/); else assert.doesNotMatch(other.html(), /Kwisatz Haderach/); + assert.equal(mixedRevivalInputError({ kind: 'and', terms: [fact(), named('kwisatz')] }, game, 'a') === null, advanced); + editFact(own.tree(), '1.1', { kind: 'spice', compare: 'gte', value: NaN }); assert.ok(own.error()); + editFact(own.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 2 }); assert.equal(own.error(), null); + } +}); + +void test('own aggregate-only panel preserves the original question, whole answer and typed cumulative ordinary progress without frozen branches', () => { + const game = projected(); game.revivalExpressionPromiseGuide = null; + game.mixedRevivalPromises.push( + { ...game.mixedRevivalPromises[0], event: 'no-event', answer: false }, + { ...game.mixedRevivalPromises[0], event: 'fulfilled-event', fulfilled: true }, + { ...game.mixedRevivalPromises[0], event: 'released-event', released: true }, + { ...game.mixedRevivalPromises[0], event: 'foreign-event', player: 'a', mixed: { kind: 'or', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), named('foreign-leader')] } }, + ); + for (const promise of game.mixedRevivalPromises) for (const key of ['binding', 'residual', 'factAnswers', 'frozenFacts', 'compiled']) + Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + const html = markup(game); + assert.match(html, /You answered Yes/); assert.match(html, /You answered No/); assert.match(html, /you hold Shield/); + 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: Named Commander/); + assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/); + assert.match(html, /Individual fact answers and any reduced future branch remain private/); + assert.match(html, /No negates the whole expression, not each condition separately/); + assert.match(html, /cumulative across smaller future ordinary returns/); + assert.match(html, /Prior returns, requests alone, Ghola, foreign leaders and special card returns do not count/); + assert.match(html, /Original costs, revival cycles, limits and turn order still apply/); + assert.doesNotMatch(html, /Poison Blade|Foreign Commander|controlled-mixed-revival-event|fulfilled-event|factAnswers/); + assert.equal(MixedRevivalPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null); +}); + +void test('shared Basic and ordered Advanced guides forward the exact mixed return packet and accept only the current leader price', () => { + for (const advanced of [false, true]) for (const type of ['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready']) { + const game = projected(4, type, advanced), 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.equal(sent[0], game.revivalExpressionPromiseGuide!.action); + assert.match(markup(game), /data-testid="revival-expression-promise-guide"/); + if (type === 'revive') assert.deepEqual(sent[0], { type: 'revive', amount: 2, elite: 1, freeElite: 0, + revivalExpressionPromiseEvent: 'controlled-mixed-revival-event' }); + if (type === 'acceptLeaderRevival') { + 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); + } + if (advanced) { game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; press(button); assert.equal(sent.length, 1); } + } +}); + +void test('early mixed preparations retain Charity, ordinary auction 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.equal(sent[0], game.revivalExpressionPromiseGuide!.action); + if (type === 'decision') { + assert.equal(sent[0].card, 'controlled-original-karama'); assert.equal(sent[0].karama, true); + assert.doesNotMatch(markup(game), /controlled-original-karama/); game.decision!.player = 'a'; + } else if (phase === 2) game.ready.push('p'); else game.auction!.active = 'a'; + press(button); assert.equal(sent.length, 1); + } + for (const method of ['silent', 'onceAround'] as const) { + const game = projected(3, 'richeseBid'), sent: Action[] = []; + const native = createRicheseAuction({ event: 'controlled-mixed-revival-auction', 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-mixed-revival-event' } }; + const button = guide({ game, busy: false, send(action) { sent.push(action); } }); + assert.equal(button.props.disabled, false); press(button); assert.equal(sent[0], game.revivalExpressionPromiseGuide.action); + game.richeseAuction.event = 'later-auction'; press(button); assert.equal(sent.length, 1); + game.richeseAuction.event = native.event; + if (method === 'silent') game.richeseAuction.submitted.push('p'); else game.richeseAuction.active = 'a'; + press(button); assert.equal(sent.length, 1); + } +}); + +void test('stale mixed guide callbacks preserve exact event, owner, turn, phase, action counters, order and quiet-window guards without private inspection', () => { + 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.revivalExpressionPromiseGuide!.action.elite = 0; }, + game => { game.revivalExpressionPromiseGuide!.action.freeElite = 1; }, + game => { game.mixedRevivalPromises[0].event = 'expired'; }, game => { game.mixedRevivalPromises[0].player = 'a'; }, + game => { game.mixedRevivalPromises[0].turn = 1; }, game => { game.mixedRevivalPromises[0].fulfilled = true; }, + game => { game.mixedRevivalPromises[0].released = true; }, game => { game.mixedRevivalPromises = []; }, + 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[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } }); + change(game); press(button); assert.deepEqual(sent, []); + } + const busy = { game: projected(), busy: false, send() { assert.fail('A busy guide cannot send'); } }; + const button = guide(busy); busy.busy = true; press(button); + const replaced = { game: projected(), busy: false, send() { assert.fail('A replaced view cannot send'); } }; + const original = guide(replaced); replaced.game = { ...replaced.game }; press(original); + 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 player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'forces', 'reserves', 'tanks', 'elites']) + Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + for (const key of ['revival', 'deck', 'spiceDeck', 'discard']) Object.defineProperty(game, key, { + get() { throw Error(`No private ${key} inspection`); }, configurable: true, + }); + if (game.auction) Object.defineProperty(game.auction, 'card', { get() { throw Error('No hidden auction lot inspection'); } }); + assert.match(markup(game), /Your mixed Revival promises/); + press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, [game.revivalExpressionPromiseGuide!.action]); + } +}); + +void test('mixed panels wait for the actual Revival actor and share one guide with live pure records while historical panels stay passive', () => { + for (const phase of [0, 1, 2, 3]) { + const game = projected(phase); game.revivalExpressionPromiseGuide = null; + assert.match(markup(game), /Your Revival opportunity is still ahead/); assert.doesNotMatch(markup(game), /