diff --git a/components/game-table.tsx b/components/game-table.tsx index 6f233b63..3e941f54 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1,4 +1,5 @@ 'use client'; +import { MixedTraitorPromises } from './mixed-traitor-promises'; import { MixedBattlePromises } from './mixed-battle-promises'; import { TraitorDecisionPromises } from './traitor-decision-promises'; import { MovementPromises } from './movement-promises'; @@ -394,7 +395,9 @@ export function GameTable({ ) ); const canDeclineTraitor = traitorBattle?.traitorCallChoices?.includes(false) ?? true; - const noTraitorReason = traitorBattle?.ownTraitorPromises?.some(promise => !promise.answer && !promise.fulfilled && !promise.released) + const noTraitorReason = traitorBattle?.ownTraitorPromises?.some(promise => !promise.answer && !promise.fulfilled && !promise.released) || + (!canRevealTraitor && canDeclineTraitor && g.mixedTraitorPromises.some(promise => + promise.battle === traitorBattle?.event && !promise.fulfilled && !promise.released)) ? 'Your accepted Truthtrance answer requires declining this Traitor declaration.' : traitorPlan?.kwisatz ? 'Kwisatz Haderach prevents this leader from turning traitor.' : 'You do not hold the opposing leader as a traitor.'; @@ -2060,6 +2063,7 @@ export function GameTable({ {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } + {g.status === 'playing' && } {g.guildBetrayalReaction ? ( ) : g.richeseBetrayalReaction ? ( diff --git a/components/mixed-traitor-fields.tsx b/components/mixed-traitor-fields.tsx new file mode 100644 index 00000000..f468240c --- /dev/null +++ b/components/mixed-traitor-fields.tsx @@ -0,0 +1,53 @@ +'use client'; + +import type { GameView } from '@/game/engine'; +import type { MixedActionExpression } from '@/game/mixed-action-question'; +import { + MIXED_TRAITOR_MAX_DEPTH, MIXED_TRAITOR_MAX_LEAVES, parseTraitorActionClaim, type TraitorActionClaim, +} from '@/game/mixed-traitor-question'; +import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields'; + +/** Question authors choose an original native declaration, never its private + * physical eligibility or the branch needed to honor the aggregate answer. */ +const config: MixedActionConfig = { + prefix: 'mixed-traitor', + legend: 'Mixed current-fact / this battle’s Traitor declaration', + actionLabel: 'Traitor declaration condition', + actionTiming: 'Traitor declaration · this revealed battle', + actionHelp: <>Traitor conditions concern the respondent’s one original Reveal or decline declaration in this revealed battle, + including a native Harkonnen declaration for an ally. Current facts are snapshotted at the answer. + An unknown current fact uses the conservative declaration commitment that guarantees the whole answer; + if no such answer is possible, the answer is I don’t know. A constant whole answer needs no declaration commitment. + The original declaration fulfills a remaining commitment before a later Karama or betrayal response can prevent its effect. + Native voter eligibility and physical Traitor rules still apply., + fallbackError: 'Choose a current fact and a Traitor declaration condition.', + maxLeaves: MIXED_TRAITOR_MAX_LEAVES, + maxDepth: MIXED_TRAITOR_MAX_DEPTH, + newAction: () => ({ call: true }), + parseAction: value => parseTraitorActionClaim(value), + renderAction: ({ value, id, disabled, onChange }) => <> + + + , +}; + +export function mixedTraitorInputError(value: unknown, game: GameView, target: string): string | null { + return mixedActionInputError(value, game, target, config); +} + +export function MixedTraitorFields(props: { + value: MixedActionExpression; + game: GameView; + target: string; + disabled: boolean; + onChange: (expression: MixedActionExpression) => void; +}) { + return MixedActionFields({ ...props, config }); +} diff --git a/components/mixed-traitor-promises.tsx b/components/mixed-traitor-promises.tsx new file mode 100644 index 00000000..5760b46c --- /dev/null +++ b/components/mixed-traitor-promises.tsx @@ -0,0 +1,33 @@ +'use client'; + +import type { GameView } from '@/game/engine'; +import { truthQuestionText } from '@/game/truthtrance'; +import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; + +/** Keep only the own original question and whole answer visible. The native + * battle controls own declaration choices; no residual belongs in this panel. */ +export function MixedTraitorPromises({ game }: { game: GameView }) { + const own = game.mixedTraitorPromises.filter(promise => promise.player === game.me); + if (!own.length) return null; + const leaderName = (id: string) => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' + : game.allLeaders.find(leader => leader.id === id)?.name ?? id; + return
+

Your mixed Traitor promises

+
    {own.map(promise =>
  • + Turn {promise.turn}, {game.combatLocations.find(location => location.id === promise.territory)?.name ?? promise.territory}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedTraitorCall', + target: promise.player, battle: promise.battle, territory: promise.territory, mixed: promise.mixed }, leaderName, + id => game.combatLocations.find(location => location.id === id)?.name ?? id, + id => game.players.find(player => player.id === id)?.name ?? id)} + {promise.released ? ' Released: this whole commitment is no longer possible.' + : promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding for this battle’s Traitor declaration.'} +
  • )}
+

Current facts are snapshotted at the answer. They create no promise to keep cards, spice or forces afterward. + Only the aggregate answer is shared. Individual fact answers and any reduced declaration commitment remain private. + AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.

+

A constant whole answer is complete immediately without a declaration commitment. + A remaining commitment completes at your original native Reveal or decline declaration. + This concerns the declaration: a later Karama or betrayal response can still prevent its effect. + Use the ordinary battle controls for the currently legal declaration choices. + {' '}Mixed Traitor rules.

+
; +} diff --git a/components/traitor-decision-promises.tsx b/components/traitor-decision-promises.tsx index 2959b23f..7678fe9a 100644 --- a/components/traitor-decision-promises.tsx +++ b/components/traitor-decision-promises.tsx @@ -2,13 +2,29 @@ import type { Action, GameView } from '@/game/engine'; import { Button } from './ui/button'; -export function TraitorDecisionAnswers({ game, act, busy }: { game: GameView; act: (action: Action) => void; busy: boolean }) { - if (game.truthtrance?.stage !== 'answer' || game.truthtrance.question?.kind !== 'traitorCall' || - game.truthtrance.question.target !== game.me) return null; +export function TraitorDecisionAnswers(props: { game: GameView; act: (action: Action) => void; busy: boolean }) { + const { game, act } = props, window = game.truthtrance, question = window?.question, battle = game.battle; + if (window?.stage !== 'answer' || (question?.kind !== 'traitorCall' && question?.kind !== 'mixedTraitorCall') || + question.target !== game.me || !battle?.revealed || question.battle !== battle.event || question.territory !== battle.territory) return null; + const mixed = question.kind === 'mixedTraitorCall'; + const answers = mixed ? game.truthMixedTraitorAnswers : game.truthTraitorAnswers; + const actor = game.me, turn = game.turn, event = battle.event, territory = battle.territory; + const source = window.queue[0], sourceSignature = JSON.stringify(source), signature = JSON.stringify(question); + const canAnswer = (answer: 'yes' | 'no' | 'unknown') => !props.busy && props.game === game && props.act === act && + game.me === actor && game.turn === turn && game.truthtrance === window && window.stage === 'answer' && + window.queue[0] === source && JSON.stringify(source) === sourceSignature && + window.question === question && question.target === actor && JSON.stringify(question) === signature && + game.battle === battle && battle.revealed && battle.event === event && battle.territory === territory && + question.battle === event && question.territory === territory && battle.traitorVoters.includes(actor) && + !battle.traitorSubmitted.includes(actor) && + (mixed ? game.truthMixedTraitorAnswers : game.truthTraitorAnswers) === answers && !!answers?.includes(answer); return
-

Yes binds Reveal for this original Traitor decision; No binds decline. This concerns your declaration, so a later Karama or betrayal response can still prevent its effect.

- {(game.truthTraitorAnswers ?? []).map(answer => )} + {mixed ?

Answer the whole mixed claim. Current facts are snapshotted at the answer; No makes the whole expression false, not each condition separately. + A constant whole answer needs no declaration commitment. A remaining commitment concerns your original Reveal or decline declaration, + so a later Karama or betrayal response can still prevent its effect.

+ :

Yes binds Reveal for this original Traitor decision; No binds decline. This concerns your declaration, so a later Karama or betrayal response can still prevent its effect.

} + {(answers ?? []).map(answer => )}
; } diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 8e068001..f28723b8 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,5 +1,8 @@ 'use client'; +import { MixedTraitorFields, mixedTraitorInputError } from './mixed-traitor-fields'; +import type { TraitorActionClaim } from '@/game/mixed-traitor-question'; + import { MixedBattleFields, mixedBattleInputError } from './mixed-battle-fields'; import type { MixedBattleActionClaim } from '@/game/mixed-battle-question'; @@ -86,7 +89,7 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); const [kind, setKind] = useState< - 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' >('fact'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); @@ -158,6 +161,11 @@ export function Truthtrance({ { kind: 'action', action: { kind: 'dial', compare: 'eq', value: 0 } }, ] }); const mixedBattleError = mixedBattleInputError(mixedBattle, g, target); + const [mixedTraitor, setMixedTraitor] = useState>({ kind: 'and', terms: [ + { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 1 } }, + { kind: 'action', action: { call: true } }, + ] }); + const mixedTraitorError = mixedTraitorInputError(mixedTraitor, g, target); const [shipment, setShipment] = useState({ territory: 'carthag', minimum: 6 }); const [mixed, setMixed] = useState({ kind: 'and', @@ -194,6 +202,7 @@ export function Truthtrance({ claim: planClaim, } : kind === 'mixedBattlePlan' ? { kind, target, battle: g.battle?.event ?? null, territory: g.battle?.territory ?? '', mixed: mixedBattle } + : kind === 'mixedTraitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '', mixed: mixedTraitor } : kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' } : kind === 'mixedMovement' ? { kind, target, turn: g.turn, mixed: mixedMovement } : kind === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression } @@ -422,7 +431,7 @@ export function Truthtrance({
{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
))} - ) : w.question!.kind === 'traitorCall' ? ( + ) : w.question!.kind === 'traitorCall' || w.question!.kind === 'mixedTraitorCall' ? ( ) : w.question!.kind === 'battlePlan' ? ( <> @@ -547,6 +556,7 @@ export function Truthtrance({ + @@ -562,7 +572,14 @@ export function Truthtrance({ )} - {kind === 'mixedBattlePlan' ? ( + {kind === 'mixedTraitorCall' ? ( + <> + + {!g.truthMixedTraitorTargets.includes(target) &&

Choose an actual voter with an unsubmitted Traitor decision in this revealed battle.

} +

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

+ + ) : kind === 'mixedBattlePlan' ? ( <> @@ -795,6 +812,7 @@ export function Truthtrance({ (kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) || (kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) || (kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) || + (kind === 'mixedTraitorCall' && (!!mixedTraitorError || !g.truthMixedTraitorTargets.includes(target))) || (kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) || (kind === 'mixedShipment' && (!!invalidMixed || !shipmentAvailable)) || diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 3c410a6e..2a7bac66 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,41 @@ open mode gates. ## Current checkpoint and work +**Truthtrance / current facts and original Traitor declarations — 26 October. Prototyped / Partial.** +Full mixed AND/OR questions freeze current facts and reduce Reveal/Decline leaves +over one original native decision. Constants/tautologies bind nothing; a forced +choice enters the existing native ledger with an optional mixed event link. +Current ineligibility cannot erase a Decline that survives a later matching +draw. Original matching/protection/prior-answer rules remain. Actual declaration +fulfills before synchronous cleanup or later effect cancellation. Own raw history +persists with turn/location while reduced choices stay out of public pure rows. +Full editor, Ask/private answers, native buttons/reasons and four minimal profiles +connect without a second declaration control or physical eligibility solver. + +Root integration with disjoint rules/controls/native lanes passes **18 files, +149/149**, types/lint/build and **eleven native paths**,29 GameTable SSR renders/ +eleven actual answer callbacks plus one editor callback followed by native Ask. +Original Basic26 adds6 for actual declaration/cleanup, edited true OR, false AND, +No-to-Decline requiring Reveal, call tautology and both unknown polarities; a real +bribe3 adds7 and retains the answer-time fact. Original remote allied72 adds9 +for an actual Reveal/Karama-prevented effect with its receipt retained. Original +Cunning77 adds8 for protected matching return/safe return/Reveal, or matching +return/frozen fact/Decline. All original Treachery identities remain physical. + +Seven strict helper cases, native event regressions,19 native/controlled runtime +cases and eight control cases supplement existing Traitor/Nexus/cheap-hero coverage. Native +cases also retain exact Cunning parent and fulfilled receipt through a later +actual draw/return. V1 passes quick checks then finds a missing test import shim; +loaded controls catch missing location text and an arrow-icon assertion. Those, +and the native disabled-choice explanation, are repaired; complete v2 passes. +Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-traitor-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-mixed-traitor-runtime/`. +No saved-game/database/server/reset/restart/deployment operation is performed. +Other families/cross-action/unknown-fact correlations, wider preparations and +final rules/browser/deployed/assurance remain. This certifies no complete mode. +[Authority, boolean reduction and native receipt evidence](TRUTHTRANCE_MIXED_TRAITOR.md). + **Truthtrance / current facts and current battle plans — 26 October. Prototyped / Partial.** Full mixed AND/OR questions freeze current facts and bind only a needed native PlanClaim residual. Native dial/support/cards/leader/KH, prior promises, Voice/ diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 553331e1..34beecbf 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,43 @@ # Dune implementation status +## 26 October 2026 — Current facts with original Traitor declarations + +**Prototyped / Partial.** +Full mixed AND/OR questions freeze current facts and reduce Reveal/Decline leaves +over one original native decision. Constants/tautologies bind nothing; a forced +choice enters the existing native ledger with an optional mixed event link. +Current ineligibility cannot erase a Decline that survives a later matching +draw. Original matching/protection/prior-answer rules remain. Actual declaration +fulfills before synchronous cleanup or later effect cancellation. Own raw history +persists with turn/location while reduced choices stay out of public pure rows. +Full editor, Ask/private answers, native buttons/reasons and four minimal profiles +connect without a second declaration control or physical eligibility solver. + +Root integration with disjoint rules/controls/native lanes passes **18 files, +149/149**, types/lint/build and **eleven native paths**,29 GameTable SSR renders/ +eleven actual answer callbacks plus one editor callback followed by native Ask. +Original Basic26 adds6 for actual declaration/cleanup, edited true OR, false AND, +No-to-Decline requiring Reveal, call tautology and both unknown polarities; a real +bribe3 adds7 and retains the answer-time fact. Original remote allied72 adds9 +for an actual Reveal/Karama-prevented effect with its receipt retained. Original +Cunning77 adds8 for protected matching return/safe return/Reveal, or matching +return/frozen fact/Decline. All original Treachery identities remain physical. + +Seven strict helper cases, native event regressions,19 native/controlled runtime +cases and eight control cases supplement existing Traitor/Nexus/cheap-hero coverage. Native +cases also retain exact Cunning parent and fulfilled receipt through a later +actual draw/return. V1 passes quick checks then finds a missing test import shim; +loaded controls catch missing location text and an arrow-icon assertion. Those, +and the native disabled-choice explanation, are repaired; complete v2 passes. +Proof/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-traitor-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-mixed-traitor-runtime/`. +No saved-game/database/server/reset/restart/deployment operation is performed. +Other families/cross-action/unknown-fact correlations, wider preparations and +final rules/browser/deployed/assurance remain. This certifies no complete mode. +[Authority, boolean reduction and native receipt evidence](TRUTHTRANCE_MIXED_TRAITOR.md). + + ## 26 October 2026 — Current facts with current battle-plan conditions **Prototyped / Partial.** diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index d7c8817a..eef1f45a 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,20 @@ # Rule decision index +## Current facts combined with original Traitor declarations — 26 October 2026 + +Compose recorded publisher FAQp8 AND/OR/current-turn and supplied Advanced +physical39 compliance/release/unknown authority with the existing revealed +native voter interval. Facts freeze at actual answer; unknown is false for +Yes/true for No. All Reveal/Decline leaves name one actual decision, so their +correlation is exact: reduce the whole residual over both logical values, +then intersect any forced choice with original eligibility and prior answers. +Current ineligibility cannot erase a forced Decline. Constants/tautologies add +no native restriction; a bound aggregate fulfills on actual declaration before +Karama/Nexus effect prevention. Original matching-card Cunning protection and +fulfilled receipts remain. This stays provisional online composition, not a +publisher-specific mixed solver or unknown-fact correlation model. No mode gate. +[Contract and evidence](TRUTHTRANCE_MIXED_TRAITOR.md). + ## Current facts combined with current battle plans — 26 October 2026 Compose recorded publisher FAQp8 AND/OR/current-turn authority and supplied diff --git a/docs/TRUTHTRANCE_MIXED_TRAITOR.md b/docs/TRUTHTRANCE_MIXED_TRAITOR.md new file mode 100644 index 00000000..86652ff3 --- /dev/null +++ b/docs/TRUTHTRANCE_MIXED_TRAITOR.md @@ -0,0 +1,133 @@ +# Truthtrance current facts and original Traitor declarations + +## Mixed aggregate prototype — 26 October 2026 + +**Prototyped / Partial.** The packet is +`{kind:'mixedTraitorCall',target,battle,territory,mixed}`. Fact/action/AND/OR clauses +use `TraitorActionClaim={call:boolean}`: true Reveal, false Decline. Every action +leaf concerns the same current original revealed native voter decision. The +[scalar contract](TRUTHTRANCE_TRAITOR_DECISIONS.md) still owns public source, +voter/beneficiary, matching physical card, protection and effect timing. +Original pure questions and faction/module start gates stay unchanged. + +## Authority and logical meaning + +Reuse recorded publisher November2020 FAQp8 AND/OR/current-turn and supplied +Advanced physical39/printed37 compliance/release/unknown authority. The existing +native declaration interval and shared snapshot/witness interpretation remain +provisional under standing14October priority. No dedicated publisher mixed +solver, future matching draw witness or arbitrary promise language is claimed. + +Current facts all freeze at the actual answer. Later bribes, Traitor draws and +returns do not reevaluate them. Unknown is false for definite Yes and true for +No; a remaining native obligation must guarantee the aggregate. No negates the +whole expression. Unknown FACT correlations are not inferred. + +Every Reveal/Decline action leaf names one boolean decision, so its correlation +is exact. `mixedTraitorRequirement` evaluates the complete compiled tree for +both logical declarations. It returns impossible, one forced choice, or no +restriction when both values guarantee the selected aggregate. Constants and +logical tautologies are terminal immediately; this claims no declaration +receipt. A contradictory action expression can have a guaranteed whole No. +No to a Decline condition can force Reveal. Smaller subclauses never acquire +separate public answers or fixed routes. + +Reduction considers both logical values before native eligibility. A presently +unmatched/leaderless/protected actor may only Decline, but a new forced Decline +still binds through later matching-card draws. Eligibility is not used to erase +the future commitment. If no guaranteed answer has an original native completion, +unknown/save/change-question remains available. + +## One native decision and receipt + +`game/mixed-traitor-question.ts` strictly accepts only one call boolean per leaf. +Outer groups use the shared16-leaf/depth4 grammar and nested fact budget, with +no additional battle-plan node/term caps. Invalid metadata/groups cannot hide +under decisive branches. The original native context must be revealed/current, +with an actual still-unsubmitted voter and exact battle/territory. This includes +native Harkonnen ally voting and retains original Homeworld restrictions. + +A forced choice enters `traitorDecisionPromises` through the original binder, +with one optional new event link. Old pure records omit that property and +retain their shape. Validation accepts only a nonempty link when present; +settlement/reconciliation preserve it. Native choice calculation, matching +identity, Kwisatz protection, beneficiary, opposing leader and prior promises +stay authoritative. The mixed helper performs no second native eligibility or +physical card search. + +Raw `mixedTraitorQuestions` links original question/answer to the native record. +Actual declaration settles it before effect prevention or synchronous cleanup. +A later canceled effect never undoes a receipt. Native impossibility or ended +unfulfilled source releases pending aggregates; submitted true and false slots +never reopen. New mixed discard signatures retain raw metadata plus the already +signed battle/ledger without changing old pure continuation signatures. + +Harkonnen Cunning still draws/returns original physical identities. A live +required Reveal protects its matching identity from voluntary return, including +an answer added during the pending native return. A Decline can return the +matching identity; new draws do not remove its cap. After an actual fulfilled +Reveal, returning that identity does not retract the original receipt/effective +call. Existing parent signatures keep their original exemptions and unrelated +fields; no extra snapshot weakening is added. + +## Projection and human controls + +Own projection retains only original raw question, whole answer, public turn/ +battle/territory and fulfilled/released status. Binding flags, forced choices, +compiled trees and separate frozen facts are not projected. Native own pure +promise rows filter mixed event links for every viewer; private declaration +choices still use the full authoritative ledger. + +The shared full editor adds/wraps/nests/switches/removes/collapses groups and +exposes per-leaf Reveal/Decline choices without inspecting card custody or +canReveal. Invalid drafts remain repairable. Raw preview/Ask and target-only +aggregate answer choices connect to the exact current source. Saved answer +callbacks retain owner/question/window/battle/choice-array identity and busy/ +source guards. Pure wording and behavior remain. + +The mixed panel is status-only and persists after battle cleanup. Original +Reveal/Decline controls use native allowed choices; no duplicate declaration +control or fact reconstruction is added. Four minimal profiles answer and +actually declare legally; strategy/calibration remain deferred. + +## Evidence and remaining gates + +Helpers: `tests/mixed-traitor-question.test.ts`, +`tests/traitor-decision-promises.test.ts`. Source/journeys: +`tests/fixture-truth-mixed-traitor.ts`, `tests/truth-mixed-traitor-runtime.test.ts`, +`tests/truth-mixed-traitor-harkonnen.test.ts`. +Controlled/shared answer/UI: `tests/truth-mixed-traitor-controls.test.tsx`. +Frozen v2 integration passes **18 files,149/149**, types/lint/build and eleven +original runtime paths with 29 GameTable SSR renders,11 actual answer callbacks +and one editor callback followed by native Ask. Basic26 prefixes add6 for actual +declaration/cleanup, edited true OR, false AND, No-to-Decline requiring Reveal, +call tautology and both unknown polarities; a real bribe3 adds7. Allied72 adds9 +for actual Reveal, original Karama cancellation and retained receipt. Cunning77 +adds8 for matching protection/safe return/Reveal or matching return/frozen fact/ +Decline. Four minimal profiles actually answer and declare legally. Native tests +also exercise the original pre-Cunning76 prefix, real draw/return after Decline, +and fulfilled Reveal followed by a later matching return. + +V1 quick checks pass, then the new controls file needs the established Vinext/ +CSS test import shim. Loaded controls find a missing history location and an +assertion that omitted the native button arrow. Repairs also make a mixed +Decline's disabled-Reveal explanation reflect its accepted obligation. The two +failing cases pass narrowly, then the complete frozen v2 passes. Proof and +per-file manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-traitor-proof-v2/`; +journeys/HTML: adjacent `20261026-truth-mixed-traitor-runtime/`. +This is local engine/SSR/actual-callback evidence, not DOM, hydration, browser, +mobile or deployed acceptance. + +The adapters reuse original Basic26 and actual allied Harkonnen72/Cunning77 +sources, deriving counts from inherited action arrays. Original setup/deal, +Traitor choices, paid invasions, alliances, plans and draw/return stay genuine. +No extra Truth, played hand/purse/clock/vote/promise is installed. The unmatched +case labels its conserved original-card exchange as controlled rather than a +historical acquisition claim. Two-Truth/prior-ledger histories are not invented. + +Unrevealed future-leader questions, other action families, unknown-fact +correlations, broader owned preparations, optional/faction/card/cross-action +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 250e6a78..46bd2c55 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 current Traitor-decision follow-up](TRUTHTRANCE_MIXED_TRAITOR.md) +combines answer-time facts with current revealed native Reveal/Decline choices. +Original eligibility, actual declaration receipts and Cunning protection remain; +unrevealed future leaders and cross-action promises remain unfinished. + The [mixed current battle-plan follow-up](TRUTHTRANCE_MIXED_BATTLE.md) combines answer-time facts with original native plan conditions. Full raw logical budgets and actual reveal settlement preserve current battle mechanics; this diff --git a/game/bots.ts b/game/bots.ts index 35160efa..30997ed1 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1374,6 +1374,10 @@ function policyActions(g: GameView): Action[] { const answer = g.truthBidAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; } + if (w.question.kind === 'mixedTraitorCall') { + const answer = g.truthMixedTraitorAnswers?.[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'traitorCall') { const answers = g.truthTraitorAnswers ?? []; const answer = answers.find(answer => answer === 'yes') ?? answers[0]; diff --git a/game/engine.ts b/game/engine.ts index e573c393..1102cc86 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,3 +1,4 @@ +import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question'; import type { MixedBattleActionClaim, MixedBattleClaim } from './mixed-battle-question'; import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans, allianceExpressionPromisesMatch, evaluateAllianceExpression, validateAllianceExpressionPromises, @@ -2161,6 +2162,10 @@ export type Game = { }; truthtrance?: TruthWindow | null; truthHistory?: TruthRecord[]; + mixedTraitorQuestions?: (MixedTraitorClaim & { + event: string; turn: number; player: string; asker: string; answer: boolean; + binding: boolean; fulfilled: boolean; released: boolean; + })[]; mixedBattleQuestions?: (Omit & { event: string; battle: string; turn: number; player: string; asker: string; answer: boolean; binding: boolean; fulfilled: boolean; released: boolean; @@ -6937,6 +6942,7 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) { battle: g.battle, shipmentPromises: g.shipmentPromises, movementPromises: g.movementPromises, + ...(next.record.question.kind === 'mixedTraitorCall' ? { mixedTraitorQuestions: g.mixedTraitorQuestions } : {}), ...(next.record.question.kind === 'mixedBattlePlan' ? { mixedBattleQuestions: g.mixedBattleQuestions } : {}), ...(['allianceExpression', 'mixedAlliance'].includes(next.record.question.kind) ? { allianceExpressionPromises: g.allianceExpressionPromises, alliancePromiseSequence: g.alliancePromiseSequence, @@ -28198,11 +28204,58 @@ function traitorTruthAnswers(g: Game, p: Player): TruthAnswer[] { return context ? traitorDecisionRule(() => traitorDecisionChoices(context, g.battle?.traitorDecisionPromises ?? [])) .map(call => call ? 'yes' as const : 'no' as const) : []; } -function bindTraitorTruth(g: Game, p: Player, answer: boolean, asker: string) { +function bindTraitorTruth(g: Game, p: Player, answer: boolean, asker: string, event?: string) { const context = traitorTruthContext(g, p.id); requireRule(context, 'Retain the current unsubmitted Traitor decision.'); g.battle!.traitorDecisionPromises = traitorDecisionRule(() => - bindTraitorDecisionPromise(context, g.battle!.traitorDecisionPromises ?? [], asker, answer)); + bindTraitorDecisionPromise(context, g.battle!.traitorDecisionPromises ?? [], asker, answer, event)); +} +function compileMixedTraitor(g: Game, p: Player, claim: MixedTraitorClaim, answer: boolean) { + return mixedTraitorRequirement(compileMixedActionExpression(claim.mixed, + fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer), answer); +} +function mixedTraitorTruthAnswers(g: Game, p: Player, claim: MixedTraitorClaim): TruthAnswer[] { + const context = traitorTruthContext(g, p.id); + if (!context || context.submitted !== undefined || context.battle !== claim.battle || + g.battle?.territory !== claim.territory) return []; + const native = traitorTruthAnswers(g, p), answers: TruthAnswer[] = []; + for (const answer of [true, false]) { + const requirement = compileMixedTraitor(g, p, claim, answer); + if (requirement.possible && (requirement.call === null || native.includes(requirement.call ? 'yes' : 'no'))) + answers.push(answer ? 'yes' : 'no'); + } + return answers.length ? answers : ['unknown']; +} +function bindMixedTraitorTruth(g: Game, p: Player, claim: MixedTraitorClaim, answer: boolean, asker: string) { + requireRule(mixedTraitorTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'), + 'Choose a whole mixed Traitor answer compatible with the current native declaration.'); + const requirement = compileMixedTraitor(g, p, claim, answer); + requireRule(requirement.possible, 'Retain the guaranteed whole mixed Traitor answer.'); + const event = crypto.randomUUID(), binding = requirement.call !== null; + if (requirement.call !== null) bindTraitorTruth(g, p, requirement.call, asker, event); + (g.mixedTraitorQuestions ??= []).push({ battle: claim.battle, territory: claim.territory, + mixed: structuredClone(claim.mixed), event, turn: g.turn, + player: p.id, asker, answer, binding, fulfilled: !binding, released: false }); +} +function reconcileMixedTraitorQuestions(g: Game) { + const b = g.battle; + for (const question of g.mixedTraitorQuestions ?? []) { + if (!question.binding || question.fulfilled || question.released) continue; + if (!b || b.event !== question.battle) { question.released = true; continue; } + const bound = b.traitorDecisionPromises?.find(promise => promise.event === question.event); + if (bound?.released) question.released = true; + else if (bound?.fulfilled) question.fulfilled = true; + } +} +function isMixedTraitorEvent(g: Game, event: string | undefined): boolean { + return typeof event === 'string' && !!g.mixedTraitorQuestions?.some(question => question.event === event); +} +function projectedMixedTraitorPromises(g: Game, player: string) { + return (g.mixedTraitorQuestions ?? []).filter(question => question.player === player).map(question => ({ + event: question.event, player: question.player, asker: question.asker, turn: question.turn, + battle: question.battle, territory: question.territory, mixed: structuredClone(question.mixed), answer: question.answer, + fulfilled: question.fulfilled, released: question.released, + })); } function traitorTruthIntegrity(g: Game) { const b = g.battle, promises = b?.traitorDecisionPromises; @@ -28219,13 +28272,14 @@ function traitorTruthIntegrity(g: Game) { } } const question = g.truthtrance?.question; - if (question?.kind === 'traitorCall') { + if (question?.kind === 'traitorCall' || question?.kind === 'mixedTraitorCall') { const context = traitorTruthContext(g, question.target); requireRule(context && context.submitted === undefined && context.battle === question.battle, 'This Truthtrance requires the original unsubmitted native Traitor voter.'); } } function reconcileTraitorTruth(g: Game, actor?: string, action?: Action) { + reconcileMixedTraitorQuestions(g); const b = g.battle; if (!b?.traitorDecisionPromises?.length) return; for (const player of new Set(b.traitorDecisionPromises.map(promise => promise.player))) { @@ -28240,6 +28294,7 @@ function reconcileTraitorTruth(g: Game, actor?: string, action?: Action) { } b.traitorDecisionPromises = next; } + reconcileMixedTraitorQuestions(g); } function traitorReturnPromiseBlock(g: Game, p: Player, returned: readonly string[]): string | null { const promises = g.battle?.traitorDecisionPromises; @@ -36841,6 +36896,8 @@ function applyActionInner( bindMixedBid: bindMixedBidTruth, bidAnswers: bidTruthAnswers, bindBid: bindBidTruth, + mixedTraitorAnswers: mixedTraitorTruthAnswers, + bindMixedTraitor: bindMixedTraitorTruth, traitorAnswers: traitorTruthAnswers, bindTraitor: bindTraitorTruth, mixedBattleAnswers: mixedBattleTruthAnswers, @@ -40291,6 +40348,7 @@ function applyActionInner( const context = traitorTruthContext(g, id); if (context && b.traitorDecisionPromises) b.traitorDecisionPromises = traitorDecisionRule(() => settleTraitorDecisionPromises(context, b.traitorDecisionPromises!, action.call as boolean)); + reconcileMixedTraitorQuestions(g); if (g.nexusCards?.cards || b.ecazOccupy) ensureTraitorDeclarations(g); if (action.call) { const blocked = traitorCallBlock(g, b, id); @@ -40629,6 +40687,10 @@ export function viewGame(state: Game, id: string) { ...(g.advanced&&g.decision?.kind==='advisor'&&g.decision.player===id ? {spiritualAdvisorChoices:spiritualAdvisorChoices(g,id)} : {}), truthMovementTargets: g.players.filter(p => movementPromiseTargetAvailable(g, p.id)).map(p => p.id), + truthMixedTraitorTargets: g.players.filter(p => { const context = traitorTruthContext(g, p.id); return context && context.submitted === undefined; }).map(p => p.id), + truthMixedTraitorAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'mixedTraitorCall' && + g.truthtrance.question.target === id ? mixedTraitorTruthAnswers(g, me, g.truthtrance.question) : null, + mixedTraitorPromises: projectedMixedTraitorPromises(g, id), truthTraitorTargets: g.players.filter(p => { const context = traitorTruthContext(g, p.id); return context && context.submitted === undefined; }).map(p => p.id), truthTraitorAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'traitorCall' && g.truthtrance.question.target === id ? traitorTruthAnswers(g, me) : null, @@ -41882,7 +41944,7 @@ export function viewGame(state: Game, id: string) { traitorSubmitted: Object.keys(b.traitorCalls), traitorCallChoices: (() => { const context = traitorTruthContext(g, id); return context ? traitorDecisionRule(() => traitorDecisionChoices(context, b.traitorDecisionPromises ?? [])) : null; })(), - ownTraitorPromises: (b.traitorDecisionPromises ?? []).filter(promise => promise.player === id), + ownTraitorPromises: (b.traitorDecisionPromises ?? []).filter(promise => promise.player === id && !isMixedTraitorEvent(g, promise.event)), traitorVoters: traitorVoters(g, b), preparation: b.preparation && g.homeworldOccupationPreview && b.preparation.kind === 'voice' ? { ...b.preparation, blocked: homeworldRule(() => quoteOccupiedHomeworldVoice(g, diff --git a/game/mixed-traitor-question.ts b/game/mixed-traitor-question.ts new file mode 100644 index 00000000..d0f3a279 --- /dev/null +++ b/game/mixed-traitor-question.ts @@ -0,0 +1,47 @@ +import type { CompiledMixedAction, MixedActionExpression } from './mixed-action-question'; +import { TraitorDecisionError } from './traitor-decision-promises'; + +export const MIXED_TRAITOR_MAX_LEAVES = 16; +export const MIXED_TRAITOR_MAX_DEPTH = 4; +export type TraitorActionClaim = { call: boolean }; +export type MixedTraitorClaim = { + battle: string; + territory: string; + mixed: MixedActionExpression; +}; + +/** One original native declaration; source and frozen metadata stay outside. */ +export function parseTraitorActionClaim(value: unknown): TraitorActionClaim { + if (!value || typeof value !== 'object' || Array.isArray(value) || + Object.keys(value).join(',') !== 'call' || typeof (value as Record).call !== 'boolean') + throw new TraitorDecisionError('Supply one Reveal or Decline condition without battle metadata or groups.'); + return { call: (value as TraitorActionClaim).call }; +} + +type TraitorOutcomes = { decline: boolean; reveal: boolean }; +function declarationOutcomes(node: CompiledMixedAction): TraitorOutcomes { + if (node.kind === 'constant') return { decline: node.value, reveal: node.value }; + if (node.kind === 'action') { + const { call } = parseTraitorActionClaim(node.action); + return { decline: !call, reveal: call }; + } + // Evaluate every child before combining, including correlated opposite calls. + const terms = node.terms.map(declarationOutcomes); + return node.kind === 'and' + ? { decline: terms.every(term => term.decline), reveal: terms.every(term => term.reveal) } + : { decline: terms.some(term => term.decline), reveal: terms.some(term => term.reveal) }; +} + +/** Exact logical residual over both declarations. The caller intersects a forced + * choice with native eligibility; current inability to Reveal must not erase a + * forced Decline that remains binding after a later matching Traitor draw. */ +export function mixedTraitorRequirement( + node: CompiledMixedAction, + answer: boolean, +): { possible: false } | { possible: true; call: boolean | null } { + if (typeof answer !== 'boolean') throw new TraitorDecisionError('Choose a definite mixed Traitor answer.'); + const outcomes = declarationOutcomes(node); + const decline = outcomes.decline === answer, reveal = outcomes.reveal === answer; + if (!decline && !reveal) return { possible: false }; + return { possible: true, call: decline && reveal ? null : reveal }; +} diff --git a/game/reference.ts b/game/reference.ts index b801810a..fd374b24 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3185,6 +3185,31 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, compound privacy, bots, JSON and production SQLite restoration; broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] }, ], }, + { + id: 'truthtrance-mixed-traitor', + title: 'Truthtrance: current facts and this Traitor declaration', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Combine answer-time facts with the original current revealed-battle Reveal or Decline decision, preserving matching eligibility and declaration receipts.', + steps: [ + 'Choose Combine current facts and this Traitor declaration. Add full AND/OR groups with both current facts and Reveal/Decline conditions. All declaration leaves concern the same original unsubmitted native voter in this revealed battle, including a native allied Harkonnen voter.', + 'Current facts freeze at the actual answer. Later spice payments, matching-card draws or returns do not create a new factual answer. Unknown facts cannot be relied on being true for Yes or false for No; No negates the complete expression.', + 'The two possible declaration values are checked together. A whole answer may force Reveal, force Decline, or already be guaranteed for either choice. A constant or tautology needs no native restriction. No to a promise to decline can require Reveal.', + 'A forced value uses the original matching physical Traitor, Kwisatz/leader protection, voter/beneficiary and prior-promise rules. Current inability to Reveal does not erase a Decline commitment: it stays binding if a later native draw makes Reveal possible.', + 'The actual original declaration fulfills a binding answer before later Karama or Nexus betrayal changes its effect. A prevented effect never retracts the receipt. Submitted true and false votes are never reopened.', + 'During Harkonnen Cunning, a live required Reveal protects the matching identity from a voluntary return. Decline may return that identity. After a fulfilled declaration, the original return rules apply and the receipt remains.', + 'Full mixed editing, raw preview/Ask, private aggregate answer choices and persistent own status connect to the original compatible declaration buttons and four minimal profiles. Reduced choices and individual frozen facts are not projected as extra answers. Unrevealed future-leader questions, broader preparations/compositions and final deployed acceptance remain unfinished.', + ], + related: ['card-truthtrance', 'truthtrance-traitor-decision', 'truthtrance-mixed-battle', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Exact logical reduction of one current decision binds only a forced original native declaration. Actual receipt/release updates own raw aggregate without exposing compiled choice or frozen facts.', evidence: ['game/mixed-traitor-question.ts', 'game/traitor-decision-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/mixed-traitor-question.test.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Full shared editor and exact source-aware aggregate answer callbacks use public context. Original private legal-choice buttons and own persistent status stay connected.', evidence: ['components/mixed-traitor-fields.tsx', 'components/mixed-traitor-promises.tsx', 'components/traitor-decision-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-mixed-traitor-controls.test.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and declare through original compatible choices; full strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-traitor-runtime.test.ts'] }, + { area: 'Documentation', status: 'Implemented', detail: 'Snapshot/unknown, opposite-call logic, native eligibility/protection, declaration-versus-effect and remaining boundaries are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_TRAITOR.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Focused pure logical/event, original Basic/allied/Cunning and controlled editing/answer cases; wider faction/module/composition and final deployed acceptance remain open.', evidence: ['tests/mixed-traitor-question.test.ts', 'tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-mixed-traitor.ts', 'tests/truth-mixed-traitor-runtime.test.ts', 'tests/truth-mixed-traitor-harkonnen.test.ts', 'tests/truth-mixed-traitor-controls.test.tsx'] }, + ], + }, { id: 'truthtrance-traitor-decision', title: 'Truthtrance: this battle’s Traitor declaration', diff --git a/game/traitor-decision-promises.ts b/game/traitor-decision-promises.ts index f879bd0b..3c8aecb5 100644 --- a/game/traitor-decision-promises.ts +++ b/game/traitor-decision-promises.ts @@ -13,6 +13,8 @@ export type TraitorDecisionContext = { export type TraitorDecisionPromise = Omit & { asker: string; answer: boolean; + /** Link to the owner's mixed-question record; pure declaration receipts omit it. */ + event?: string; fulfilled?: boolean; released?: boolean; }; @@ -40,11 +42,12 @@ export function validateTraitorDecisionPromise(value: unknown): value is Traitor if (!record(value)) return false; const required = ['answer', 'asker', 'battle', 'beneficiary', 'leader', 'player', 'target', 'turn']; if (!required.every(key => Object.hasOwn(value, key)) || - Object.keys(value).some(key => !required.includes(key) && key !== 'fulfilled' && key !== 'released')) return false; + Object.keys(value).some(key => !required.includes(key) && key !== 'event' && key !== 'fulfilled' && key !== 'released')) return false; return turnNumber(value.turn) && identifier(value.battle) && identifier(value.player) && identifier(value.beneficiary) && identifier(value.target) && (value.leader === null || identifier(value.leader)) && identifier(value.asker) && - typeof value.answer === 'boolean' && optionalBoolean(value.fulfilled) && optionalBoolean(value.released); + typeof value.answer === 'boolean' && (!Object.hasOwn(value, 'event') || identifier(value.event)) && + optionalBoolean(value.fulfilled) && optionalBoolean(value.released); } function validateContext(context: TraitorDecisionContext) { @@ -85,13 +88,16 @@ export function bindTraitorDecisionPromise( promises: readonly TraitorDecisionPromise[], asker: string, answer: boolean, + event?: string, ): TraitorDecisionPromise[] { - requireDecision(identifier(asker) && typeof answer === 'boolean', 'Choose a definite Traitor decision answer and its asker.'); + requireDecision(identifier(asker) && typeof answer === 'boolean' && (event === undefined || identifier(event)), + 'Choose a definite Traitor decision answer, its asker and an optional public event.'); requireDecision(traitorDecisionChoices(context, promises).includes(answer), 'This answer has no compatible current Traitor declaration.'); return [...promises.map(promise => ({ ...promise })), { turn: context.turn, battle: context.battle, player: context.player, beneficiary: context.beneficiary, target: context.target, leader: context.leader, asker, answer, + ...(event === undefined ? {} : { event }), }]; } diff --git a/game/truthtrance.ts b/game/truthtrance.ts index d81767a6..4baf60ad 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -1,3 +1,4 @@ +import { parseTraitorActionClaim, type MixedTraitorClaim } from './mixed-traitor-question'; import { parseBattleActionClaim, MIXED_BATTLE_MAX_NODES, MIXED_BATTLE_MAX_TERMS, MIXED_BATTLE_MAX_DEPTH, MIXED_BATTLE_MAX_LEAVES, type MixedBattleClaim } from './mixed-battle-question'; import { parseAllianceExpressionClaim, allianceExpressionText, type AllianceExpressionClaim } from './alliance-expression-promises'; @@ -77,6 +78,7 @@ export type TruthFact = | { kind: 'and' | 'or'; terms: TruthFact[] }; export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression }; export type TruthQuestion = + | ({ kind: 'mixedTraitorCall'; target: string } & MixedTraitorClaim) | ({ kind: 'mixedBattlePlan'; target: string } & MixedBattleClaim) | ({ kind: 'allianceExpression'; target: string } & AllianceExpressionClaim) | ({ kind: 'mixedAlliance'; target: string } & MixedAllianceClaim) @@ -368,6 +370,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { 'Choose the current ordinary bid lot in a supported table and finish pending decisions.'); return { kind: 'bid', target: v.target, ...claim }; } + if (v.kind === 'mixedTraitorCall') { + check(Object.keys(v).sort().join(',') === 'battle,kind,mixed,target,territory' && + g.status === 'playing' && g.phase === 6 && g.battle?.revealed && typeof v.battle === 'string' && + v.battle.length > 0 && v.battle === g.battle.event && v.territory === g.battle.territory && + g.battle.traitorCalls[v.target as string] === undefined, + 'Choose the current revealed battle and an original unsubmitted Traitor decision.'); + const respondent = g.players.find(player => player.id === v.target)!; + return { kind: 'mixedTraitorCall', target: v.target as string, battle: v.battle, + territory: v.territory as string, + mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseTraitorActionClaim) }; + } if (v.kind === 'traitorCall') { check(Object.keys(v).sort().join(',') === 'battle,kind,target,territory' && g.status === 'playing' && g.phase === 6 && g.battle?.revealed && @@ -657,6 +670,9 @@ 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 === 'mixedTraitorCall') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, + action => action.call ? 'you declare the opposing leader a Traitor in this battle' + : 'you decline to declare the opposing leader a Traitor in this battle')}?`; if (q.kind === 'mixedBattlePlan') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, action => `${planClaimText(action, leaderName)} in this battle`)}?`; if (q.kind === 'mixedAlliance') return `Is it true that ${mixedActionExpressionText(q.mixed, clause, @@ -723,6 +739,8 @@ export function resolveTruthAction( bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) => void; bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[]; bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void; + mixedTraitorAnswers?: (g: Game, p: Player, claim: MixedTraitorClaim) => TruthAnswer[]; + bindMixedTraitor?: (g: Game, p: Player, claim: MixedTraitorClaim, answer: boolean, asker: string) => void; traitorAnswers?: (g: Game, p: Player) => TruthAnswer[]; bindTraitor?: (g: Game, p: Player, answer: boolean, asker: string) => void; mixedBattleAnswers?: (g: Game, p: Player, claim: MixedBattleClaim) => TruthAnswer[]; @@ -888,6 +906,9 @@ export function resolveTruthAction( if (w.question.kind === 'bid') check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose a bidder with a remaining ordinary opportunity in this lot.'); + if (w.question.kind === 'mixedTraitorCall') + check(effects.mixedTraitorAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, + 'Choose a whole mixed Traitor question for an actual current voter.'); if (w.question.kind === 'traitorCall') check(effects.traitorAnswers?.(g, g.players.find(other => other.id === w.question!.target)!).length, 'Choose an actual unsubmitted native Traitor voter.'); @@ -1009,6 +1030,11 @@ export function resolveTruthAction( 'Choose an answer consistent with this lot, your legal bids and earlier answers.'); if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player); } + if (q.kind === 'mixedTraitorCall') { + check(effects.mixedTraitorAnswers && effects.bindMixedTraitor && effects.mixedTraitorAnswers(g, p, q).includes(a.answer), + 'Choose an aggregate answer compatible with this mixed question and current native declaration.'); + if (a.answer !== 'unknown') effects.bindMixedTraitor(g, p, q, a.answer === 'yes', current.player); + } if (q.kind === 'traitorCall') { check(effects.traitorAnswers && effects.bindTraitor && effects.traitorAnswers(g, p).includes(a.answer), 'Choose an answer compatible with your current legal Traitor declaration and earlier answers.'); diff --git a/tests/fixture-truth-mixed-traitor.ts b/tests/fixture-truth-mixed-traitor.ts new file mode 100644 index 00000000..aeed4be5 --- /dev/null +++ b/tests/fixture-truth-mixed-traitor.ts @@ -0,0 +1,149 @@ +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 { TraitorActionClaim } from '../game/mixed-traitor-question'; +import type { TruthAnswer, TruthFact } from '../game/truthtrance'; +import { + createTruthTraitorDecisionFixture, actTruthTraitorDecision, + nextTruthTraitorDecision, truthTraitorDecisionClean, truthTraitorDecisionInventory, + truthTraitorDecisionPlayer, truthTraitorDecisionTraitors, + type TruthTraitorDecisionFixture, type TruthTraitorDecisionStep, +} from './fixture-truth-traitor-decision'; +import { + createTruthTraitorHarkonnenFixture, nextTruthTraitorHarkonnen, + type TruthTraitorHarkonnenFixture, +} from './fixture-truth-traitor-harkonnen'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type TruthMixedTraitorExpression = MixedActionExpression; +export type TruthMixedTraitorStep = TruthTraitorDecisionStep; +export const actTruthMixedTraitor = actTruthTraitorDecision; +export const truthMixedTraitorClean = truthTraitorDecisionClean; +export const truthMixedTraitorInventory = truthTraitorDecisionInventory; +export const truthMixedTraitorPlayer = truthTraitorDecisionPlayer; +export const truthMixedTraitorTraitors = truthTraitorDecisionTraitors; +export const truthMixedTraitorFact = (fact: TruthFact): TruthMixedTraitorExpression => ({ kind: 'fact', fact }); +export const truthMixedTraitorCall = (call: boolean): TruthMixedTraitorExpression => ({ kind: 'action', action: { call } }); +export const truthMixedTraitorAction = (action: TraitorActionClaim): TruthMixedTraitorExpression => ({ kind: 'action', action }); +export const truthMixedTraitorGroup = (kind: 'and' | 'or', ...terms: TruthMixedTraitorExpression[]): TruthMixedTraitorExpression => + ({ kind, terms }); + +function adaptTruthMixedTraitor( + base: T, source: Game, prefix: TruthMixedTraitorStep[], mixed?: TruthMixedTraitorExpression, +) { + const game = structuredClone(source), actions = structuredClone(prefix); + assert.ok(game.battle?.revealed && game.battle.event); + assert.equal(game.battle.traitorCalls[base.target], undefined); + const event = game.battle.event; + const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthMixedTraitorPlayer(game, base.target).spice }; + const absentFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthMixedTraitorPlayer(game, base.target).spice + 1 }; + const heldFact: TruthFact = { kind: 'traitor', leader: base.traitorHeld }; + const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 }; + const expression = structuredClone(mixed ?? truthMixedTraitorGroup('and', truthMixedTraitorFact(spiceFact), truthMixedTraitorCall(true))); + const question = { kind: 'mixedTraitorCall' as const, target: base.target, battle: event, + territory: base.territory, mixed: expression }; + const questionAction = { type: 'truthAsk', question } satisfies Action; + return { ...base, base, game, source: structuredClone(game), event, battle: event, + mixed: expression, question, questionAction, askStep: { actor: base.asker, action: questionAction }, + spiceFact, trueFact: spiceFact, absentFact, falseFact: absentFact, heldFact, forecastFact, + sourceActions: actions, actions, steps: actions, sourceActionCount: actions.length, + sourceCounts: { inherited: base.sourceActionCount, selected: actions.length }, + sourceDisclosure: `Original public Basic base33 source with ${game.players.length} seats, ${actions.length} actual actions, at its earned revealed/unsubmitted native Traitor decision. The inherited source selects only unopened original cards and scalar lotteries. This adapter changes only the raw mixed question; declarations, answers, votes, payments, Nexus returns and cleanup remain actual caller actions.`, + staging: [...base.staging, + 'Thin mixed Traitor adapter: retain the exact original revealed battle, matching physical identity and unsubmitted native voter. No played hand, purse, clock, alliance, offer, vote, promise, fact result or residual is installed. The source owns one actual Truthtrance; no second copy or Ghola is supplied.'], + }; +} + +/** Exact original two-seat Basic source; all original 26 actions are inherited. */ +export function createTruthMixedTraitorFixture(options: { mixed?: TruthMixedTraitorExpression } = {}) { + const base = createTruthTraitorDecisionFixture(); + return adaptTruthMixedTraitor(base, base.game, base.actions, options.mixed); +} +export type TruthMixedTraitorFixture = ReturnType; + +/** Original three-seat source: earned remote ally, pending Cunning return, or + * the exact prefix immediately before the original Cunning draw. */ +export function createTruthMixedTraitorHarkonnenFixture(options: { + kind?: 'ally' | 'cunning'; window?: 'pendingReturn' | 'beforeCunning'; mixed?: TruthMixedTraitorExpression; +} = {}) { + const base = createTruthTraitorHarkonnenFixture({ kind: options.kind }); + const beforeCunning = options.kind === 'cunning' && options.window === 'beforeCunning'; + return adaptTruthMixedTraitor(base, beforeCunning ? base.beforeCunning : base.game, + beforeCunning ? base.actions.slice(0, base.beforeCunningActionCount) : base.actions, options.mixed); +} +export type TruthMixedTraitorHarkonnenFixture = ReturnType; +type MixedFixture = TruthMixedTraitorFixture | TruthMixedTraitorHarkonnenFixture; + +/** Priority may advance, but the caller retains Truth, votes and returns. */ +export function nextTruthMixedTraitor(game: Game): TruthMixedTraitorStep | null { + return game.nexusCards ? nextTruthTraitorHarkonnen(game) : nextTruthTraitorDecision(game); +} +export const nextTruthMixedTraitorNativeStep = nextTruthMixedTraitor; +export function advanceTruthMixedTraitor(game: Game, until: (state: Game) => boolean, + actions: TruthMixedTraitorStep[] = []): Game { + for (let count = 0; count < 800; count++) { + if (until(game)) return game; + const step = nextTruthMixedTraitor(game); + assert.ok(step, 'Choose the original Truthtrance, native Traitor vote or pending Nexus return'); + game = actTruthMixedTraitor(game, step.actor, step.action, actions); + } + throw Error('The original mixed Traitor source did not reach its finite boundary'); +} +export function settleTruthMixedTraitor(game: Game, actions: TruthMixedTraitorStep[] = []): Game { + return advanceTruthMixedTraitor(game, truthMixedTraitorClean, actions); +} +export function declareTruthMixedTraitor(f: MixedFixture, source = f.game) { + const before = structuredClone(source), actions: TruthMixedTraitorStep[] = []; + let game = actTruthMixedTraitor(before, f.asker, { type: 'card', card: f.truth }, actions); + const opened = structuredClone(game); + for (const player of game.players) if (!game.truthtrance!.passed.includes(player.id)) + game = actTruthMixedTraitor(game, player.id, { type: 'truthPass' }, actions); + return { game, before, opened, beforeQuestion: structuredClone(game), actions }; +} +export const createTruthMixedTraitorAskSource = declareTruthMixedTraitor; +export function askTruthMixedTraitor(game: Game, asker: string, target: string, mixed: TruthMixedTraitorExpression, + actions: TruthMixedTraitorStep[] = []): Game { + assert.ok(game.battle?.event); + return actTruthMixedTraitor(game, asker, { type: 'truthAsk', question: { + kind: 'mixedTraitorCall', target, battle: game.battle.event, + territory: game.battle.territory, mixed: structuredClone(mixed), + } }, actions); +} +export function answerTruthMixedTraitor(game: Game, target: string, answer: TruthAnswer, + actions: TruthMixedTraitorStep[] = []): Game { + assert.ok(viewGame(game, target).truthMixedTraitorAnswers?.includes(answer)); + return actTruthMixedTraitor(game, target, { type: 'truthAnswer', answer }, actions); +} +export function bindTruthMixedTraitor(f: MixedFixture, answer: 'yes' | 'no' = 'yes', + mixed = f.mixed, source = f.game) { + const declared = declareTruthMixedTraitor(f, source), actions = declared.actions; + const asked = askTruthMixedTraitor(declared.game, f.asker, f.target, mixed, actions); + const game = answerTruthMixedTraitor(asked, f.target, answer, actions); + return { game, declared, asked, actions, actionCount: actions.length }; +} +export function voteTruthMixedTraitor(game: Game, actor: string, call: boolean, + actions: TruthMixedTraitorStep[] = []): Game { + return actTruthMixedTraitor(game, actor, { type: 'traitorCall', call }, actions); +} +export const nativeVoteTruthMixedTraitor = voteTruthMixedTraitor; + +/** Finish the actual remaining votes, responses and cleanup. Bound target votes + * must already have been submitted explicitly by the caller. */ +export function finishTruthMixedTraitor(game: Game, f: Pick, + actions: TruthMixedTraitorStep[] = []): Game { + for (let count = 0; count < 100; count++) { + if (!game.battle || game.battle.event !== f.event) return game; + assert.notEqual(game.battle.traitorCalls[f.target], undefined, 'Declare the bound native vote before cleanup'); + assert.ok(!game.truthtrance && !game.nexusTraitorPending, 'Finish the original Truthtrance or Nexus return'); + const step = nextStrongholdFactionsNativeStep(game); + assert.ok(step, 'Finish the original revealed battle control'); + game = actTruthMixedTraitor(game, step.actor, step.action, actions); + } + throw Error('The original mixed Traitor battle cleanup did not finish'); +} +export function truthMixedTraitorPromise(game: Game, + target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) { + const row = viewGame(game, target).mixedTraitorPromises.find(row => row.player === target); + assert.ok(row, 'The respondent retains its original raw mixed question and terminal status'); + return row; +} diff --git a/tests/mixed-traitor-question.test.ts b/tests/mixed-traitor-question.test.ts new file mode 100644 index 00000000..cee4edf6 --- /dev/null +++ b/tests/mixed-traitor-question.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + compileMixedActionExpression, mixedActionExpressionSize, MixedActionQuestionError, + parseMixedActionExpression, type CompiledMixedAction, type MixedActionExpression, +} from '../game/mixed-action-question'; +import { + MIXED_TRAITOR_MAX_DEPTH, MIXED_TRAITOR_MAX_LEAVES, mixedTraitorRequirement, + parseTraitorActionClaim, type MixedTraitorClaim, type TraitorActionClaim, +} from '../game/mixed-traitor-question'; +import { + bindTraitorDecisionPromise, TraitorDecisionError, traitorDecisionChoices, + type TraitorDecisionContext, +} from '../game/traitor-decision-promises'; +import type { TruthAnswer, TruthFact } from '../game/truthtrance'; + +type Expression = MixedActionExpression; +type Compiled = CompiledMixedAction; +const action = (call: boolean): Expression & Compiled => ({ kind: 'action', action: { call } }); +const fact = (value = 5): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value } }); +const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms }); +const compiledGroup = (kind: 'and' | 'or', ...terms: Compiled[]): Compiled => ({ kind, terms }); +const constant = (value: boolean): Compiled => ({ kind: 'constant', value }); +const parse = (input: unknown): Expression => parseMixedActionExpression(input, + value => structuredClone(value) as TruthFact, parseTraitorActionClaim); +const context = (canReveal: boolean): TraitorDecisionContext => ({ + turn: 4, battle: 'battle-event-7', player: 'harkonnen', beneficiary: 'atreides', + target: 'emperor', leader: 'aramsham', canReveal, +}); + +void test('strict Traitor leaves copy only the original native Reveal or Decline and metadata stays on the question', () => { + for (const call of [false, true]) { + const original = { call }; + const parsed = parseTraitorActionClaim(original); + assert.deepEqual(parsed, original); + assert.notEqual(parsed, original); + } + const claim: MixedTraitorClaim = { + battle: 'battle-event-7', territory: 'arrakeen', mixed: group('and', fact(), action(true)), + }; + assert.deepEqual(parse(claim.mixed), claim.mixed); + assert.deepEqual(Object.keys(parseTraitorActionClaim({ call: false })), ['call']); +}); + +void test('strict Traitor leaves reject source, frozen values, wrappers, groups and malformed booleans even after decisive facts', () => { + const invalid: unknown[] = [null, [], {}, true, { call: undefined }, { call: null }, + { call: 0 }, { call: 'false' }, { call: Object(false) }, + { call: true, battle: 'battle-event-7' }, { call: false, territory: 'arrakeen' }, + { call: true, target: 'emperor' }, { call: true, turn: 4 }, { call: true, leader: 'aramsham' }, + { call: true, event: 'private-link' }, { call: false, answer: true }, + { call: true, fulfilled: true }, { call: true, released: false }, { call: true, canReveal: true }, + { kind: 'constant', value: true }, { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } }, + { kind: 'action', action: { call: true } }, { kind: 'and', terms: [{ call: true }, { call: false }] }, + { kind: 'or', terms: [] }]; + for (const value of invalid) { + assert.throws(() => parseTraitorActionClaim(value), TraitorDecisionError); + for (const kind of ['and', 'or'] as const) assert.throws(() => parse(group(kind, fact(), + { kind: 'action', action: value } as Expression)), TraitorDecisionError); + } +}); + +void test('both whole answers retain exact truth tables through every nested AND or OR combination and constants', () => { + const cases: { node: Compiled; decline: boolean; reveal: boolean }[] = [ + { node: constant(false), decline: false, reveal: false }, + { node: constant(true), decline: true, reveal: true }, + { node: action(false), decline: true, reveal: false }, + { node: action(true), decline: false, reveal: true }, + ]; + for (const outer of ['and', 'or'] as const) for (const inner of ['and', 'or'] as const) { + for (const first of [false, true]) for (const second of [false, true]) for (const third of [false, true]) { + const evaluate = (choice: boolean) => { + const left = inner === 'and' ? choice === first && choice === second : choice === first || choice === second; + return outer === 'and' ? left && choice === third : left || choice === third; + }; + cases.push({ node: compiledGroup(outer, compiledGroup(inner, action(first), action(second)), action(third)), + decline: evaluate(false), reveal: evaluate(true) }); + } + } + for (const { node, decline, reveal } of cases) for (const answer of [false, true]) { + const expected = decline !== answer && reveal !== answer ? { possible: false } + : { possible: true, call: decline === answer && reveal === answer ? null : reveal === answer }; + assert.deepEqual(mixedTraitorRequirement(node, answer), expected, JSON.stringify({ node, answer })); + } + assert.throws(() => mixedTraitorRequirement(action(true), 'yes' as unknown as boolean), TraitorDecisionError); +}); + +void test('opposite-call correlations use both logical choices while a forced Decline remains binding after native eligibility changes', () => { + const tautology = compiledGroup('or', action(true), action(false)); + const contradiction = compiledGroup('and', action(true), action(false)); + assert.deepEqual(mixedTraitorRequirement(tautology, true), { possible: true, call: null }); + assert.deepEqual(mixedTraitorRequirement(tautology, false), { possible: false }); + assert.deepEqual(mixedTraitorRequirement(contradiction, false), { possible: true, call: null }); + assert.deepEqual(mixedTraitorRequirement(contradiction, true), { possible: false }); + assert.deepEqual(traitorDecisionChoices(context(false), []), [false]); + const required = mixedTraitorRequirement(compiledGroup('and', constant(true), action(false)), true); + assert.deepEqual(required, { possible: true, call: false }); + assert(required.possible && required.call !== null); + const promises = bindTraitorDecisionPromise(context(false), [], 'beneGesserit', required.call); + assert.deepEqual(traitorDecisionChoices(context(false), promises), [false]); + assert.deepEqual(traitorDecisionChoices(context(true), promises), [false], 'A later matching card cannot erase the original Decline'); + assert.deepEqual(mixedTraitorRequirement(action(true), true), { possible: true, call: true }, + 'Current native eligibility remains the caller’s separate intersection'); +}); + +void test('compilation snapshots every current fact before exact native reduction without mutating the public or compiled trees', () => { + const mixed = group('and', group('or', fact(5), action(false)), group('or', fact(9), action(true))); + const before = structuredClone(mixed), reads: number[] = []; + const residual = compileMixedActionExpression(mixed, current => { + assert(current.kind === 'spice'); + reads.push(current.value); + return current.value === 5 ? 'yes' : 'no'; + }, true); + assert.deepEqual(reads, [5, 9]); + assert.deepEqual(residual, action(true)); + const frozen = structuredClone(residual); + assert.deepEqual(mixedTraitorRequirement(residual, true), { possible: true, call: true }); + assert.deepEqual(mixedTraitorRequirement(residual, false), { possible: true, call: false }); + assert.deepEqual(mixed, before); + assert.deepEqual(residual, frozen); + const decisiveReads: number[] = []; + const decisive = compileMixedActionExpression(group('or', fact(5), group('and', fact(9), action(false))), current => { + assert(current.kind === 'spice'); decisiveReads.push(current.value); return 'yes'; + }, true); + assert.deepEqual(decisiveReads, [5, 9]); + assert.deepEqual(mixedTraitorRequirement(decisive, true), { possible: true, call: null }); +}); + +void test('unknown facts keep the conservative false-for-Yes or true-for-No snapshot without guessing their correlations', () => { + for (const answer of [false, true]) for (const call of [false, true]) for (const kind of ['and', 'or'] as const) { + const raw = group(kind, fact(), action(call)); + const before = structuredClone(raw); + const snapshots: TruthAnswer[] = []; + const compiled = compileMixedActionExpression(raw, () => { snapshots.push('unknown'); return 'unknown'; }, answer); + const permitsAction = answer ? kind === 'or' : kind === 'and'; + assert.deepEqual(mixedTraitorRequirement(compiled, answer), permitsAction + ? { possible: true, call: answer ? call : !call } : { possible: false }); + assert.deepEqual(snapshots, ['unknown']); + assert.deepEqual(raw, before); + } +}); + +void test('mixed Traitor questions retain the generic sixteen-leaf and depth-four budgets including embedded fact groups', () => { + assert.equal(MIXED_TRAITOR_MAX_LEAVES, 16); + assert.equal(MIXED_TRAITOR_MAX_DEPTH, 4); + const atLeaves = group('and', fact(), ...Array.from({ length: 15 }, () => action(true))); + assert.deepEqual(parse(atLeaves), atLeaves); + assert.equal(mixedActionExpressionSize(atLeaves).leaves, 16); + assert.throws(() => parse(group('and', atLeaves, action(false))), MixedActionQuestionError); + const embedded: Expression = { kind: 'fact', fact: { kind: 'or', terms: + Array.from({ length: 15 }, () => ({ kind: 'spice', compare: 'gte', value: 5 })), + } }; + assert.deepEqual(parse(group('or', embedded, action(false))), group('or', embedded, action(false))); + assert.throws(() => parse(group('and', embedded, action(false), action(true))), MixedActionQuestionError); + let atDepth = group('and', fact(), action(true)); + for (let depth = 1; depth < 4; depth++) atDepth = group('or', action(false), atDepth); + assert.equal(mixedActionExpressionSize(atDepth).depth, 4); + assert.deepEqual(parse(atDepth), atDepth); + assert.throws(() => parse(group('and', action(true), atDepth)), MixedActionQuestionError); +}); diff --git a/tests/traitor-decision-promises.test.ts b/tests/traitor-decision-promises.test.ts index d8dbf6b9..48fe1dd4 100644 --- a/tests/traitor-decision-promises.test.ts +++ b/tests/traitor-decision-promises.test.ts @@ -134,12 +134,15 @@ void test('promise validation accepts only the bounded public source and typed f assert.equal(validateTraitorDecisionPromise(promise({ fulfilled: false, released: false })), true); assert.equal(validateTraitorDecisionPromise(promise({ fulfilled: true })), true); assert.equal(validateTraitorDecisionPromise(promise({ released: true })), true); + assert.equal(validateTraitorDecisionPromise(promise({ event: 'mixed-traitor-event' })), true); for (const invalid of [ null, [], 'promise', 4, {}, { ...promise(), turn: 0 }, { ...promise(), turn: 1.5 }, { ...promise(), turn: Number.MAX_SAFE_INTEGER + 1 }, { ...promise(), battle: '' }, { ...promise(), player: ' ' }, { ...promise(), beneficiary: null }, { ...promise(), target: 4 }, { ...promise(), leader: '' }, { ...promise(), asker: '' }, { ...promise(), answer: 'yes' }, { ...promise(), fulfilled: 1 }, { ...promise(), released: 'false' }, + { ...promise(), event: '' }, { ...promise(), event: ' ' }, { ...promise(), event: null }, + { ...promise(), event: 7 }, { ...promise(), event: undefined }, { ...promise(), canReveal: true }, { ...promise(), submitted: false }, { ...promise(), card: 'private-card' }, { ...promise(), hand: [] }, ]) { @@ -152,6 +155,42 @@ void test('promise validation accepts only the bounded public source and typed f } }); +void test('an optional mixed event links only its new native promise and survives cloning, fulfillment and release', () => { + const ctx = context(); + const historical = promise({ battle: 'older-battle', event: 'older-mixed-event' }); + const ledger = [historical]; + const before = structuredClone({ ctx, ledger }); + for (const answer of [false, true]) { + const accepted = bindTraitorDecisionPromise(ctx, ledger, 'fremen', answer, 'mixed-traitor-event'); + assert.deepEqual(accepted, [historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event' })]); + assert.notEqual(accepted[0], historical); + assert.deepEqual(settleTraitorDecisionPromises(ctx, accepted, answer), [ + historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event', fulfilled: true }), + ]); + assert.deepEqual(reconcileTraitorDecisionPromises(context({ canReveal: false }), accepted), [ + historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event', ...(answer ? { released: true } : {}) }), + ]); + assert.deepEqual(accepted, [historical, promise({ asker: 'fremen', answer, event: 'mixed-traitor-event' })]); + } + assert.deepEqual({ ctx, ledger }, before); +}); + +void test('omitted or undefined event arguments retain the original pure shape and invalid event links reject without mutation', () => { + const ctx = context(), ledger = [promise()]; + const before = structuredClone({ ctx, ledger }); + for (const bound of [ + bindTraitorDecisionPromise(ctx, ledger, 'fremen', true), + bindTraitorDecisionPromise(ctx, ledger, 'fremen', true, undefined), + ]) { + assert.deepEqual(bound, [promise(), promise({ asker: 'fremen' })]); + assert.equal(Object.hasOwn(bound[1], 'event'), false); + } + for (const event of ['', ' ', null, 7, false, {}]) { + assert.throws(() => bindTraitorDecisionPromise(ctx, ledger, 'fremen', true, event as string), TraitorDecisionError); + assert.deepEqual({ ctx, ledger }, before); + } +}); + void test('operational guards reject malformed contexts or ledger values with the named error and unchanged input', () => { for (const malformed of [ null, [], { ...context(), turn: 0 }, { ...context(), battle: '' }, diff --git a/tests/truth-mixed-traitor-controls.test.tsx b/tests/truth-mixed-traitor-controls.test.tsx new file mode 100644 index 00000000..ccdad77e --- /dev/null +++ b/tests/truth-mixed-traitor-controls.test.tsx @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MixedTraitorFields, mixedTraitorInputError } from '../components/mixed-traitor-fields'; +import { MixedTraitorPromises } from '../components/mixed-traitor-promises'; +import { TruthFactFields } from '../components/mixed-shipment-fields'; +import { TraitorDecisionAnswers, TraitorDecisionPromises } from '../components/traitor-decision-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import { mixedActionExpressionSize, type MixedActionExpression } from '../game/mixed-action-question'; +import type { TraitorActionClaim } from '../game/mixed-traitor-question'; +import type { TruthFact } from '../game/truthtrance'; +import { createTruthTraitorDecisionFixture } from './fixture-truth-traitor-decision'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +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 declaration = (call = true): Expression => ({ kind: 'action', action: { call } }); +const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [declaration(), declaration(false)] }] }); +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-traitor-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-traitor-${path}-${field}`); + (input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } }); +} +function editFact(tree: ReactNode, path: string, next: TruthFact) { + const scope = find(tree, 'data-mixed-traitor-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); +} +function textContent(node: ReactNode): string { + if (Array.isArray(node)) return node.map(textContent).join(''); + if (isValidElement(node)) return textContent(node.props.children); + return typeof node === 'string' || typeof node === 'number' ? String(node) : ''; +} +function answer(tree: ReactNode, label: string): Element { + const found = elements(tree).find(element => typeof element.props.onClick === 'function' && textContent(element) === `Answer ${label}`); + assert.ok(found, `Answer ${label}`); return found; +} + +/** Controlled views prove original-tree editing, aggregate display and saved + * callback guards. The native source suite owns actual Truthtrance lifecycle. */ +function projected(): GameView { + const source = createGame('TRUTHMIXEDTRAITORCONTROLS', newPlayer('p', 'Respondent', 'emperor'), false); + joinGame(source, newPlayer('a', 'Asker', 'atreides')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.turn = 2; game.phase = 6; + game.truthMixedTraitorTargets = ['p', 'a']; + game.truthMixedTraitorAnswers = ['yes', 'no', 'unknown']; + game.mixedTraitorPromises = [{ event: 'controlled-mixed-traitor-question', player: 'p', asker: 'a', turn: 2, + battle: 'controlled-native-battle', territory: 'arrakeen', mixed: claim(), answer: true, fulfilled: false, released: false }]; + game.battle = { event: 'controlled-native-battle', territory: 'arrakeen', attacker: 'p', defender: 'a', revealed: true, + submitted: ['p', 'a'], plans: {}, traitorSubmitted: [], traitorVoters: ['p', 'a'], traitorCallChoices: [false, true], ownTraitorPromises: [], + } as unknown as NonNullable; + game.truthtrance = { stage: 'answer', queue: [{ player: 'a', card: 'controlled-truth-card' }], passed: [], question: { + kind: 'mixedTraitorCall', target: 'p', battle: 'controlled-native-battle', territory: 'arrakeen', mixed: claim(), + } }; + 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: () => MixedTraitorFields({ value, game, target, disabled, onChange(next) { value = next; changes++; } }), + html: () => renderToStaticMarkup(createElement(MixedTraitorFields, { value, game, target, disabled, onChange() {} })), + error: () => mixedTraitorInputError(value, game, target), + }; +} +const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedTraitorPromises, { game })); +const answerMarkup = (game: GameView) => renderToStaticMarkup(createElement(TraitorDecisionAnswers, { game, busy: false, act() {} })); + +void test('mixed Traitor editor preserves sixteen original leaves without plan node or eight-term limits', () => { + const e = editor(fact()); click(control(e.tree(), 'or-1')); + for (let count = 3; count <= 16; count++) { + click(control(e.tree(), 'add-action-1')); select(e.tree(), `1.${count}`, 'call', count % 2 ? 'decline' : 'reveal'); + } + 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[2], declaration(false)); + assert.deepEqual(full.terms[15], declaration()); assert.equal(e.error(), null); + assert.deepEqual(mixedActionExpressionSize(full), { leaves: 16, nodes: 17, depth: 1, maxTerms: 16 }); + const html = e.html(), ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); + assert.match(html, /16 \/ 16 conditions/); assert.match(html, /Reveal traitor/); assert.match(html, /Decline traitor/); + assert.equal((html.match(/>Traitor declaration<\/label>/g) ?? []).length, 15); + assert.equal(new Set(ids).size, ids.length); assert.doesNotMatch(html, /total clauses|8 conditions per group/); + assert.ok(!elements(e.tree()).some(element => typeof element.props.id === 'string' && /-(battle|territory|leader|beneficiary|target)$/.test(element.props.id))); + 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 Traitor edits preserve every fact and declaration branch through collapse and depth-four grouping', () => { + const e = editor({ kind: 'and', terms: [fact(), declaration(false)] }); + 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 }); + 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); assert.equal(e.error(), null); + assert.match(e.html(), /Current-fact group 1\.1/); + 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(), declaration(false)] }); + select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error()); + select(e.tree(), '1.2', 'timing', 'action'); assert.equal(e.error(), null); + select(e.tree(), '1.2', 'call', 'decline'); click(control(e.tree(), 'remove-1.1')); + assert.deepEqual(e.value, declaration(false)); 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, declaration()] }; + 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(mixedTraitorInputError({ kind: 'and', terms: [nested, declaration()] }, projected(), 'p')); +}); + +void test('mixed Traitor fact drafts stay repairable, strict declaration leaves reject metadata, and disabled callbacks cannot edit', () => { + for (const invalid of [NaN, -1, 0.5, Infinity]) { + const e = editor({ kind: 'and', terms: [fact(), declaration()] }); + editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: invalid }); assert.ok(e.error()); + click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2.1', 'call', 'decline'); + assert.ok(e.value.kind === 'and'); assert.deepEqual(e.value.terms[1], { kind: 'or', terms: [declaration(false), fact()] }); + assert.match(e.html(), /role="alert"/); editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 2 }); assert.equal(e.error(), null); + } + for (const action of [{ call: 'yes' }, { call: 1 }, {}, { call: true, battle: 'other' }, { call: false, territory: 'carthag' }, + { call: true, leader: 'private' }, { call: false, canReveal: false }, { call: true, answer: true }, + { kind: 'or', terms: [{ call: true }, { call: false }] }]) + assert.ok(mixedTraitorInputError({ kind: 'and', terms: [fact(), { kind: 'action', action }] }, projected(), 'p')); + const frozen = editor({ kind: 'and', terms: [fact(), declaration()] }, 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)); + select(frozen.tree(), '1.2', 'call', 'decline'); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'timing', 'fact'); + editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 }); + assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before); +}); + +void test('mixed Traitor authoring uses only public vocabulary for combatants and remote native Harkonnen voters', () => { + const game = projected(); game.players[0].faction = 'harkonnen'; game.players[0].ally = 'a'; + for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'spice', 'forces', 'reserves', 'tanks', 'elites']) + Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + for (const key of ['plans', 'traitorCallChoices', 'canReveal', 'cards', 'ownTraitorPromises']) Object.defineProperty(game.battle!, key, { + get() { throw Error(`No native ${key} inspection`); }, configurable: true, + }); + for (const key of ['deck', 'discard', 'truthMixedTraitorAnswers', 'truthTraitorAnswers']) Object.defineProperty(game, key, { + get() { throw Error(`No private ${key} inspection`); }, configurable: true, + }); + const e = editor({ kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), declaration(false)] }, false, game); + assert.equal(e.error(), null); const html = e.html(); + assert.match(html, /native Harkonnen declaration for an ally/); assert.match(html, /Current facts are snapshotted at the answer/); + assert.match(html, /conservative declaration commitment/); assert.match(html, /constant whole answer needs no declaration commitment/); + assert.match(html, /original declaration fulfills a remaining commitment before a later Karama or betrayal response/); + select(e.tree(), '1.2', 'call', 'reveal'); assert.equal(e.error(), null); +}); + +void test('own mixed Traitor history retains original whole answers and constant, bound and terminal statuses after battle cleanup', () => { + const game = projected(); + game.mixedTraitorPromises.push( + { ...game.mixedTraitorPromises[0], event: 'constant-whole-answer', answer: false, fulfilled: true }, + { ...game.mixedTraitorPromises[0], event: 'declared-whole-answer', fulfilled: true }, + { ...game.mixedTraitorPromises[0], event: 'released-whole-answer', released: true }, + { ...game.mixedTraitorPromises[0], event: 'foreign-question', player: 'a', mixed: { kind: 'and', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), declaration(false)] } }, + ); + for (const promise of game.mixedTraitorPromises) for (const key of ['binding', 'requiredCall', 'residual', 'factAnswers', 'frozenFacts', 'compiled', 'canReveal', 'leader', 'beneficiary', 'progress']) + Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true }); + game.battle = null; game.truthtrance = null; + const html = markup(game); + assert.match(html, /Your mixed Traitor promises/); assert.match(html, /Turn 2/); assert.match(html, /Arrakeen/); + 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, /This whole answer is binding/); assert.match(html, /Fulfilled: this whole commitment is complete/); + assert.match(html, /Released: this whole commitment is no longer possible/); + assert.match(html, /constant whole answer is complete immediately/i); assert.match(html, /No negates the whole expression, not each condition separately/); + assert.match(html, /later Karama or betrayal response can still prevent its effect/); + assert.doesNotMatch(html, /Poison Blade|controlled-mixed-traitor-question|declared-whole-answer|