({
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({
Bind ordinary bribe payments this turn
Combine ordinary bribe payments with AND/OR
Combine current facts and ordinary payments
+ Combine current facts and ordinary Revival
Bind combined force and leader revivals
Bind a named leader revival this turn
Bind ordinary force revivals this turn
@@ -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), / act(game, actor, action)); assert.deepEqual(game, before);
+}
+function truthsOnce(game: Game, truths: string[]) {
+ for (const truth of truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+}
+
+void test('two original Truths retain scalar force No while mixed OR Yes follows the real alternate leader', () => {
+ 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 = answer(game, f.target, 'no');
+ game = ask(game, f.asker, f.target, group('and', fact(f.spiceFact), group('or', force(), leader(f.leader))));
+ assert.deepEqual(viewGame(game, f.target).truthMixedRevivalAnswers, ['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).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);
+ truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('two original Truths retain scalar leader No while mixed OR Yes returns only the real free force', () => {
+ const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
+ game = act(game, f.asker, { type: 'truthAsk', question: {
+ kind: 'leaderRevival', target: f.target, turn: game.turn, leader: f.leader,
+ } });
+ game = answer(game, f.target, 'no');
+ game = answer(ask(game, f.asker, f.target,
+ group('and', fact(f.spiceFact), group('or', force(), leader(f.leader)))), f.target, 'yes');
+ const 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(promise(game).fulfilled, true); assert.deepEqual(promise(game).progress.leaders, []);
+ assert.equal(player(game, f.target).spice, 5); rejected(game, f.target, f.revivalAction);
+ game = finish(game); assert.equal(game.leaderRevivalPromises![0].fulfilled, true);
+ assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
+ truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('two original Truths share one native leader invoice between an earlier named Yes and mixed AND Yes', () => {
+ const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
+ game = act(game, f.asker, { type: 'truthAsk', question: {
+ kind: 'leaderRevival', target: f.target, turn: game.turn, leader: f.leader,
+ } });
+ game = answer(game, f.target, 'yes'); game = answer(ask(game, f.asker, f.target, f.mixed), f.target, 'yes');
+ game = complete(game, f.target);
+ assert.equal(game.leaderRevivalPromises![0].fulfilled, true); assert.equal(promise(game).fulfilled, true);
+ assert.equal(player(game, f.target).spice, 3); assert.equal(player(game, f.target).leaderRevived, true);
+ assert.equal(player(game, f.target).freeForcesRevived, 1);
+ assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.deaths, 1);
+ truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('two original Truths preserve an earlier mixed AND Yes against a contradictory later scalar force No', () => {
+ const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
+ game = answer(ask(game, f.asker, f.target, f.mixed), 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 = answer(game, f.target, 'yes'); game = complete(game, f.target);
+ assert.equal(game.revivalPromises![0].fulfilled, true); assert.equal(game.revivalPromises![0].progress, 1);
+ assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, 3);
+ truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('earlier original Bribe Yes reserves T4 before a later mixed ordinary AND answer', () => {
+ 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.mixed);
+ assert.deepEqual(viewGame(game, f.target).truthMixedRevivalAnswers, ['no']);
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); 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);
+ assert.equal(player(game, f.opponent).bribes, 4); game = finish(game);
+ assert.equal(promise(game).fulfilled, true); assert.equal(game.bribePromises![0].fulfilled, true);
+ truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('earlier mixed T4 fact and AND Yes retain their budget through a real bribe3 free-force bank1 and leader2', () => {
+ const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
+ game = answer(ask(game, f.asker, f.target, f.mixed), 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');
+ rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 });
+ game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 3 });
+ assert.equal(player(game, f.target).spice, 1);
+ 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(promise(game).progress.normal, 1); assert.equal(promise(game).fulfilled, false);
+ 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(promise(game).fulfilled, true); assert.equal(player(game, f.opponent).bribes, 3);
+ game = finish(game); assert.ok(!game.bribePromises![0].fulfilled, 'The actual turn-wide bribe No outlasts Revival');
+ truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('earlier original shipment funding excludes a later mixed AND beyond the original 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.mixed);
+ assert.deepEqual(viewGame(game, f.target).truthMixedRevivalAnswers, ['no']);
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
+ assert.equal(game.shipmentPromises![0].answer, true); assert.ok(!game.shipmentPromises![0].released);
+ truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('earlier mixed AND reserves one real return invoice before a later original shipment answer', () => {
+ const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
+ game = answer(ask(game, f.asker, f.target, f.mixed), 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');
+ game = complete(game, f.target); assert.equal(promise(game).fulfilled, true);
+ assert.equal(player(game, f.target).spice, 3); assert.ok(!game.shipmentPromises![0].fulfilled);
+ truthsOnce(game, f.truths); assert.deepEqual(inventory(game), f.originalCards);
+});
diff --git a/tests/truth-mixed-revival-runtime.test.ts b/tests/truth-mixed-revival-runtime.test.ts
new file mode 100644
index 00000000..9ec769e8
--- /dev/null
+++ b/tests/truth-mixed-revival-runtime.test.ts
@@ -0,0 +1,315 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { truthFactAnswer } from '../game/truthtrance';
+import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
+import {
+ actTruthMixedRevival as act, advanceTruthMixedRevival as advance,
+ answerTruthMixedRevival as answer, askTruthMixedRevival as ask,
+ bindTruthMixedRevival as bind, completeTruthMixedRevival as complete,
+ createTruthMixedRevivalFixture as fixture, declareTruthMixedRevival as declare,
+ finishTruthMixedRevival as finish, settleTruthMixedRevival as settle,
+ truthMixedRevivalClean as clean, truthMixedRevivalFact as fact,
+ truthMixedRevivalForce as force, truthMixedRevivalGroup as group,
+ truthMixedRevivalInventory as inventory, truthMixedRevivalLeader as leader,
+ truthMixedRevivalPlayer as player, truthMixedRevivalPromise as promise,
+} from './fixture-truth-mixed-revival';
+
+const progress0 = { total: 0, normal: 0, elite: 0, leaders: [] };
+const forceAction: Action = { type: 'revive', amount: 1, elite: 0 };
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
+}
+function truthOnce(game: Game, truth: string) {
+ assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+}
+
+for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} mixed Revival freezes original T4 before real force income and named leader debit`, () => {
+ const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); 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.sourceActionCount, advanced ? 108 : 102); assert.equal(f.sourceActionCount, f.sourceActions.length);
+ assert.equal(f.sourceCounts.inherited, f.sourceActionCount);
+ assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
+ assert.equal(f.revivalSource.question.kind, 'revivalExpression'); assert.equal(f.question.kind, 'mixedRevival');
+ 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).leaders.filter(disc => !disc.dead).length, 4);
+ assert.equal(player(f.game, f.target).revivalCycle, 0); assert.equal(f.quote.early, true); assert.equal(f.quote.cost, 2);
+ assert.equal(player(f.game, f.target).tanks, 1); assert.equal(player(f.game, f.target).spice, 4);
+ assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
+ const before = structuredClone(f.game), bound = bind(f); assert.deepEqual(f.game, before);
+ assert.deepEqual(promise(bound.game).mixed, f.mixed); assert.deepEqual(promise(bound.game).progress, progress0);
+ rejected(bound.game, f.target, { type: 'ready' });
+ let game = act(bound.game, f.target, forceAction);
+ if (game.pendingRevival) assert.deepEqual(promise(game).progress, progress0);
+ game = settle(game);
+ assert.deepEqual(promise(game).progress, { total: 1, normal: 1, elite: 0, leaders: [] });
+ assert.equal(promise(game).fulfilled, false); 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);
+ assert.equal(guide.action.revivalExpressionPromiseEvent, promise(game).event);
+ game = settle(act(game, f.target, guide.action));
+ assert.deepEqual(promise(game).progress, { total: 1, normal: 1, elite: 0, leaders: [f.leader] });
+ assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
+ assert.equal(player(game, f.target).spice, 3); assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
+ assert.equal(player(game, f.target).leaderRevived, true);
+ 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);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
+});
+
+void test('native true current fact OR return answers Yes immediately with no native Revival binding', () => {
+ const f = fixture(), mixed = group('or', fact(f.spiceFact), leader(f.leader));
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedRevivalAnswers, ['yes']);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' });
+ let game = answer(asked, f.target, 'yes'); assert.equal(promise(game).fulfilled, true);
+ assert.equal(game.revivalExpressionPromises?.length ?? 0, 0);
+ assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null);
+ assert.deepEqual(promise(game).mixed, mixed); assert.deepEqual(promise(game).progress, progress0);
+ game = finish(game); assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.target).revived, 0);
+ assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native false current fact AND return answers No immediately while the real return remains legal', () => {
+ const f = fixture(), mixed = group('and', fact(f.falseSpiceFact), force());
+ assert.equal(truthFactAnswer(player(f.game, f.target), f.falseSpiceFact), 'no');
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedRevivalAnswers, ['no']);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
+ let game = answer(asked, f.target, 'no'); assert.equal(promise(game).fulfilled, true);
+ assert.equal(game.revivalExpressionPromises?.length ?? 0, 0);
+ game = settle(act(game, f.target, forceAction)); assert.equal(player(game, f.target).spice, 5);
+ assert.equal(truthFactAnswer(player(game, f.target), f.falseSpiceFact), 'yes');
+ assert.deepEqual(promise(game).progress, progress0); assert.equal(promise(game).fulfilled, true);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
+});
+
+void test('native false current fact OR return Yes requires the actual ordinary return', () => {
+ const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.falseSpiceFact), leader(f.leader))).game;
+ assert.equal(promise(game).fulfilled, false); assert.deepEqual(promise(game).progress, progress0);
+ rejected(game, f.target, { type: 'ready' }); game = settle(act(game, f.target, f.revivalAction));
+ assert.equal(promise(game).fulfilled, true); assert.deepEqual(promise(game).progress, { ...progress0, leaders: [f.leader] });
+ assert.equal(player(game, f.target).spice, 2); truthOnce(game, f.truth);
+ assert.deepEqual(inventory(finish(game)), f.originalCards);
+});
+
+void test('native true current fact AND return No forbids that exact ordinary event until the real deadline', () => {
+ const f = fixture(); let game = bind(f, 'no', group('and', fact(f.spiceFact), force())).game;
+ rejected(game, f.target, forceAction); assert.equal(promise(game).fulfilled, false);
+ game = settle(act(game, f.target, f.revivalAction)); assert.equal(player(game, f.target).spice, 2);
+ assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
+ rejected(game, f.target, forceAction); assert.deepEqual(promise(game).progress, { ...progress0, leaders: [f.leader] });
+ game = finish(game); assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).tanks, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+for (const answerValue of ['yes', 'no'] as const) void test(`native unknown non-Fremen forecast uses the conservative whole ${answerValue} ordinary-return constraint`, () => {
+ const f = fixture(), p = player(f.game, f.target);
+ assert.equal(p.faction, 'tleilaxu'); assert.equal(truthKnowledgeOf(f.game, p).stormForecast, null);
+ assert.equal(truthFactAnswer(p, f.forecastFact, truthKnowledgeOf(f.game, p)), 'unknown');
+ const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), leader(f.leader));
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedRevivalAnswers, [answerValue]);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
+ let game = answer(asked, f.target, answerValue); assert.equal(promise(game).fulfilled, false);
+ if (answerValue === 'yes') {
+ game = settle(act(game, f.target, f.revivalAction)); assert.equal(promise(game).fulfilled, true);
+ assert.deepEqual(promise(game).progress.leaders, [f.leader]);
+ } else {
+ rejected(game, f.target, f.revivalAction); game = settle(act(game, f.target, forceAction));
+ assert.equal(promise(game).progress.normal, 1); assert.equal(promise(game).fulfilled, false);
+ }
+ game = finish(game); assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed OR preserves the manual force or leader alternative instead of pinning its guide', () => {
+ const f = fixture(), mixed = group('or', group('and', fact(f.spiceFact), force()), leader(f.leader));
+ let game = bind(f, 'yes', mixed).game;
+ const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide);
+ assert.ok(guide.action.type === 'revive' || guide.action.type === 'reviveLeader');
+ const manual = guide.action.type === 'revive' ? f.revivalAction : forceAction;
+ assert.notEqual(manual.type, guide.action.type); game = settle(act(game, f.target, manual));
+ assert.equal(promise(game).fulfilled, true);
+ assert.equal(promise(game).progress.total, manual.type === 'revive' ? 1 : 0);
+ assert.deepEqual(promise(game).progress.leaders, manual.type === 'reviveLeader' ? [f.leader] : []);
+ rejected(game, f.target, guide.action); assert.deepEqual(inventory(finish(game)), f.originalCards);
+});
+
+void test('native mixed partial normal returns retain a larger force OR until the real alternate leader return', () => {
+ const f = fixture(), mixed = group('and', fact(f.spiceFact), group('or', force(2, 'normal'), leader(f.leader)));
+ let game = bind(f, 'yes', mixed).game; game = settle(act(game, f.target, forceAction));
+ assert.equal(promise(game).progress.normal, 1); assert.equal(promise(game).fulfilled, false);
+ 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).fulfilled, true); assert.equal(promise(game).progress.total, 1);
+ assert.deepEqual(promise(game).progress.leaders, [f.leader]); assert.equal(player(game, f.target).spice, 3);
+ assert.deepEqual(inventory(finish(game)), f.originalCards);
+});
+
+for (const first of ['forces', 'leader'] as const) void test(`native mixed whole AND No permits ${first} first and rejects only whole completion`, () => {
+ const f = fixture(); let game = bind(f, 'no').game;
+ game = settle(act(game, f.target, first === 'forces' ? forceAction : f.revivalAction));
+ assert.equal(promise(game).fulfilled, false); assert.equal(promise(game).progress.total, first === 'forces' ? 1 : 0);
+ assert.deepEqual(promise(game).progress.leaders, first === 'leader' ? [f.leader] : []);
+ rejected(game, f.target, first === 'forces' ? f.revivalAction : forceAction);
+ game = finish(game); assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed whole OR No forbids both qualifying ordinary returns through the native deadline', () => {
+ const f = fixture(), mixed = group('or', fact(f.falseSpiceFact), force(), leader(f.leader));
+ let game = bind(f, 'no', mixed).game;
+ rejected(game, f.target, forceAction); rejected(game, f.target, f.revivalAction);
+ assert.deepEqual(promise(game).progress, progress0); game = finish(game);
+ assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, 4);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native prior ordinary returns start outside a newly answered mixed interval', () => {
+ const f = fixture(); let game = settle(act(f.game, f.target, forceAction));
+ assert.equal(player(game, f.target).revived, 1); assert.equal(player(game, f.target).spice, 5);
+ const mixed = group('and', fact(f.spiceFact), group('or', force(), leader(f.leader)));
+ game = bind(f, 'yes', mixed, game).game;
+ assert.deepEqual(promise(game).progress, progress0); assert.equal(promise(game).fulfilled, false);
+ game = settle(act(game, f.target, f.revivalAction));
+ assert.deepEqual(promise(game).progress, { ...progress0, leaders: [f.leader] });
+ assert.equal(promise(game).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native original Ghola stays outside the mixed ordinary-return progress and No restriction', () => {
+ const f = fixture({ ghola: true }); assert.ok(f.ghola);
+ let game = bind(f, 'no', group('and', fact(f.spiceFact), group('or', force(), leader(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).progress, progress0); assert.equal(promise(game).fulfilled, false);
+ rejected(game, f.target, forceAction); game = finish(game); assert.equal(promise(game).fulfilled, true);
+ assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed projection exposes only owned raw question aggregate and filters its pure residual', () => {
+ const f = fixture(), game = bind(f).game;
+ for (const actor of [f.target, f.asker, f.opponent]) {
+ const view = viewGame(game, actor); assert.ok(!('mixedRevivalQuestions' in view));
+ assert.deepEqual(view.revivalExpressionPromises, []); assert.equal(view.truthMixedRevivalAnswers, null);
+ assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
+ if (actor !== f.target) {
+ assert.deepEqual(view.mixedRevivalPromises, []); assert.equal(view.revivalExpressionPromiseGuide, null);
+ } else {
+ const row = promise(game);
+ assert.deepEqual(Object.keys(row).sort(), ['answer', 'asker', 'event', 'fulfilled', 'mixed', 'player', 'progress', 'released', 'turn']);
+ assert.deepEqual(row.mixed, f.mixed); assert.deepEqual(row.progress, progress0);
+ assert.equal(row.player, f.target); assert.equal(row.asker, f.asker); assert.equal(row.turn, game.turn);
+ assert.equal(typeof row.answer, 'boolean'); assert.equal(typeof row.fulfilled, 'boolean'); assert.equal(typeof row.released, 'boolean');
+ assert.ok(view.revivalExpressionPromiseGuide?.action.revivalExpressionPromiseEvent);
+ assert.ok(!JSON.stringify(row).includes('"claim"'));
+ }
+ }
+ assert.equal(game.revivalExpressionPromises?.length, 1);
+ assert.equal(game.revivalExpressionPromises![0].event, promise(game).event);
+});
+
+void test('native mixed question and guide preserve exact owner turn event and JSON packets', () => {
+ const f = fixture(), declared = declare(f);
+ assert.ok(viewGame(declared.game, f.asker).truthMixedRevivalTargets.includes(f.target));
+ rejected(declared.game, f.opponent, f.questionAction);
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question,
+ mixed: group('and', fact(f.spiceFact), leader('atreides-0')) } });
+ let game = ask(declared.game, f.asker, f.target, f.mixed);
+ assert.equal(viewGame(game, f.asker).truthMixedRevivalAnswers, null);
+ assert.deepEqual(viewGame(game, f.target).truthMixedRevivalAnswers, ['yes', 'no']);
+ rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
+ const guide = viewGame(game, f.target).revivalExpressionPromiseGuide; assert.ok(guide); assert.equal(guide.phase, 4);
+ assert.equal(guide.action.revivalExpressionPromiseEvent, promise(game).event);
+ rejected(game, f.opponent, guide.action);
+ rejected(game, f.target, { ...guide.action, revivalExpressionPromiseEvent: 'stale-mixed-revival-event' });
+ rejected(game, f.target, { ...guide.action, amount: 20, elite: 0 });
+ const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = settle(act(game, f.target, wire));
+ rejected(game, f.target, wire); game = complete(game, f.target);
+ rejected(game, f.target, wire); rejected(finish(game), f.target, wire);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed post-Storm Yes crosses real Spice Charity Bidding and original ordered Revival', () => {
+ const f = fixture({ advanced: true, window: 'waiting' });
+ assert.equal(f.postStorm.turn, 2); assert.equal(f.postStorm.phase, 1);
+ assert.equal(f.sourceCounts.postStorm, 83); assert.deepEqual(f.postStorm, f.storm.after);
+ assert.equal(f.postStormSourceActions.length, f.postStormActionCount);
+ assert.deepEqual(f.sourceActions.slice(f.postStormActionCount, f.phase3ActionCount), f.bidding.steps);
+ let game = bind(f, 'yes', f.mixed, f.postStorm).game;
+ assert.deepEqual(promise(game).progress, progress0); assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null);
+ const actions: { actor: string; action: Action }[] = [];
+ game = advance(game, state => state.phase === 4 && clean(state) && state.revivalOrder?.remaining[0] === f.target, actions);
+ assert.ok(actions.some(step => step.action.type === 'passBid')); assert.ok(actions.some(step => step.action.type === 'ready'));
+ assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original);
+ assert.deepEqual(promise(game).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 mixed Yes exposes the common guide only at its original owned opportunity', () => {
+ const f = fixture({ advanced: true, window: 'waiting' }); assert.notEqual(f.game.revivalOrder!.remaining[0], f.target);
+ let game = bind(f).game; assert.equal(viewGame(game, f.target).revivalExpressionPromiseGuide, null);
+ rejected(game, f.target, forceAction); rejected(game, f.target, f.revivalAction);
+ game = advance(game, state => clean(state) && state.revivalOrder?.remaining[0] === f.target);
+ assert.deepEqual(game.revivalOrder!.original, f.game.revivalOrder!.original);
+ game = complete(game, f.target); assert.equal(promise(game).fulfilled, true);
+ assert.equal(player(game, f.target).spice, 3); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+for (const permitted of [false, true]) void test(`native mixed personally priced early leader ${permitted ? 'Yes pays the real quote' : 'No rejects the real acceptance'}`, () => {
+ const f = fixture({ advanced: true, window: 'waiting', bothDead: true });
+ assert.equal(player(f.afterBattle, f.opponent).leaders.find(disc => disc.id === f.opponentLeader)!.dead, true);
+ assert.equal(player(f.game, f.opponent).leaders.filter(disc => !disc.dead).length, 4);
+ assert.equal(f.game.revivalOrder!.remaining[0], f.opponent);
+ let game = act(f.game, f.opponent, { type: 'requestLeaderRevival', leader: f.opponentLeader });
+ game = act(game, f.target, { type: 'quoteLeaderRevival', target: f.opponent, amount: 3 });
+ const before = player(game, f.opponent).spice, payee = player(game, f.target).spice;
+ game = declare(f, game).game;
+ game = ask(game, f.asker, f.opponent, group('and', fact({ kind: 'spice', compare: 'gte', value: 3 }), leader(f.opponentLeader)));
+ game = answer(game, f.opponent, permitted ? 'yes' : 'no');
+ if (permitted) {
+ const guide = viewGame(game, f.opponent).revivalExpressionPromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'acceptLeaderRevival'); assert.equal(guide.action.leaderRevivalPrice, 3);
+ assert.equal(guide.action.revivalExpressionPromiseEvent, promise(game, f.opponent).event);
+ rejected(game, f.opponent, { ...guide.action, leaderRevivalPrice: 2 });
+ game = settle(act(game, f.opponent, guide.action));
+ assert.equal(player(game, f.opponent).spice, before - 3); assert.equal(player(game, f.target).spice, payee + 3);
+ assert.equal(player(game, f.opponent).leaderRevived, true); assert.equal(promise(game, f.opponent).fulfilled, true);
+ assert.deepEqual(promise(game, f.opponent).progress.leaders, [f.opponentLeader]);
+ } else {
+ rejected(game, f.opponent, { type: 'acceptLeaderRevival', leaderRevivalPrice: 3 });
+ assert.equal(player(game, f.opponent).spice, before);
+ assert.equal(player(game, f.opponent).leaders.find(disc => disc.id === f.opponentLeader)!.dead, true);
+ }
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('all four minimal policies actually answer mixed Yes and perform exact native ordinary return guides', () => {
+ const f = fixture(), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
+ for (const difficulty of DIFFICULTIES) {
+ let game = structuredClone(asked);
+ const answering = viewGame(game, f.target); answering.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const answerAction = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
+ assert.equal(answerAction.answer, 'yes', difficulty); assert.ok(answering.truthMixedRevivalAnswers?.includes('yes'));
+ game = act(game, f.target, answerAction);
+ for (let count = 0; count < 4 && !promise(game).fulfilled; count++) {
+ const view = viewGame(game, f.target); view.players.find(seat => seat.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); assert.deepEqual(action, guide.action); game = settle(act(game, f.target, action));
+ }
+ assert.equal(promise(game).fulfilled, true, difficulty); assert.equal(player(game, f.target).spice, 3);
+ assert.equal(player(game, f.target).tanks, 0); assert.equal(player(game, f.target).freeForcesRevived, 1);
+ assert.deepEqual(promise(game).progress, { total: 1, normal: 1, elite: 0, leaders: [f.leader] });
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+ }
+});