From 86608f7ea9aa4a1e9585d36bc3fd314b69363f2a Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:23:14 +1100 Subject: [PATCH] Bind current Traitor declarations with Truthtrance --- components/game-table.tsx | 13 +- components/nexus-traitors.tsx | 3 +- components/traitor-decision-promises.tsx | 22 ++ components/truthtrance.tsx | 16 +- docs/CURRENT_STATUS.md | 26 ++ docs/IMPLEMENTATION_STATUS.md | 36 +++ docs/RULE_DECISIONS.md | 11 + docs/TRUTHTRANCE_TRAITOR_DECISIONS.md | 91 ++++++ game/bots.ts | 9 +- game/engine.ts | 146 ++++++++-- game/nexus-traitor-options.ts | 4 +- game/reference.ts | 26 +- game/traitor-decision-promises.ts | 122 ++++++++ game/truthtrance.ts | 22 +- tests/fixture-truth-traitor-decision.ts | 192 +++++++++++++ tests/fixture-truth-traitor-harkonnen.ts | 260 ++++++++++++++++++ tests/traitor-decision-promises.test.ts | 200 ++++++++++++++ .../truth-traitor-decision-controls.test.tsx | 41 +++ tests/truth-traitor-decision-engine.test.ts | 88 ++++++ tests/truth-traitor-harkonnen-engine.test.ts | 69 +++++ 20 files changed, 1362 insertions(+), 35 deletions(-) create mode 100644 components/traitor-decision-promises.tsx create mode 100644 docs/TRUTHTRANCE_TRAITOR_DECISIONS.md create mode 100644 game/traitor-decision-promises.ts create mode 100644 tests/fixture-truth-traitor-decision.ts create mode 100644 tests/fixture-truth-traitor-harkonnen.ts create mode 100644 tests/traitor-decision-promises.test.ts create mode 100644 tests/truth-traitor-decision-controls.test.tsx create mode 100644 tests/truth-traitor-decision-engine.test.ts create mode 100644 tests/truth-traitor-harkonnen-engine.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 049036b8..faed7117 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1,4 +1,5 @@ 'use client'; +import { TraitorDecisionPromises } from './traitor-decision-promises'; import { MovementPromises } from './movement-promises'; import { integerInputValue } from '../lib/integer-input'; import { EcazSetup } from './ecaz-setup'; @@ -362,7 +363,7 @@ export function GameTable({ : traitorBattle.attacker ] : undefined; - const canRevealTraitor = !!( + const canRevealTraitor = traitorBattle?.traitorCallChoices?.includes(true) ?? !!( traitorBattle?.traitorVoters.includes(me.id) && traitorPlan && !traitorPlan.kwisatz && @@ -372,7 +373,9 @@ export function GameTable({ traitorBattle.cards.find((card) => card.id === traitorPlan.leader), ) ); - const noTraitorReason = traitorPlan?.kwisatz + const canDeclineTraitor = traitorBattle?.traitorCallChoices?.includes(false) ?? true; + const noTraitorReason = traitorBattle?.ownTraitorPromises?.some(promise => !promise.answer && !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.'; const [guildSecretEvent, setGuildSecretEvent] = useState(''); @@ -5307,6 +5310,7 @@ export function GameTable({ )} {g.battle.traitorVoters.includes(me.id) && !g.battle.traitorSubmitted.includes(me.id) && ( <> + {actionButton( 'Reveal traitor', { @@ -5321,7 +5325,7 @@ export function GameTable({ {actionButton('No traitor — resolve', { type: 'traitorCall', call: false, - })} + }, !canDeclineTraitor)} )} @@ -5719,6 +5723,7 @@ export function GameTable({

{!g.battle.traitorSubmitted.includes(me.id) ? ( <> + {actionButton( 'Reveal ally’s opponent as traitor', { @@ -5733,7 +5738,7 @@ export function GameTable({ {actionButton('Decline traitor support', { type: 'traitorCall', call: false, - })} + }, !canDeclineTraitor)} ) : (

diff --git a/components/nexus-traitors.tsx b/components/nexus-traitors.tsx index dd9fa91d..ef2aee49 100644 --- a/components/nexus-traitors.tsx +++ b/components/nexus-traitors.tsx @@ -165,7 +165,7 @@ function ReturnCards({ game, act, busy }: Props) { className="shrink-0" checked={checked} disabled={ - blocked || (!checked && selected.length >= pending.count) + blocked || (!checked && (!!choice.blocked || selected.length >= pending.count)) } onChange={() => setSelected((old) => @@ -184,6 +184,7 @@ function ReturnCards({ game, act, busy }: Props) { + {choice.blocked &&

{choice.blocked}

} void; busy: boolean }) { + if (game.truthtrance?.stage !== 'answer' || game.truthtrance.question?.kind !== 'traitorCall' || + game.truthtrance.question.target !== game.me) return null; + 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 => )} +
; +} + +export function TraitorDecisionPromises({ game }: { game: GameView }) { + const promises = game.battle?.ownTraitorPromises?.filter(promise => !promise.fulfilled && !promise.released) ?? []; + if (!promises.length) return null; + return
+

Your Truthtrance answer requires {promises[0].answer ? 'Reveal traitor' : 'No traitor'} for this battle.

+

The original declaration fulfills your answer. Later Karama or betrayal prevention can still change its effect.

+
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 1fae51ed..cac272a0 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,5 +1,6 @@ 'use client'; import { MovementClaimFields } from './movement-claim-fields'; +import { TraitorDecisionAnswers } from './traitor-decision-promises'; import { validMovementClaim, type MovementClaim } from '@/game/movement-promises'; import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput } from './shipment-claim-fields'; import { BattleClaimFields } from './battle-promises'; @@ -52,7 +53,7 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); const [kind, setKind] = useState< - 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' >('fact'); const [movement, setMovement] = useState({ territory: 'carthag', minimum: 6 }); const [shipmentJoin, setShipmentJoin] = useState<'single' | 'and' | 'or'>('single'); @@ -108,6 +109,7 @@ export function Truthtrance({ ? planClauses[0] : { kind: join, terms: planClauses }, } + : kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' } : kind === 'movement' ? { kind, target, ...movement } : kind === 'shipment' ? { @@ -223,6 +225,8 @@ 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 === 'battlePlan' ? ( <>

@@ -331,6 +335,7 @@ export function Truthtrance({ Verified current facts + @@ -345,7 +350,13 @@ export function Truthtrance({ )} - {kind === 'movement' ? ( + {kind === 'traitorCall' ? ( + <> +

Choose a still-unsubmitted voter in the current revealed battle. A definite answer binds that player’s original declaration, without reopening any sealed vote or promising that a later response will allow it.

+ {!g.truthTraitorTargets.includes(target) &&

Reveal both plans and choose a voter who has not submitted their Traitor decision.

} + {g.battle?.revealed &&

{truthQuestionText(question, leaderName, locationName)}

} + + ) : kind === 'movement' ? ( <>

One ground movement of the chosen physical minimum, from now until the respondent’s Movement turn ends. Shipment, HMS relocation and Ambassador relocation do not count. Owned Hajr, Ornithopter, native Kulon and paid Box recovery of a known discard can prepare a later move. No stays binding after earlier nonmatching moves.

@@ -496,6 +507,7 @@ export function Truthtrance({ 'Ask publicly', { type: 'truthAsk', question }, (kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) || + (kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) || (kind === 'shipment' && (invalidShipment || !shipmentAvailable)) || (kind === 'mixedShipment' && diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index b36121ad..d2ee8592 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,32 @@ open mode gates. ## Current checkpoint and work +**Current Traitor-declaration Truthtrance — 25 October. Prototyped / Partial.** +A structured revealed-battle question now binds an actual unsubmitted voter to +Reveal or decline. Native matching/KH/voter/beneficiary rules supply the private +choices. Fulfill the original declaration before later Karama/betrayal changes +its effect; submitted votes cannot be reopened. Harkonnen Cunning keeps the +identity needed by a live Yes, with other returns available, while fulfilled +calls retain later native custody rights. Shared answer/vote controls, original +discard/return parents and minimal policies are connected. +[Source, current-choice meaning and limits](TRUTHTRANCE_TRAITOR_DECISIONS.md). + +Disjoint pure/native-fixture work, bounded source review and root integration +pass **13 files101/101**, types/lint/build and six native engine/answer-callback/ +GameTable SSR journeys. Genuine26-action Basic,72-action allied Harkonnen and77- +action Cunning sources earn original cards/Traitors, real alliances, payments, +plan reveal and native draw/return. Basic Yes/No actually constrain declarations; +allied prevention leaves fulfillment intact. Pending-return Truthtrance keeps +its original parent, blocks only the required identity, and later completed +calls may return it. All four minimal policies answer/declare legally. One +unmatched case explicitly exchanges conserved original Traitor identities. +Feedback repairs submitted-false semantics and a fixture that stopped before +mandatory response passes; final types make reconciliation/fixture narrowing +explicit, and sealed-vote refusal is checked at the exact queued request. +No strategic tuning or full future-promise/module/mode acceptance follows. +Final assurance/browser/deployed remain open; no saved-room/D1/server/reset/ +restart/deployment operation. + **Native Advanced and No-Field movement promises — 25 October. Prototyped / Partial.** Declared native Advanced families now share phase5 movement questions. Owned No-Field shipment, reserve-capped reveal, concealed positioning and mixed moves diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index c5a00830..5b870420 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,41 @@ # Dune implementation status +## 25 October 2026 — Truthtrance binds the current native Traitor declaration + +A separate revealed-battle question binds an actual unsubmitted native voter, +using original battle/turn/voter/beneficiary/opponent/leader source and shared +matching/KH legality. Yes/No constrain the declaration; fulfill before later +allied Karama or Nexus betrayal changes its effect. Sealed votes stay fixed. +Original Truthtrance disposal binds the new ledger. Harkonnen Cunning return +checks a live required identity before custody/shuffle, projects a private +reason and retains other choices; fulfilled declarations remain returnable. +The pending return parent exempts only old/new promise ledgers. Both human vote +panels and minimal policies consume shared choices; no new module gate opens. + +Disjoint helper/native-fixture lanes, bounded review and root integration pass +**13 files101/101**, types/lint/build and six genuine native engine/answer-callback/ +GameTable SSR journeys. Basic26 earns original setup/deal/Traitor choice, +paid invasion and actual seals. Allied Harkonnen72 earns a real turn-two alliance +and original Karama purchase; Cunning77 earns its real original Nexus draw and +mandatory return. Native Yes/No refusals, post-declaration prevention, pending- +return Truthtrance and later fulfilled custody pass. Four profiles answer and +vote legally; an unmatched original-card exchange is explicitly controlled. + +Feedback corrects submitted-false handling, a fixture stopping before mandatory +response passes, reconciliation annotations and nullable/corruption fixture +narrowing. Sealed-vote/early refusal is checked against the exact queued action. +Final frozen proof/HTML: +`/home/quintine/.local/share/dune/checkpoints/20261025-truth-traitor-decision/`. +`frozen-integrated-3/report.json` binds tree +`1f0db4bd54ba97381e12a3d3c5e98944d387f78fbcd473e212295635aab80c43`, +2787files on base `724eabf99ce0243f9ef27982cbf2509de9ad7550`. +Final four prose/reference overlays are reviewed separately. This is explicit +current-turn software composition under the existing general FAQ, not a new +Traitor-specific publisher ruling. Broader future/preparation promises, complete +modes/modules and final rules/recovery/privacy/custody/browser/deployed acceptance +remain open. No AI tuning or saved-room/D1/server/reset/restart/deployment action. +[Canonical contract](TRUTHTRANCE_TRAITOR_DECISIONS.md). + ## 25 October 2026 — Native Advanced and owned No-Field movement preparation Advanced phase5 now reuses the declared native-family admission. Richese's diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index f62a5843..ff95db15 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,16 @@ # Rule decision index +## Current Traitor-declaration Truthtrance — 25 October 2026 + +Compose the existing general current-turn rule with an actual unsubmitted +revealed-battle voter. Bind its native public source; Yes/No constrains the +original declaration, which fulfills before later Karama/betrayal changes its +effect. Preserve native voter/beneficiary, physical matching, KH and submitted +votes. Protect an unfulfilled Yes from a voluntary Harkonnen Cunning return; +fulfilled declarations retain their ordinary later custody rules. This is +explicit software composition, not a new publisher Traitor-specific FAQ. +[Source, shared controls and native evidence](TRUTHTRANCE_TRAITOR_DECISIONS.md). + ## Native Advanced and No-Field movement preparation — 25 October 2026 Reuse the existing physical-movement/current-turn composition and printed diff --git a/docs/TRUTHTRANCE_TRAITOR_DECISIONS.md b/docs/TRUTHTRANCE_TRAITOR_DECISIONS.md new file mode 100644 index 00000000..c61f511c --- /dev/null +++ b/docs/TRUTHTRANCE_TRAITOR_DECISIONS.md @@ -0,0 +1,91 @@ +# Truthtrance: the current Traitor declaration + +## Meaning and authority + +**Prototyped / Partial — 25 October 2026.** After both plans reveal, ask an +actual voter who has not submitted whether they will declare the opposing +leader a Traitor in this battle. Yes requires the original legal Reveal; No +requires decline. This concerns the declaration, not whether later Karama or +Nexus betrayal allows its effect. Fulfill at the actual declaration before +those responses. Submitted Yes and No votes are never reopened or rewritten. + +This composes the general current-turn action/decision rule from GF9's +November2020 FAQ printedp8, recorded in the +[shipment authority](TRUTHTRANCE_SHIPMENT_PROMISES.md#authority-and-supported-meaning). +No dedicated publisher Traitor-promise example or new ruling is claimed. Future +unrevealed-leader scenarios, arbitrary promises and full-mode acceptance remain +separate. The question has no new faction/module start permission. + +## Shared native decision + +The engine's original voters and beneficiary determine who can answer, including +combatants, Ecaz co-side voters and a remote Harkonnen ally. Homeworlds retain +their native-voter restriction. Use the same matching physical Traitor and +Kwisatz Haderach check as a real declaration. A Face Dancer is not an ordinary +Traitor. A leaderless/protected or unmatched opponent supplies only No. + +Bind current turn, original battle event, voter, beneficiary, opposing player +and revealed leader. Private eligibility stays out of that public source. +Only the respondent sees feasible answers. Public authoring targets depend on +native decision ownership and submission, not hidden card custody. A definite +answer consumes the original physical Truthtrance and resumes the interrupted +parent. Its original discard continuation binds the new promise as well. + +Both combatant and allied vote controls use shared private allowed choices and +a persistent promise notice. Busy answer callbacks submit nothing. Existing +minimal policies answer and declare from those choices; no strategy tuning or +difficulty calibration is added. Opposing impossibility can release a still-live +Yes, while voluntary actions cannot erase the required legal declaration. + +## Harkonnen Cunning + +Printed Cunning may draw during this revealed battle and then requires returning +one held identity. Secret Ally has its existing Mentat timing. See the +[native timing contract](NEXUS_HARKONNEN_RULES.md#timing-composition). + +A live promised Reveal protects its matching identity from a voluntary return. +Check the exact selection before applying the exchange or shuffling, and show +the same private blocked reason in controls and legal bot choices. Other held +identities, including the new draw, remain available. A previously selected +blocked identity can be unchecked. No stays binding after a new draw and may +return its matching card. A fulfilled declaration may also return that card: +the original declaration receipt/effective call remains independent of later +custody. A Truthtrance overlay may add its promise during a pending return; +the original parent exempts only the old/new promise ledgers, retaining its +other battle/continuation fields. + +## Evidence and remaining scope + +The [Basic source](../tests/fixture-truth-traitor-decision.ts) earns original +base33 setup/deal, an actual opposing Traitor choice, paid invasion and two legal +zero-dial unarmed plans. The [Harkonnen sources](../tests/fixture-truth-traitor-harkonnen.ts) +earn actual turn-two alliances, paid Karama/invasion, original four-Traitor hand +and sealed plans. The Cunning branch additionally earns its real original +Harkonnen Nexus draw and pending native return. No played hand, force, wallet, +clock, alliance, vote, promise or parent is installed. A separate unmatched-card +case explicitly uses a conserved original-card exchange. Current choices quote +present native matching eligibility; unknown future Traitor draws are not assumed +to supply a new Yes. Broader owned future-preparation questions remain unfinished. + +[Pure choices](../tests/traitor-decision-promises.test.ts), +[native declarations](../tests/truth-traitor-decision-engine.test.ts), +[allied prevention/Cunning](../tests/truth-traitor-harkonnen-engine.test.ts) and +[answer controls](../tests/truth-traitor-decision-controls.test.tsx) cover the +changed path. Frozen **13 files101/101**, types/lint/build and six genuine native +engine/answer-callback/GameTable SSR journeys pass. Basic26, allied Harkonnen72 +and Cunning77 source prefixes earn original setup/cards, payments, alliances, +plans and draw/return. Actual answer callbacks respect busy state; four minimal +profiles answer/declare legally. Submitted-false handling and a response-stopping +fixture are repaired; final annotations/narrowing pass, with exact queued +sealed-vote refusal retained. + +Private proof/HTML: +`/home/quintine/.local/share/dune/checkpoints/20261025-truth-traitor-decision/`. +`frozen-integrated-3/report.json` binds tree +`1f0db4bd54ba97381e12a3d3c5e98944d387f78fbcd473e212295635aab80c43`, +2787files on base `724eabf99ce0243f9ef27982cbf2509de9ad7550`. +Final prose/reference overlays are separately reviewed. +These are focused prototypes; full faction/mode/module combinations, final +rules/recovery/privacy/custody, browser/hydration/mobile and deployed acceptance +remain open. No saved-game, database, server restart or deployment operation is +part of this checkpoint. diff --git a/game/bots.ts b/game/bots.ts index 8f73a575..b55e76dd 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1292,6 +1292,11 @@ function policyActions(g: GameView): Action[] { return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }]; if (w.stage === 'answer') { if (w.question?.target !== me.id) return []; + if (w.question.kind === 'traitorCall') { + const answers = g.truthTraitorAnswers ?? []; + const answer = answers.find(answer => answer === 'yes') ?? answers[0]; + return answer ? [{ type: 'truthAnswer', answer }] : []; + } if (w.question.kind === 'movement') { const answer = g.truthMovementAnswers?.find(answer => answer !== 'unknown') ?? g.truthMovementAnswers?.[0]; return answer ? [{ type: 'truthAnswer', answer }] : []; @@ -3904,7 +3909,7 @@ function policyActions(g: GameView): Action[] { ? me.id : me.ally; const enemy = b.attacker === beneficiary ? b.defender : b.attacker; - const call = + const preferredCall = ownsTraitorDecision && !b.plans[enemy].kwisatz && !!matchingTraitor( @@ -3912,6 +3917,8 @@ function policyActions(g: GameView): Action[] { b.plans[enemy].leader, b.cards.find((c) => c.id === b.plans[enemy].leader), ); + const choices = b.traitorCallChoices; + const call = choices && !choices.includes(preferredCall) ? choices[0] ?? false : preferredCall; const portable = g.portableSnooper; if ( !call && diff --git a/game/engine.ts b/game/engine.ts index 6ad1dc8c..1193d25c 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,4 +1,7 @@ import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income'; +import { traitorDecisionChoices, bindTraitorDecisionPromise, settleTraitorDecisionPromises, + reconcileTraitorDecisionPromises as reconcileTraitorDecisions, validateTraitorDecisionPromise, + TraitorDecisionError, type TraitorDecisionContext, type TraitorDecisionPromise } from './traitor-decision-promises'; import { gholaForcePreparations } from './ghola-force-preparation'; import { earlyAuctionFunding } from './early-auction-funding'; import { recordMovementDiscardKnowledge, knownMovementDiscards, recordMovementBoxKnowledge, recordMovementClaimKnowledge, clearMovementDiscardKnowledge, type MovementDiscardKnowledge } from './movement-discard-knowledge'; @@ -838,6 +841,7 @@ export type Battle = { plans: Record; revealed: boolean; traitorCalls: Record; + traitorDecisionPromises?: TraitorDecisionPromise[]; /** Original native allowance, retained separately from the Nexus source. */ nexusHarkonnenAllowance?: { provider: string; @@ -1669,6 +1673,7 @@ export type Game = { | { kind: 'shipment'; index: number; value: ShipmentPromise } | { kind: 'movement'; index: number; value: MovementPromise } | { kind: 'battle'; index: number; value: BattlePromise } + | { kind: 'traitor'; index: number; value: TraitorDecisionPromise } | null; parentSignature: string; } @@ -3524,7 +3529,8 @@ function nexusTraitorParentSignature(g: Game, event: string) { const resume = { response: context.response ?? null, decision: context.decision ?? null, pendingKarama: 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] ?? null : null, phaseOpening: 'phaseOpening' in context ? context.phaseOpening as Game['phaseOpening'] ?? null : null }; - const battle = g.battle ? Object.fromEntries(Object.entries(g.battle).filter(([key]) => key !== 'truthPromises')) : null; + const battle = g.battle ? Object.fromEntries(Object.entries(g.battle).filter(([key]) => + key !== 'truthPromises' && key !== 'traitorDecisionPromises')) : null; return JSON.stringify({ event, turn: g.turn, phase: g.phase, status: g.status, active: g.active, order: g.order, ready: g.ready, battle, nullentropy: g.pendingNullentropy ?? null, controls: nullentropyParentSignature(g, resume, event) }); @@ -3629,6 +3635,8 @@ function finishNexusTraitorReturn(g: Game, p: Player, action: Action) { requireRule(pending?.owner === p.id && action.event === pending.event && Object.keys(action).sort().join(',') === 'cards,event,type' && Array.isArray(action.cards), 'This Nexus return is stale or belongs to another player.'); + const promiseBlock = traitorReturnPromiseBlock(g, p, action.cards as string[]); + requireRule(!promiseBlock, promiseBlock ?? 'Preserve your accepted Traitor declaration.'); const result = nexusRule(() => finishNexusTraitorExchange(nexusTraitorSnapshot(g), nexusTraitorUniverse(g), pending, action.cards as string[], random)); applyNexusTraitorSnapshot(g, result.state); @@ -3641,11 +3649,16 @@ function projectedNexusTraitors(g: Game, id: string) { if (!g.nexusCards?.cards) return null; const pending = pendingNexusTraitors(g); const owner = pending ? getPlayer(g, pending.owner) : null; + const choices: {id:string;revealed:boolean;drawn:boolean;blocked?:string}[] = !pending || pending.owner !== id ? [] + : owner!.faction === 'tleilaxu' + ? owner!.faceDancers!.filter(card => !card.revealed).map(card => ({id:card.leader, revealed:card.revealed, drawn:pending.drawn.includes(card.leader)})) + : owner!.traitors.map(card => { + const blocked = traitorReturnPromiseBlock(g, owner!, [card]); + return {id:card, revealed:false, drawn:pending.drawn.includes(card), ...(blocked ? {blocked} : {})}; + }); return { offer: nexusTraitorOffer(g, id), pending: pending ? { event: pending.event, owner: pending.owner, mode: pending.mode, count: pending.drawn.length, - choices: pending.owner !== id ? [] : (owner!.faction === 'tleilaxu' - ? owner!.faceDancers!.filter(card => !card.revealed).map(card => ({id:card.leader, revealed:card.revealed, drawn:pending.drawn.includes(card.leader)})) - : owner!.traitors.map(card => ({id:card, revealed:false, drawn:pending.drawn.includes(card)}))), + choices, } : null }; } @@ -6179,6 +6192,9 @@ function finishTruthtranceAnswer(g: Game) { if (record.question.kind === 'movement') promise = { kind: 'movement', index: g.movementPromises!.length - 1, value: g.movementPromises!.at(-1)!, }; + else if (record.question.kind === 'traitorCall') promise = { + kind: 'traitor', index: g.battle!.traitorDecisionPromises!.length - 1, value: g.battle!.traitorDecisionPromises!.at(-1)!, + }; else if (record.question.kind === 'shipment' || record.question.kind === 'mixedShipment') promise = { kind: 'shipment', @@ -7066,15 +7082,16 @@ function treacheryDiscardIntegrity(g: Game) { !g.battle.revealed && !g.battle.plans[q.target]; const expectedMovement = q.kind === 'movement'; - if (expectedShipment || expectedBattle || expectedMovement) { - const actual = expectedMovement ? g.movementPromises : expectedShipment + const expectedTraitor = q.kind === 'traitorCall'; + if (expectedShipment || expectedBattle || expectedMovement || expectedTraitor) { + const actual = expectedTraitor ? g.battle?.traitorDecisionPromises : expectedMovement ? g.movementPromises : expectedShipment ? g.shipmentPromises : g.battle?.truthPromises; requireRule( promise && promise.value && typeof promise.value === 'object' && - promise.kind === (expectedMovement ? 'movement' : expectedShipment ? 'shipment' : 'battle') && + promise.kind === (expectedTraitor ? 'traitor' : expectedMovement ? 'movement' : expectedShipment ? 'shipment' : 'battle') && Array.isArray(actual) && Number.isSafeInteger(promise.index) && promise.index >= 0 && @@ -7087,7 +7104,11 @@ function treacheryDiscardIntegrity(g: Game) { !promise.value.released, 'The completed Truthtrance is missing its newly bound promise.', ); - if (promise.kind === 'movement') + if (promise.kind === 'traitor') + requireRule(q.kind === 'traitorCall' && promise.value.battle === q.battle && + promise.value.turn === g.turn && !promise.value.fulfilled, + 'The completed Truthtrance Traitor promise differs from its original decision.'); + else if (promise.kind === 'movement') requireRule(q.kind === 'movement' && promise.value.turn === g.turn && !promise.value.fulfilled && promise.value.territory === q.territory && promise.value.minimum === q.minimum, 'The completed Truthtrance ground-movement promise differs from its answer.'); @@ -24740,6 +24761,7 @@ function findReachableBattlePlan( function shipmentPromiseIntegrity(g: Game) { try { validateSavedTruthtrance(g); + traitorTruthIntegrity(g); } catch (error) { if (error instanceof TruthError) throw new RuleError(error.message); throw error; @@ -25797,6 +25819,82 @@ function traitorBeneficiary(g: Game, b: Battle, id: string) { if (b.ecazOccupy?.lead && [b.ecazOccupy.ecaz, b.ecazOccupy.ally].includes(id)) return b.ecazOccupy.lead; return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally; } +/** One native eligibility quote for declarations and private Truthtrance answers. */ +function traitorCallBlock(g: Game, b: Battle, id: string, held = getPlayer(g, id).traitors): string | null { + const other = b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker; + if (b.plans[other].kwisatz) return 'Kwisatz Haderach prevents this leader from turning traitor.'; + return matchingTraitor(held, b.plans[other].leader, cardOf(getPlayer(g, other), b.plans[other].leader)) + ? null : 'You do not hold that leader as a traitor.'; +} +function traitorTruthContext(g: Game, player: string, held?: string[]): TraitorDecisionContext | null { + const b = g.battle; + if (g.status !== 'playing' || g.phase !== 6 || !b?.revealed || !b.event || !traitorVoters(g, b).includes(player)) return null; + const beneficiary = traitorBeneficiary(g, b, player); + if (!beneficiary) return null; + const target = b.attacker === beneficiary ? b.defender : b.attacker; + if (!b.plans[target]) return null; + return { turn: g.turn, battle: b.event, player, beneficiary, target, leader: b.plans[target].leader, + canReveal: !traitorCallBlock(g, b, player, held), + ...(b.traitorCalls[player] === undefined ? {} : { submitted: b.traitorCalls[player] }) }; +} +function traitorDecisionRule(operation: () => T): T { + try { return operation(); } + catch (error) { if (error instanceof TraitorDecisionError) throw new RuleError(error.message); throw error; } +} +function traitorTruthAnswers(g: Game, p: Player): TruthAnswer[] { + const context = traitorTruthContext(g, p.id); + 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) { + 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)); +} +function traitorTruthIntegrity(g: Game) { + const b = g.battle, promises = b?.traitorDecisionPromises; + if (promises !== undefined) { + requireRule(Array.isArray(promises) && promises.every(validateTraitorDecisionPromise), 'Invalid Traitor decision promises.'); + for (const promise of promises) { + const context = traitorTruthContext(g, promise.player); + requireRule(context && promise.turn === context.turn && promise.battle === context.battle && + promise.beneficiary === context.beneficiary && promise.target === context.target && promise.leader === context.leader && + g.players.some(p => p.id === promise.asker && p.id !== promise.player) && + !(promise.fulfilled && promise.released) && + (promise.fulfilled || promise.released || context.submitted === undefined), + 'The Traitor promise lost its original public battle/voter source.'); + } + } + const question = g.truthtrance?.question; + if (question?.kind === 'traitorCall') { + 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) { + const b = g.battle; + if (!b?.traitorDecisionPromises?.length) return; + for (const player of new Set(b.traitorDecisionPromises.map(promise => promise.player))) { + const context = traitorTruthContext(g, player); + if (!context || context.submitted !== undefined) continue; + const old: TraitorDecisionPromise[] = b.traitorDecisionPromises; + const next: TraitorDecisionPromise[] = traitorDecisionRule(() => reconcileTraitorDecisions(context, old)); + if (next.some((promise, index) => promise.released && !old[index].released)) { + requireRule(actor !== player || action?.type === 'passResponse', + 'Your action would remove the legal Traitor declaration required by Truthtrance.'); + log(g, `${getPlayer(g, player).name}'s Truthtrance Traitor declaration promise is released because its original Reveal is no longer possible.`); + } + b.traitorDecisionPromises = next; + } +} +function traitorReturnPromiseBlock(g: Game, p: Player, returned: readonly string[]): string | null { + const promises = g.battle?.traitorDecisionPromises; + if (!promises?.some(promise => promise.player === p.id && promise.answer && !promise.fulfilled && !promise.released)) return null; + const context = traitorTruthContext(g, p.id, p.traitors.filter(card => !returned.includes(card))); + return context && !context.canReveal ? 'Keep the identity needed by your accepted Traitor declaration; return another card.' : null; +} function pendingSmugglerBattle(g: Game): SmugglerBattleReceipt | null { const b = g.battle!; const frame = JSON.stringify({ plans: b.plans, hidden: b.leaderSkillHidden, @@ -32965,6 +33063,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { } const g = applyActionInner(state, id, action); reconcileGroundMovementPromises(g, id, action, state.phase); + reconcileTraitorTruth(g, id, action); harkonnenNexusBetrayalIntegrity(g); if (g.pendingNexusHarkonnenBetrayal) return g; ixianNexusBetrayalIntegrity(g); @@ -33268,6 +33367,7 @@ function settleAutomaticContinuations(g: Game) { reconcileBattlePromises(g); reconcileShipmentPromises(g); reconcileGroundMovementPromises(g); + reconcileTraitorTruth(g); requireRule( JSON.stringify(g) !== before, 'Automatic response continuation made no progress.', @@ -33362,6 +33462,7 @@ export function normalizeAutomaticGame(state: Game): Game { reconcileBattlePromises(g); reconcileShipmentPromises(g); reconcileGroundMovementPromises(g); + reconcileTraitorTruth(g); settleAutomaticContinuations(g); observeOccupation(g); discoveryIntegrity(g); @@ -33596,6 +33697,8 @@ function applyActionInner( bindShipment: bindShipmentTruth, movementAnswers: movementTruthAnswers, bindMovement: bindMovementTruth, + traitorAnswers: traitorTruthAnswers, + bindTraitor: bindTraitorTruth, battleAnswers: battleTruthAnswers, bindBattle: bindBattleTruth, cardBlock: transferCardBlock, @@ -33603,7 +33706,7 @@ function applyActionInner( ) return g; } catch (error) { - if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError) + if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError) throw new RuleError(error.message); throw error; } @@ -37168,22 +37271,13 @@ function applyActionInner( 'Traitor decision is not available.', ); requireRule(typeof action.call === 'boolean', 'Choose reveal or decline.'); + const context = traitorTruthContext(g, id); + if (context && b.traitorDecisionPromises) + b.traitorDecisionPromises = traitorDecisionRule(() => settleTraitorDecisionPromises(context, b.traitorDecisionPromises!, action.call as boolean)); if (g.nexusCards?.cards || b.ecazOccupy) ensureTraitorDeclarations(g); if (action.call) { - const other = - b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker; - requireRule( - !b.plans[other].kwisatz, - 'Kwisatz Haderach prevents this leader from turning traitor.', - ); - requireRule( - matchingTraitor( - p.traitors, - b.plans[other].leader, - cardOf(getPlayer(g, other), b.plans[other].leader), - ), - 'You do not hold that leader as a traitor.', - ); + const blocked = traitorCallBlock(g, b, id); + requireRule(!blocked, blocked ?? 'This Traitor declaration is unavailable.'); } if (action.call && b.traitorDeclarationVersion) b.traitorDeclarations![id] = nexusRule(() => createTraitorDeclaration(traitorDeclarationContext(g), id)); @@ -37491,6 +37585,9 @@ 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), + 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, truthMovementAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.kind === 'movement' && g.truthtrance.question.target === id ? movementTruthAnswers(g, me, g.truthtrance.question) : null, @@ -38659,6 +38756,9 @@ export function viewGame(state: Game, id: string) { ? { [id]: b.plans[id] } : {}, 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), traitorVoters: traitorVoters(g, b), preparation: b.preparation && g.homeworldOccupationPreview && b.preparation.kind === 'voice' ? { ...b.preparation, blocked: homeworldRule(() => quoteOccupiedHomeworldVoice(g, diff --git a/game/nexus-traitor-options.ts b/game/nexus-traitor-options.ts index 6d0560a5..16b6e265 100644 --- a/game/nexus-traitor-options.ts +++ b/game/nexus-traitor-options.ts @@ -69,7 +69,7 @@ export function nexusTraitorReturnAction( (pending.mode === 'secretAlly' && game.phase !== 8) || cards.length !== pending.count || new Set(cards).size !== cards.length || - !cards.every((id) => pending.choices.some((choice) => choice.id === id)) + !cards.every((id) => pending.choices.some((choice) => choice.id === id && !choice.blocked)) ) return null; return { type: 'nexusTraitorReturn', event, cards: [...cards] }; @@ -118,7 +118,7 @@ export function nexusTraitorBotActions(game: GameView): Action[] { game.status !== 'playing' ) return []; - const cards = [...pending.choices] + const cards = pending.choices.filter(choice => !choice.blocked) .sort( (one, two) => returnPreference(game, one.id) - returnPreference(game, two.id), diff --git a/game/reference.ts b/game/reference.ts index 93c39554..aeff14b8 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2997,6 +2997,29 @@ 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-traitor-decision', + title: 'Truthtrance: this battle’s Traitor declaration', + category: 'Cards', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Bind an unsubmitted native voter’s original declaration after both battle plans reveal.', + steps: [ + 'Choose Bind this battle’s Traitor declaration, then an actual voter who has not submitted. The public question names the current revealed battle. Already submitted Yes and No votes cannot be reopened; future unrevealed-leader questions remain separate.', + 'Yes requires a legal Reveal against the opposing revealed leader; No requires decline. Native matching Traitor identity, Kwisatz protection and voter/beneficiary rules remain. Face Dancers do not count as ordinary Traitors. Only the respondent sees feasible answers. This current-choice prototype uses held matching eligibility; unknown future identity draws and broader future preparations remain unfinished.', + 'The original declaration fulfills the answer before any later Harkonnen-ally Karama response or Nexus betrayal. A prevented effect does not make that completed declaration untruthful. The answer does not guarantee successful betrayal.', + 'Harkonnen Cunning may still draw and return an ordinary legal identity. A live promised Reveal protects the matching card from voluntary return, with a private reason and other original choices available. No and already fulfilled declarations do not impose that custody restriction. The native pending return resumes after Truthtrance.', + 'Persistent answer and vote notices use the same legal choices as the server. Four minimal policies honor the declared choice; strategy and calibration remain deferred. Current-decision composition is explicit software policy under the general current-turn rule, not a publisher Traitor-specific FAQ. Full game/module and final acceptance remain unfinished.', + ], + related: ['card-truthtrance', 'battle', 'card-karama', 'nexus-harkonnen-betrayal', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Original event/voter/beneficiary/opponent/leader binding, declaration-time fulfillment and native matching eligibility are connected. Harkonnen Cunning retains unfulfilled matching custody while completed declarations keep ordinary return rights.', evidence: ['game/traitor-decision-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'game/nexus-traitor-options.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Public eligible targets, private definite answers, persistent vote notices, both native/ally vote panels and private blocked return reasons are connected. Busy callbacks do nothing and a stale blocked checkbox may be unchecked.', evidence: ['components/truthtrance.tsx', 'components/traitor-decision-promises.tsx', 'components/game-table.tsx', 'components/nexus-traitors.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Existing profiles consume private legal answers and declarations; the shared Nexus return policy excludes a forbidden identity. No strength tuning or general freeform interpretation is added.', evidence: ['game/bots.ts', 'game/nexus-traitor-options.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Current declaration versus successful effect, native eligibility and Cunning custody boundaries are explicit, with wider future promises and final acceptance open.', evidence: ['docs/TRUTHTRANCE_TRAITOR_DECISIONS.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] }, + ], + }, { id: 'truthtrance-movement', title: 'Truthtrance: ground-movement promises', @@ -3113,7 +3136,7 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Cards', coverage: 'Partial', summary: - 'Ask another player a public yes/no question. Verified facts, supported shipment and ground-movement promises, current-battle plan commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', + 'Ask another player a public yes/no question. Verified facts, supported shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.', steps: [ 'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.', 'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.', @@ -3123,6 +3146,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'A saved definite answer resumes automatically: its public answer, binding promise and consumed card are recorded once, then the next queued question or interrupted action resumes. Private answer and preparation guidance is unavailable during this automatic recovery.', 'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.', 'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.', + 'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.', 'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.', 'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.', 'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.', diff --git a/game/traitor-decision-promises.ts b/game/traitor-decision-promises.ts new file mode 100644 index 00000000..f879bd0b --- /dev/null +++ b/game/traitor-decision-promises.ts @@ -0,0 +1,122 @@ +export type TraitorDecisionContext = { + turn: number; + battle: string; + player: string; + beneficiary: string; + target: string; + leader: string | null; + canReveal: boolean; + submitted?: boolean; +}; + +/** Public source of the original declaration, separate from its later effect. */ +export type TraitorDecisionPromise = Omit & { + asker: string; + answer: boolean; + fulfilled?: boolean; + released?: boolean; +}; + +export class TraitorDecisionError extends Error { + constructor(message: string) { + super(message); + this.name = 'TraitorDecisionError'; + } +} + +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +const identifier = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; +const turnNumber = (value: unknown): value is number => + Number.isSafeInteger(value) && (value as number) > 0; +const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean'; +function requireDecision(value: unknown, message: string): asserts value { + if (!value) throw new TraitorDecisionError(message); +} + +/** Reject private card/eligibility fields rather than retaining them in history. */ +export function validateTraitorDecisionPromise(value: unknown): value is TraitorDecisionPromise { + 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; + 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); +} + +function validateContext(context: TraitorDecisionContext) { + requireDecision(record(context) && turnNumber(context.turn) && identifier(context.battle) && + identifier(context.player) && identifier(context.beneficiary) && identifier(context.target) && + (context.leader === null || identifier(context.leader)) && typeof context.canReveal === 'boolean' && + optionalBoolean(context.submitted), + 'Retain the current public Traitor decision source and native eligibility.'); +} + +function validatePromises(promises: readonly TraitorDecisionPromise[]) { + requireDecision(Array.isArray(promises) && promises.every(validateTraitorDecisionPromise), + 'The Traitor decision promise has an invalid public source.'); +} + +function liveSource(context: TraitorDecisionContext, promise: TraitorDecisionPromise) { + return !promise.fulfilled && !promise.released && promise.turn === context.turn && + promise.battle === context.battle && promise.player === context.player && + promise.beneficiary === context.beneficiary && promise.target === context.target && promise.leader === context.leader; +} + +/** Current-turn decision composition. Native voters, matching physical cards, + * ally benefit and Kwisatz Haderach eligibility remain the caller's authority. */ +export function traitorDecisionChoices( + context: TraitorDecisionContext, + promises: readonly TraitorDecisionPromise[], +): boolean[] { + validateContext(context); + validatePromises(promises); + if (context.submitted !== undefined) return []; + const baseline = context.canReveal ? [false, true] : [false]; + return baseline.filter(choice => promises.every(promise => + !liveSource(context, promise) || promise.answer === choice)); +} + +export function bindTraitorDecisionPromise( + context: TraitorDecisionContext, + promises: readonly TraitorDecisionPromise[], + asker: string, + answer: boolean, +): TraitorDecisionPromise[] { + requireDecision(identifier(asker) && typeof answer === 'boolean', 'Choose a definite Traitor decision answer and its asker.'); + 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, + }]; +} + +/** Call before assigning the actual declaration. A later prevented effect does + * not retract a declaration or undo its fulfilled promise. */ +export function settleTraitorDecisionPromises( + context: TraitorDecisionContext, + promises: readonly TraitorDecisionPromise[], + call: boolean, +): TraitorDecisionPromise[] { + requireDecision(typeof call === 'boolean', 'Declare whether to reveal your Traitor.'); + requireDecision(traitorDecisionChoices(context, promises).includes(call), + 'This Traitor declaration would break an accepted answer or its current opportunity.'); + return promises.map(promise => liveSource(context, promise) + ? { ...promise, fulfilled: true } : { ...promise }); +} + +/** Only native loss of the still-current Yes capability releases it. Source + * mismatch and original producer integrity remain with the battle engine. */ +export function reconcileTraitorDecisionPromises( + context: TraitorDecisionContext, + promises: readonly TraitorDecisionPromise[], +): TraitorDecisionPromise[] { + validateContext(context); + validatePromises(promises); + return promises.map(promise => !context.canReveal && promise.answer && liveSource(context, promise) + ? { ...promise, released: true } : { ...promise }); +} diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 5996c7a5..7300bbb0 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -55,6 +55,7 @@ export type TruthFact = | { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number } | { kind: 'and' | 'or'; terms: TruthFact[] }; export type TruthQuestion = + | { kind: 'traitorCall'; target: string; battle: string; territory: string } | ({ kind: 'movement'; target: string } & MovementClaim) | ({ kind: 'shipment'; target: string } & ShipmentClaim) | { kind: 'mixedShipment'; target: string; mixed: MixedShipmentExpression } @@ -205,6 +206,14 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { g.players.some((p) => p.id === v.target), 'Ask one other player.', ); + if (v.kind === 'traitorCall') { + check(Object.keys(v).sort().join(',') === 'battle,kind,target,territory' && + g.status === 'playing' && g.phase === 6 && g.battle?.revealed && + typeof v.battle === 'string' && v.battle === g.battle.event && + v.territory === g.battle.territory && g.battle.traitorCalls[v.target as string] === undefined, + 'Choose the current revealed battle and a still-unsubmitted Traitor decision.'); + return { kind: 'traitorCall', target: v.target as string, battle: v.battle, territory: v.territory as string }; + } if (v.kind === 'movement') { check(Object.keys(v).sort().join(',') === 'kind,minimum,target,territory', 'Supply one ground-movement destination, minimum and respondent.'); @@ -317,7 +326,7 @@ export function validateSavedTruthtrance(g: Game) { 'The saved Truthtrance must retain each committed physical card in its holder’s hand.', ); } - if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement') { + if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' || window?.question?.kind === 'traitorCall') { check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0, 'The saved fact question must retain its asking player and answer stage.'); check(JSON.stringify(parseQuestion(g, window.queue[0].player, window.question)) === JSON.stringify(window.question), @@ -421,6 +430,7 @@ export function truthQuestionText( locationName: (id: string) => string, ): string { if (q.kind === 'freeform') return q.text; + if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`; if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`; if (q.kind === 'shipment') return `Will you ${shipmentClaimText(q)} this turn?`; @@ -465,6 +475,8 @@ export function resolveTruthAction( ) => void; movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[]; bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void; + traitorAnswers?: (g: Game, p: Player) => TruthAnswer[]; + bindTraitor?: (g: Game, p: Player, answer: boolean, asker: string) => void; battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[]; bindBattle: ( g: Game, @@ -581,6 +593,9 @@ export function resolveTruthAction( 'Waiting for the Truthtrance holder to ask a question.', ); w.question = parseQuestion(g, p.id, a.question); + if (w.question.kind === 'traitorCall') + check(effects.traitorAnswers?.(g, g.players.find(other => other.id === w.question!.target)!).length, + 'Choose an actual unsubmitted native Traitor voter.'); w.stage = 'answer'; const target = g.players.find( (other) => other.id === w.question!.target, @@ -628,6 +643,11 @@ export function resolveTruthAction( 'Choose an answer consistent with legal ground movement and earlier promises.'); if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player); } + if (q.kind === '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.'); + if (a.answer !== 'unknown') effects.bindTraitor(g, p, a.answer === 'yes', current.player); + } if (q.kind === 'battlePlan') { check( effects.battleAnswers(g, p, q.claim).includes(a.answer), diff --git a/tests/fixture-truth-traitor-decision.ts b/tests/fixture-truth-traitor-decision.ts new file mode 100644 index 00000000..3e058213 --- /dev/null +++ b/tests/fixture-truth-traitor-decision.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { + actTruthMovement, truthMovementClean, truthMovementInventory, truthMovementPlayer, + type TruthMovementStep, type TruthMovementTransition, +} from './fixture-truth-movement'; + +export type TruthTraitorDecisionStep = TruthMovementStep; +export const actTruthTraitorDecision = actTruthMovement; +export const truthTraitorDecisionClean = truthMovementClean; +export const truthTraitorDecisionInventory = truthMovementInventory; +export const truthTraitorDecisionPlayer = truthMovementPlayer; +export const truthTraitorDecisionTraitors = (game: Game) => { + const cards = [...(game.traitorReserve ?? []), ...game.players.flatMap(player => + [...player.traitors, ...player.traitorChoices])]; + assert.equal(new Set(cards).size, cards.length, 'Each original Traitor has one current location'); + return cards.sort(); +}; + +/** Never declare or answer Truthtrance, seal a private plan, or submit a + * revealed-battle Traitor vote on behalf of the caller. */ +export function nextTruthTraitorDecision(game: Game): TruthTraitorDecisionStep | null { + if (game.truthtrance) return null; + if (game.battle?.revealed && truthTraitorDecisionClean(game) && viewGame(game, game.host).battle!.traitorVoters + .some(actor => game.battle!.traitorCalls[actor] === undefined)) return null; + return nextStrongholdFactionsNativeStep(game); +} +export const nextTruthTraitorDecisionNativeStep = nextTruthTraitorDecision; +export function advanceTruthTraitorDecision(game: Game, until: (state: Game) => boolean, + actions: TruthTraitorDecisionStep[] = []): Game { + for (let count = 0; count < 800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original battle ended before its requested control'); + const next = nextTruthTraitorDecision(game); + assert.ok(next, `Choose the original Truthtrance, private plan or Traitor vote: ${JSON.stringify({ + turn: game.turn, phase: game.phase, decision: game.decision?.kind, + truth: game.truthtrance?.stage, revealed: game.battle?.revealed, + })}`); + game = actTruthTraitorDecision(game, next.actor, next.action, actions); + } + throw Error('The genuine Basic Traitor-decision source did not reach its finite boundary'); +} + +/** Genuine public Basic two-seat source. Only original unopened Treachery and + * Spice positions and original scalar lotteries are selected. Emperor keeps + * an actually offered opposing Traitor; that living native disc is really + * sealed in Atreides' unarmed plan. Stop before every original Traitor vote. */ +export function createTruthTraitorDecisionFixture() { + const atreides = 'atreides', emperor = 'emperor', asker = atreides, target = emperor; + let game = createGame('TRUTHTRAITORDECISION', newPlayer(atreides, 'Atreides', 'atreides'), false); + joinGame(game, newPlayer(emperor, 'Emperor', 'emperor')); + const initial = structuredClone(game), actions: TruthTraitorDecisionStep[] = []; + const act = (actor: string, action: Action) => { + game = actTruthTraitorDecision(game, actor, action, actions); + }; + const reach = (until: (state: Game) => boolean) => { + game = advanceTruthTraitorDecision(game, until, actions); + }; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); + act(game.host, { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); + assert.deepEqual(game.expansions, []); assert.equal(game.deck.length, 33); + assert.ok(game.players.every(player => player.hand.length === 0)); + const originalCards = truthTraitorDecisionInventory(game); + const originalTraitors = truthTraitorDecisionTraitors(game); + assert.equal(originalTraitors.length, 10); + const truthtrance = game.deck.find(card => card.effect === 'truthtrance'); + const ordinary = game.deck.find(card => card.kind === 'projectile'); + assert.ok(truthtrance && ordinary); + const deal = [truthtrance.id, ordinary.id]; + const dealOrder = deal.map((id, position) => { + const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: id }; + }); + const beforeStartingDeal = structuredClone(game); + const traitorOffers = Object.fromEntries(game.players.map(player => [player.id, [...player.traitorChoices]])); + const traitorHeld = truthTraitorDecisionPlayer(game, target).traitorChoices + .find(id => truthTraitorDecisionPlayer(game, atreides).leaders.some(leader => leader.id === id)); + assert.ok(traitorHeld, 'The native Emperor packet offers an opposing Atreides Traitor'); + const atreidesTraitor = truthTraitorDecisionPlayer(game, atreides).traitorChoices[0]; + assert.ok(atreidesTraitor); + const traitorSteps: TruthTraitorDecisionStep[] = [ + { actor: atreides, action: { type: 'traitor', leader: atreidesTraitor } }, + { actor: emperor, action: { type: 'traitor', leader: traitorHeld } }, + ]; + for (const step of traitorSteps) act(step.actor, step.action); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + assert.deepEqual(truthTraitorDecisionPlayer(game, asker).hand.map(card => card.id), [truthtrance.id]); + assert.deepEqual(truthTraitorDecisionPlayer(game, target).hand.map(card => card.id), [ordinary.id]); + assert.deepEqual(truthTraitorDecisionPlayer(game, atreides).forces, { 'arrakeen:10': 10 }); + assert.deepEqual(truthTraitorDecisionPlayer(game, emperor).forces, {}); + assert.equal(truthTraitorDecisionPlayer(game, emperor).reserves, 20); + assert.deepEqual(truthTraitorDecisionPlayer(game, target).traitors, [traitorHeld]); + const startingPositions = Object.fromEntries(game.players.map(player => [player.id, { + forces: structuredClone(player.forces), reserves: player.reserves, spice: player.spice, + }])); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder = [0, 1].map(position => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; + game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) }; + }); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + reach(state => state.turn === 1 && state.phase === 5 && state.active === atreides && truthTraitorDecisionClean(state)); + const beforeAtreidesEnd = structuredClone(game); + act(atreides, { type: 'endMovement' }); + reach(state => state.turn === 1 && state.phase === 5 && state.active === emperor && truthTraitorDecisionClean(state)); + const beforeShipment = structuredClone(game); + const shipmentStep: TruthTraitorDecisionStep = { actor: emperor, action: { + type: 'ship', amount: 1, territory: 'arrakeen', sector: 10, elite: 0, allyPayment: 0, + } }; + act(shipmentStep.actor, shipmentStep.action); + reach(truthTraitorDecisionClean); + const shipment: TruthMovementTransition = { before: beforeShipment, step: shipmentStep, after: structuredClone(game) }; + assert.equal(truthTraitorDecisionPlayer(game, emperor).forces['arrakeen:10'], 1); + assert.equal(truthTraitorDecisionPlayer(game, emperor).reserves, 19); + assert.equal(truthTraitorDecisionPlayer(beforeShipment, emperor).spice - truthTraitorDecisionPlayer(game, emperor).spice, 1); + act(emperor, { type: 'endMovement' }); + reach(state => state.phase === 6 && truthTraitorDecisionClean(state) && !!state.active); + const beforeBattle = structuredClone(game); + const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === 'arrakeen'); + assert.ok(choice); + const battleStep: TruthTraitorDecisionStep = { actor: choice.chooser, action: { + type: 'chooseBattle', territory: 'arrakeen', + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker, + } }; + act(battleStep.actor, battleStep.action); + const beforePreparation = structuredClone(game); + reach(state => !!state.battle && truthTraitorDecisionClean(state) && !state.battle.preparation && + state.battle.preLeader?.closed !== false); + const beforePlans = structuredClone(game); + assert.ok(game.battle && !game.battle.revealed && !Object.keys(game.battle.plans).length); + const opposingLeader = truthTraitorDecisionPlayer(game, emperor).leaders + .filter(leader => !leader.dead && !leader.usedAt) + .sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0]; + assert.ok(opposingLeader); + const atreidesPlan: Action = { type: 'battlePlan', leader: traitorHeld, dial: 0, support: 0, weapon: null, defense: null }; + const emperorPlan: Action = { type: 'battlePlan', leader: opposingLeader.id, dial: 0, support: 0, weapon: null, defense: null }; + const planSteps: TruthTraitorDecisionStep[] = [ + { actor: atreides, action: atreidesPlan }, { actor: emperor, action: emperorPlan }, + ]; + act(planSteps[0].actor, planSteps[0].action); + const beforeReveal = structuredClone(game); + act(planSteps[1].actor, planSteps[1].action); + assert.ok(game.battle?.revealed && truthTraitorDecisionClean(game)); + assert.deepEqual(game.battle.traitorCalls, {}); + const traitorVoters = [...viewGame(game, game.host).battle!.traitorVoters]; + const voter = traitorVoters.find(actor => game.battle!.traitorCalls[actor] === undefined); + assert.ok(voter && traitorVoters.includes(target)); + assert.ok(truthTraitorDecisionPlayer(game, asker).hand.some(card => card.id === truthtrance.id)); + assert.deepEqual(truthTraitorDecisionInventory(game), originalCards); + assert.deepEqual(truthTraitorDecisionTraitors(game), originalTraitors); + const source = structuredClone(game); + const truthStep: TruthTraitorDecisionStep = { actor: asker, action: { type: 'card', card: truthtrance.id } }; + const callStep: TruthTraitorDecisionStep = { actor: target, action: { type: 'traitorCall', call: true } }; + const declineStep: TruthTraitorDecisionStep = { actor: target, action: { type: 'traitorCall', call: false } }; + return { game, source, initial, lobby, setup, beforeStartingDeal, afterSetup, beforeAtreidesEnd, + beforeShipment, shipment, shipmentStep, beforeBattle, battleStep, beforePreparation, beforePlans, beforeReveal, + asker, target, atreides, emperor, owner: asker, respondent: target, voter, currentVoter: voter, traitorVoters, + truth: truthtrance.id, truthtrance: truthtrance.id, card: truthtrance.id, ordinary: ordinary.id, + traitorHeld, traitorCard: traitorHeld, traitorOffers, traitorSteps, + leaders: { [atreides]: traitorHeld, [emperor]: opposingLeader.id }, + atreidesPlan, emperorPlan, ownPlan: atreidesPlan, targetPlan: emperorPlan, + plans: { [atreides]: atreidesPlan, [emperor]: emperorPlan }, planSteps, + territory: 'arrakeen', sector: 10, location: 'arrakeen:10', event: game.battle.event, + truthStep, callStep, declineStep, deal, dealOrder, spiceOrder, originalSpice, startingPositions, + originalCards, originalInventory: originalCards, originalTraitors, + actions, steps: actions, sourceActionCount: actions.length, + staging: [ + 'Actual public Basic Atreides/Emperor ready/start with the original base33, native ten-leader Traitor deck and no optional modules. Only native scalar lotteries, two unopened starting Treachery positions and two unopened ordinary Spice positions are selected. UUID and non-scalar entropy remain native.', + 'Atreides is actually dealt Truthtrance; Emperor receives an ordinary projectile. Emperor actually chooses its offered opposing Atreides Traitor during original setup. No Traitor exchange or played hand, disc, phase, force, wallet or receipt is installed.', + 'Complete actual safe Storm, Spice Blow, Charity, Bidding and Revival. Atreides ends its unused Movement; Emperor pays one spice to ship one original reserve into native Atreides ten at Arrakeen10 and really ends Movement. Choose the resulting native battle, decline original preparation and seal both actual zero-dial unarmed plans with living original leaders.', + 'The Atreides plan uses Emperor’s actually held opposing Traitor. Stop immediately after original plan reveal, with Truthtrance held and no Traitor votes submitted. Both possible declaration choices remain caller-owned; this source makes no promise about the eventual winner.', + ], + }; +} +export type TruthTraitorDecisionFixture = ReturnType; + +export function askTruthTraitorDecision(game: Game, asker: string, card: string, target: string) { + const battle = game.battle; assert.ok(battle?.event); + game = actTruthTraitorDecision(game, asker, { type: 'card', card }); + for (const player of game.players) if (!game.truthtrance!.passed.includes(player.id)) + game = actTruthTraitorDecision(game, player.id, { type: 'truthPass' }); + return actTruthTraitorDecision(game, asker, { type: 'truthAsk', question: { + kind: 'traitorCall', target, battle: battle.event, territory: battle.territory, + } }); +} diff --git a/tests/fixture-truth-traitor-harkonnen.ts b/tests/fixture-truth-traitor-harkonnen.ts new file mode 100644 index 00000000..ec295065 --- /dev/null +++ b/tests/fixture-truth-traitor-harkonnen.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { NEXUS_FACTIONS } from '../game/nexus-cards'; +import { nexusTraitorDrawAction, nexusTraitorReturnAction } from '../game/nexus-traitor-options'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { + actTruthTraitorDecision, truthTraitorDecisionClean, truthTraitorDecisionInventory, + truthTraitorDecisionPlayer, truthTraitorDecisionTraitors, type TruthTraitorDecisionStep, +} from './fixture-truth-traitor-decision'; + +export type TruthTraitorHarkonnenStep = TruthTraitorDecisionStep; +export type TruthTraitorHarkonnenTransition = { before: Game; steps: TruthTraitorHarkonnenStep[]; after: Game }; +export const actTruthTraitorHarkonnen = actTruthTraitorDecision; +export const truthTraitorHarkonnenClean = truthTraitorDecisionClean; +export const truthTraitorHarkonnenInventory = truthTraitorDecisionInventory; +export const truthTraitorHarkonnenPlayer = truthTraitorDecisionPlayer; +export const truthTraitorHarkonnenTraitors = truthTraitorDecisionTraitors; +export function truthTraitorHarkonnenNexusInventory(game: Game) { + const cards = game.nexusCards?.cards; + if (!cards) return []; + const ids = [...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(); + assert.equal(new Set(ids).size, ids.length, 'Each original Nexus singleton retains one current slot'); + return ids; +} + +/** Preserve Truthtrance, every revealed Traitor vote and any native Cunning + * return. A closing Nexus choice is kept through its real public action. */ +export function nextTruthTraitorHarkonnen(game: Game): TruthTraitorHarkonnenStep | null { + if (game.truthtrance || game.nexusTraitorPending) return null; + if (game.battle?.revealed && truthTraitorHarkonnenClean(game) && viewGame(game, game.host).battle!.traitorVoters + .some(actor => game.battle!.traitorCalls[actor] === undefined)) return null; + if (game.nexusCards?.phase?.stage === 'drawing') { + const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); + assert.ok(actor); + return { actor, action: { type: 'nexusCardChoice', turn: phase.turn, + card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } }; + } + return nextStrongholdFactionsNativeStep(game); +} +export const nextTruthTraitorHarkonnenNativeStep = nextTruthTraitorHarkonnen; +export function advanceTruthTraitorHarkonnen(game: Game, until: (state: Game) => boolean, + actions: TruthTraitorHarkonnenStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original Harkonnen chronology ended before its requested control'); + const next = nextTruthTraitorHarkonnen(game); + assert.ok(next, `Choose the original Truthtrance, plan, Traitor vote or Nexus return at ${game.turn}/${game.phase}/${game.decision?.kind}`); + game = actTruthTraitorHarkonnen(game, next.actor, next.action, actions); + } + throw Error('The genuine Harkonnen voter source did not reach its finite boundary'); +} + +/** Public Basic A/H/E seats earn the original Harkonnen opposing Traitor, + * Atreides Truthtrance and a one-spice Karama purchase. A real turn-two worm + * alliance supplies either Harkonnen ally support or an unallied Cunning draw. + * No played cards, alliances, armies, phases or pending controls are installed. */ +export function createTruthTraitorHarkonnenFixture(options: { kind?: 'ally' | 'cunning' } = {}) { + const kind = options.kind ?? 'ally', atreides = 'atreides', harkonnen = 'harkonnen', emperor = 'emperor'; + const asker = atreides, target = harkonnen, beneficiary = kind === 'ally' ? emperor : harkonnen; + let game = createGame('TRUTHTRAITORHARKONNEN', newPlayer(atreides, 'Atreides', 'atreides'), false); + joinGame(game, newPlayer(harkonnen, 'Harkonnen', 'harkonnen')); + joinGame(game, newPlayer(emperor, 'Emperor', 'emperor')); + const initial = structuredClone(game), actions: TruthTraitorHarkonnenStep[] = []; + const act = (actor: string, action: Action) => { game = actTruthTraitorHarkonnen(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceTruthTraitorHarkonnen(game, until, actions); }; + const transition = (run: () => void): TruthTraitorHarkonnenTransition => { + const before = structuredClone(game), start = actions.length; run(); + return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) }; + }; + const enableNexus = kind === 'cunning' ? transition(() => { + act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true }); + }) : null; + for (const player of game.players) act(player.id, { type: 'ready' }); + const lobby = structuredClone(game); act(game.host, { type: 'start' }); + const setup = structuredClone(game); + assert.equal(game.status, 'setup'); assert.equal(game.advanced, false); + assert.deepEqual(game.expansions, []); assert.equal(game.deck.length, 33); + assert.equal(!!game.nexusCards, kind === 'cunning'); + assert.ok(!game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards && + !game.discoveryEnabled && !game.ecazTreachery); + assert.ok(game.players.every(player => !player.hand.length)); + const originalCards = truthTraitorHarkonnenInventory(game); + const originalTraitors = truthTraitorHarkonnenTraitors(game); assert.equal(originalTraitors.length, 15); + const originalNexusInventory = truthTraitorHarkonnenNexusInventory(game); + assert.deepEqual(originalNexusInventory, kind === 'cunning' ? [...NEXUS_FACTIONS].sort() : []); + const truthtrance = game.deck.find(card => card.effect === 'truthtrance'); + const karama = game.deck.find(card => card.effect === 'karama'); + const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 3); + assert.ok(truthtrance && karama); assert.equal(ordinary.length, 3); + const selected = [truthtrance, ...ordinary, karama]; + const dealOrder = selected.map((card, position) => { + const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); + [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; + return { position, from, card: card.id }; + }); + const beforeStartingDeal = structuredClone(game); + const traitorOffers = Object.fromEntries(game.players.map(player => [player.id, [...player.traitorChoices]])); + const heldOriginal = [...truthTraitorHarkonnenPlayer(game, harkonnen).traitors]; + assert.equal(heldOriginal.length, 4); + const traitorHeld = heldOriginal.find(id => truthTraitorHarkonnenPlayer(game, atreides).leaders.some(leader => leader.id === id)); + assert.ok(traitorHeld, 'The native four-card Harkonnen packet holds an opposing Atreides identity'); + const traitorSteps: TruthTraitorHarkonnenStep[] = [atreides, emperor].map(actor => { + const leader = truthTraitorHarkonnenPlayer(game, actor).traitorChoices[0]; assert.ok(leader); + return { actor, action: { type: 'traitor', leader } }; + }); + for (const step of traitorSteps) act(step.actor, step.action); + reach(state => state.status === 'playing'); + const afterSetup = structuredClone(game); + assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.slice(0, 4).map(card => card.id)); + assert.deepEqual(truthTraitorHarkonnenPlayer(game, atreides).forces, { 'arrakeen:10': 10 }); + assert.deepEqual(truthTraitorHarkonnenPlayer(game, harkonnen).forces, { 'carthag:11': 10 }); + assert.equal(truthTraitorHarkonnenPlayer(game, emperor).reserves, 20); + const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const spiceOrder = [0, 1].map(position => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; + game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) }; + }); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice); + reach(state => state.turn === 1 && state.phase === 3 && truthTraitorHarkonnenClean(state) && + state.auction?.active === atreides && state.auction.cards[state.auction.index]?.id === karama.id); + const beforeKaramaPurchase = structuredClone(game); + const karamaPurchase = transition(() => { + act(atreides, { type: 'bid', amount: 1, allyPayment: 0 }); + reach(state => truthTraitorHarkonnenClean(state) && truthTraitorHarkonnenPlayer(state, atreides).hand.some(card => card.id === karama.id)); + }); + assert.equal(truthTraitorHarkonnenPlayer(beforeKaramaPurchase, atreides).spice - truthTraitorHarkonnenPlayer(game, atreides).spice, 1); + reach(state => state.turn === 2 && state.phase === 1 && truthTraitorHarkonnenClean(state) && + !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0); + const beforeWorm = structuredClone(game), beforeWormInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort(); + const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper); + assert.ok(wormIndex >= 0); const worm = game.spiceDeck.splice(wormIndex, 1)[0]; game.spiceDeck.unshift(worm); + const wormOrder = { position: 0, from: wormIndex, card: structuredClone(worm) }; + const wormFollowupOrder = [1, 2].map(position => { + const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; + game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) }; + }); + assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeWormInventory); + reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && truthTraitorHarkonnenClean(state)); + const beforeAlliance = structuredClone(game); + const partners = kind === 'ally' ? [emperor, harkonnen] : [atreides, emperor]; + const alliance = transition(() => { + act(partners[0], { type: 'alliance', target: partners[1] }); + act(partners[1], { type: 'alliance', target: partners[0] }); + }); + assert.equal(truthTraitorHarkonnenPlayer(game, partners[0]).ally, partners[1]); + assert.equal(truthTraitorHarkonnenPlayer(game, partners[1]).ally, partners[0]); + let beforeNexusDraw: Game | null = null, nexusDraw: TruthTraitorHarkonnenTransition | null = null; + let nexusOrder: { card: 'harkonnen'; position: number; from: number } | null = null; + if (kind === 'cunning') { + assert.equal(truthTraitorHarkonnenPlayer(game, harkonnen).ally, null); + reach(state => state.nexusCards?.phase?.stage === 'drawing' && truthTraitorHarkonnenClean(state)); + const cards = game.nexusCards!.cards!, from = cards.deck.indexOf('harkonnen'); assert.ok(from >= 0); + assert.equal(cards.hands[harkonnen], null); + cards.deck.unshift(cards.deck.splice(from, 1)[0]); + nexusOrder = { card: 'harkonnen', position: 0, from }; + beforeNexusDraw = structuredClone(game); + nexusDraw = transition(() => { act(harkonnen, { type: 'nexusCardChoice', turn: game.turn, + card: null, choice: 'draw', ownRedraws: 0 }); }); + assert.equal(game.nexusCards!.cards!.hands[harkonnen], 'harkonnen'); + } + reach(state => state.turn === 2 && state.phase === 5 && state.active === beneficiary && truthTraitorHarkonnenClean(state)); + const beforeShipment = structuredClone(game); + const shipmentStep: TruthTraitorHarkonnenStep = { actor: beneficiary, action: { + type: 'ship', amount: 1, territory: 'arrakeen', sector: 10, elite: 0, allyPayment: 0, + } }; + const shipment = transition(() => { act(shipmentStep.actor, shipmentStep.action); reach(truthTraitorHarkonnenClean); }); + assert.equal(truthTraitorHarkonnenPlayer(beforeShipment, beneficiary).spice - truthTraitorHarkonnenPlayer(game, beneficiary).spice, 1); + assert.equal(truthTraitorHarkonnenPlayer(game, beneficiary).forces['arrakeen:10'], 1); + assert.equal(truthTraitorHarkonnenPlayer(game, atreides).forces['arrakeen:10'], 10); + act(beneficiary, { type: 'endMovement' }); + reach(state => state.turn === 2 && state.phase === 6 && truthTraitorHarkonnenClean(state) && !!state.active); + const beforeBattle = structuredClone(game); + const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === 'arrakeen'); assert.ok(choice); + const battleStep: TruthTraitorHarkonnenStep = { actor: choice.chooser, action: { + type: 'chooseBattle', territory: 'arrakeen', + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker, + } }; + act(battleStep.actor, battleStep.action); + const beforePreparation = structuredClone(game); + reach(state => !!state.battle && truthTraitorHarkonnenClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false); + const beforePlans = structuredClone(game); + assert.ok(game.battle && !game.battle.revealed && !Object.keys(game.battle.plans).length); + const beneficiaryLeader = truthTraitorHarkonnenPlayer(game, beneficiary).leaders + .filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0]; + assert.ok(beneficiaryLeader); + const atreidesPlan: Action = { type: 'battlePlan', leader: traitorHeld, dial: 0, support: 0, weapon: null, defense: null }; + const beneficiaryPlan: Action = { type: 'battlePlan', leader: beneficiaryLeader.id, dial: 0, support: 0, weapon: null, defense: null }; + const planSteps: TruthTraitorHarkonnenStep[] = [ + { actor: atreides, action: atreidesPlan }, { actor: beneficiary, action: beneficiaryPlan }, + ]; + act(planSteps[0].actor, planSteps[0].action); + const beforeReveal = structuredClone(game); act(planSteps[1].actor, planSteps[1].action); + assert.ok(game.battle?.revealed && truthTraitorHarkonnenClean(game)); + assert.deepEqual(game.battle.traitorCalls, {}); + const traitorVoters = [...viewGame(game, game.host).battle!.traitorVoters]; + assert.ok(traitorVoters.includes(harkonnen)); + assert.equal([game.battle.attacker, game.battle.defender].includes(harkonnen), kind === 'cunning'); + const beforeCunning = structuredClone(game), beforeCunningActionCount = actions.length; + let drawStep: TruthTraitorHarkonnenStep | null = null, cunningDraw: TruthTraitorHarkonnenTransition | null = null; + let returnHeld: Action | null = null, returnDrawn: Action | null = null, pendingEvent: string | null = null; + let drawn: string[] = []; + if (kind === 'cunning') { + const view = viewGame(game, harkonnen), offer = view.nexusTraitors?.offer; assert.ok(offer && !offer.blocked); + const action = nexusTraitorDrawAction(view, offer.event, 'cunning'); assert.ok(action); + drawStep = { actor: harkonnen, action }; + cunningDraw = transition(() => { act(harkonnen, action); }); + const pending = viewGame(game, harkonnen).nexusTraitors?.pending; + assert.ok(pending && pending.owner === harkonnen && pending.count === 1); + pendingEvent = pending.event; + drawn = pending.choices.filter(choice => choice.drawn).map(choice => choice.id); assert.equal(drawn.length, 1); + returnHeld = nexusTraitorReturnAction(viewGame(game, harkonnen), pending.event, [traitorHeld]); + returnDrawn = nexusTraitorReturnAction(viewGame(game, harkonnen), pending.event, drawn); + assert.ok(returnHeld && returnDrawn); + assert.equal(game.nexusCards!.cards!.hands[harkonnen], null); + assert.ok(game.nexusCards!.cards!.discard.includes('harkonnen')); + } + for (const card of [truthtrance, karama]) + assert.ok(truthTraitorHarkonnenPlayer(game, atreides).hand.some(held => held.id === card.id)); + assert.ok(truthTraitorHarkonnenPlayer(game, harkonnen).traitors.includes(traitorHeld)); + assert.deepEqual(truthTraitorHarkonnenInventory(game), originalCards); + assert.deepEqual(truthTraitorHarkonnenTraitors(game), originalTraitors); + assert.deepEqual(truthTraitorHarkonnenNexusInventory(game), originalNexusInventory); + const source = structuredClone(game); + const truthStep: TruthTraitorHarkonnenStep = { actor: asker, action: { type: 'card', card: truthtrance.id } }; + const callStep: TruthTraitorHarkonnenStep = { actor: harkonnen, action: { type: 'traitorCall', call: true } }; + const declineStep: TruthTraitorHarkonnenStep = { actor: harkonnen, action: { type: 'traitorCall', call: false } }; + return { game, source, initial, enableNexus, lobby, setup, beforeStartingDeal, afterSetup, + beforeKaramaPurchase, karamaPurchase, beforeWorm, wormOrder, wormFollowupOrder, beforeAlliance, alliance, + beforeNexusDraw, nexusDraw, nexusOrder, beforeShipment, shipmentStep, shipment, + beforeBattle, battleStep, beforePreparation, beforePlans, beforeReveal, + beforeCunning, beforeDraw: beforeCunning, beforeCunningActionCount, drawStep, cunningDraw, + pendingEvent, drawn, returnHeld, returnDrawn, + kind, asker, target, respondent: target, owner: asker, atreides, emperor, harkonnen, + beneficiary, opponent: atreides, voter: harkonnen, currentVoter: harkonnen, + firstVoter: traitorVoters[0], traitorVoters, traitorHeld, traitorCard: traitorHeld, heldOriginal, traitorOffers, traitorSteps, + truth: truthtrance.id, truthtrance: truthtrance.id, card: truthtrance.id, karama: karama.id, + leaders: { [atreides]: traitorHeld, [beneficiary]: beneficiaryLeader.id }, + atreidesPlan, beneficiaryPlan, ownPlan: atreidesPlan, targetPlan: beneficiaryPlan, planSteps, + plans: { [atreides]: atreidesPlan, [beneficiary]: beneficiaryPlan }, + territory: 'arrakeen', sector: 10, location: 'arrakeen:10', event: game.battle!.event, + truthStep, callStep, declineStep, originalCards, originalInventory: originalCards, originalTraitors, + originalNexusInventory, originalSpice, dealOrder, spiceOrder, actions, steps: actions, sourceActionCount: actions.length, + staging: [ + 'Actual public Basic Atreides/Harkonnen/Emperor seat order uses original base33 and fifteen native Traitors. Harkonnen keeps its original four-card packet, which genuinely contains the living Atreides leader later sealed by Atreides. No held Traitor exchange is required.', + 'Only native scalar lotteries, five unopened Treachery positions and unopened ordinary Spice/worm positions are selected. Atreides receives its original starting Truthtrance; Harkonnen receives two original ordinary cards and Emperor one. Atreides wins the first original Karama auction lot for one own spice and retains both special cards.', + 'The peaceful first turn completes through actual Storm, Spice, Charity, normal auction, Revival, Movement, Collection and Mentat. A real original turn-two Shai-Hulud opens Nexus. The returned alliance contains both original proposal and acceptance actions.', + kind === 'ally' + ? 'No optional module is enabled. Emperor and Harkonnen actually become reciprocal allies at Nexus. Emperor pays one to ship one original reserve into Atreides’ native ten Arrakeen counters and ends Movement. Harkonnen remains outside this battle but is its original allied-support Traitor voter.' + : 'Only Nexus Cards is enabled by the actual lobby moduleOption. Atreides and Emperor actually ally, leaving Harkonnen unallied. Move only the unopened Harkonnen Nexus singleton before its real closing draw; retain the original all12 inventory. Harkonnen pays one to ship one original reserve into Atreides’ native ten Arrakeen counters and ends Movement.', + 'Choose the actual Arrakeen battle, decline original preparation and seal living native leaders with zero dial, zero support and no weapon or defense. Atreides uses the exact opposing identity originally held by Harkonnen. No revealed-battle Traitor vote has been submitted; the pre-Cunning snapshot is directly usable.', + kind === 'cunning' + ? 'The final source actually declares Harkonnen Nexus Cunning after reveal, physically draws one native reserve Traitor, discards its original Nexus singleton and stops at the original pending return. Returned payloads can return the original matching identity or the new draw. Truthtrance, the return and all Traitor votes remain caller-owned.' + : 'The final source stops before Harkonnen’s allied declaration and its original Karama response. The returned Atreides Karama is genuinely bought and held; no response is preinstalled.', + 'No played phase, wallet, army, leader, hand, alliance, sealed plan, draw history or pending receipt is assigned. Counts are derived from the returned native action log; source construction is not a final assurance campaign.', + ], + }; +} +export type TruthTraitorHarkonnenFixture = ReturnType; diff --git a/tests/traitor-decision-promises.test.ts b/tests/traitor-decision-promises.test.ts new file mode 100644 index 00000000..d8dbf6b9 --- /dev/null +++ b/tests/traitor-decision-promises.test.ts @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + TraitorDecisionError, + bindTraitorDecisionPromise, + reconcileTraitorDecisionPromises, + settleTraitorDecisionPromises, + traitorDecisionChoices, + validateTraitorDecisionPromise, + type TraitorDecisionContext, + type TraitorDecisionPromise, +} from '../game/traitor-decision-promises'; + +function context(overrides: Partial = {}): TraitorDecisionContext { + return { + turn: 4, battle: 'original-public-battle', player: 'harkonnen', + beneficiary: 'atreides', target: 'emperor', leader: 'aramsham', + canReveal: true, ...overrides, + }; +} + +function promise(overrides: Partial = {}): TraitorDecisionPromise { + return { + turn: 4, battle: 'original-public-battle', player: 'harkonnen', + beneficiary: 'atreides', target: 'emperor', leader: 'aramsham', + asker: 'beneGesserit', answer: true, ...overrides, + }; +} + +void test('native eligibility supplies ordered declaration choices and a submitted decision has none', () => { + assert.deepEqual(traitorDecisionChoices(context(), []), [false, true]); + assert.deepEqual(traitorDecisionChoices(context({ canReveal: false }), []), [false]); + assert.deepEqual(traitorDecisionChoices(context({ leader: null, canReveal: false }), []), [false]); + for (const canReveal of [false, true]) { + assert.deepEqual(traitorDecisionChoices(context({ canReveal, submitted: true }), []), []); + } + assert.deepEqual(traitorDecisionChoices(context({ submitted: false }), []), []); +}); + +void test('compatible definite answers bind independently while contradiction and impossible Yes reject without mutation', () => { + const ctx = context(); + const first = bindTraitorDecisionPromise(ctx, [], 'beneGesserit', true); + const before = structuredClone({ ctx, first }); + const second = bindTraitorDecisionPromise(ctx, first, 'fremen', true); + assert.deepEqual(second, [promise(), promise({ asker: 'fremen' })]); + assert.deepEqual(traitorDecisionChoices(ctx, second), [true]); + assert.deepEqual({ ctx, first }, before); + assert.notEqual(second, first); + assert.notEqual(second[0], first[0]); + assert.throws(() => bindTraitorDecisionPromise(ctx, first, 'fremen', false), TraitorDecisionError); + assert.deepEqual({ ctx, first }, before); + const no = bindTraitorDecisionPromise(context({ canReveal: false }), [], 'beneGesserit', false); + assert.deepEqual(traitorDecisionChoices(context(), no), [false]); + assert.throws(() => bindTraitorDecisionPromise(context({ canReveal: false }), [], 'fremen', true), TraitorDecisionError); + assert.throws(() => bindTraitorDecisionPromise(context({ submitted: true }), [], 'fremen', false), TraitorDecisionError); +}); + +void test('only the complete original public source constrains a current declaration', () => { + const changes: Partial[] = [ + { turn: 3 }, { battle: 'other-battle' }, { player: 'other-voter' }, + { beneficiary: 'other-beneficiary' }, { target: 'other-target' }, { leader: 'other-leader' }, + { leader: null }, { fulfilled: true }, { released: true }, + ]; + for (const change of changes) { + const ledger = [promise(change)]; + const before = structuredClone(ledger); + assert.deepEqual(traitorDecisionChoices(context(), ledger), [false, true]); + assert.deepEqual(reconcileTraitorDecisionPromises(context({ canReveal: false }), ledger), ledger); + assert.deepEqual(ledger, before); + } + assert.deepEqual(traitorDecisionChoices(context(), [promise(), promise({ asker: 'fremen', answer: false })]), []); +}); + +void test('settlement validates before the actual assignment and fulfills either matching declaration exactly once', () => { + for (const call of [false, true]) { + const ctx = context(); + const ledger = [ + promise({ answer: call }), promise({ answer: call, asker: 'fremen' }), + promise({ battle: 'older-battle', answer: !call }), + promise({ beneficiary: 'other-beneficiary', answer: !call }), + promise({ released: true, answer: !call }), promise({ fulfilled: true, answer: !call }), + ]; + const before = structuredClone({ ctx, ledger }); + assert.throws(() => settleTraitorDecisionPromises(ctx, ledger, !call), TraitorDecisionError); + assert.deepEqual({ ctx, ledger }, before); + const settled = settleTraitorDecisionPromises(ctx, ledger, call); + assert.equal(settled[0].fulfilled, true); + assert.equal(settled[1].fulfilled, true); + assert.deepEqual(settled.slice(2), ledger.slice(2)); + assert.deepEqual({ ctx, ledger }, before); + assert.equal(ctx.submitted, undefined, 'The caller still owns actual declaration assignment'); + assert.throws(() => settleTraitorDecisionPromises({ ...ctx, submitted: true }, settled, call), TraitorDecisionError); + } + assert.throws(() => settleTraitorDecisionPromises(context({ canReveal: false }), [], true), TraitorDecisionError); + assert.deepEqual(settleTraitorDecisionPromises(context({ canReveal: false }), [promise({ answer: false })], false), + [promise({ answer: false, fulfilled: true })]); +}); + +void test('native impossibility releases only a current live Yes and keeps No, historical sources and fulfilled decisions', () => { + const ctx = context({ canReveal: false }); + const ledger = [ + promise(), promise({ answer: false, asker: 'fremen' }), + promise({ battle: 'another-original-source' }), + promise({ fulfilled: true }), promise({ released: true }), + ]; + const before = structuredClone({ ctx, ledger }); + assert.deepEqual(traitorDecisionChoices(ctx, ledger), []); + const reconciled = reconcileTraitorDecisionPromises(ctx, ledger); + assert.deepEqual(reconciled, [promise({ released: true }), ...ledger.slice(1)]); + assert.deepEqual(traitorDecisionChoices(ctx, reconciled), [false]); + assert.deepEqual(reconcileTraitorDecisionPromises(ctx, reconciled), reconciled); + assert.deepEqual(reconcileTraitorDecisionPromises(context(), ledger), ledger); + assert.deepEqual({ ctx, ledger }, before); +}); + +void test('an accepted declaration remains fulfilled if its later Traitor effect is prevented', () => { + const accepted = bindTraitorDecisionPromise(context(), [], 'beneGesserit', true); + const declared = settleTraitorDecisionPromises(context(), accepted, true); + const before = structuredClone(declared); + const afterPrevention = reconcileTraitorDecisionPromises( + context({ canReveal: false, submitted: true }), declared, + ); + assert.equal(afterPrevention[0].answer, true); + assert.equal(afterPrevention[0].fulfilled, true); + assert.equal(afterPrevention[0].released, undefined); + assert.deepEqual(afterPrevention, before); + assert.deepEqual(traitorDecisionChoices(context({ canReveal: false, submitted: true }), afterPrevention), []); + assert.deepEqual(accepted, [promise()]); +}); + +void test('promise validation accepts only the bounded public source and typed flags, including a null leader', () => { + assert.equal(validateTraitorDecisionPromise(promise()), true); + assert.equal(validateTraitorDecisionPromise(promise({ leader: null, answer: false })), true); + assert.equal(validateTraitorDecisionPromise(promise({ fulfilled: false, released: false })), true); + assert.equal(validateTraitorDecisionPromise(promise({ fulfilled: true })), true); + assert.equal(validateTraitorDecisionPromise(promise({ released: true })), 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(), canReveal: true }, { ...promise(), submitted: false }, + { ...promise(), card: 'private-card' }, { ...promise(), hand: [] }, + ]) { + assert.equal(validateTraitorDecisionPromise(invalid), false); + } + for (const key of ['turn', 'battle', 'player', 'beneficiary', 'target', 'leader', 'asker', 'answer']) { + const missing = { ...promise() } as Record; + delete missing[key]; + assert.equal(validateTraitorDecisionPromise(missing), false, key); + } +}); + +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: '' }, + { ...context(), canReveal: 'yes' }, { ...context(), submitted: 1 }, + { ...context(), leader: undefined }, + ]) { + const ctx = malformed as unknown as TraitorDecisionContext; + assert.throws(() => traitorDecisionChoices(ctx, []), (error: unknown) => + error instanceof TraitorDecisionError && error.name === 'TraitorDecisionError'); + } + const ledger = [{ ...promise(), privateCard: 'secret' }] as unknown as TraitorDecisionPromise[]; + const before = structuredClone(ledger); + for (const operation of [ + () => traitorDecisionChoices(context(), ledger), + () => bindTraitorDecisionPromise(context(), ledger, 'fremen', true), + () => settleTraitorDecisionPromises(context(), ledger, true), + () => reconcileTraitorDecisionPromises(context({ canReveal: false }), ledger), + ]) assert.throws(operation, TraitorDecisionError); + assert.deepEqual(ledger, before); + assert.throws(() => bindTraitorDecisionPromise(context(), [], '', true), TraitorDecisionError); + assert.throws(() => bindTraitorDecisionPromise(context(), [], 'fremen', 'yes' as unknown as boolean), TraitorDecisionError); + assert.throws(() => settleTraitorDecisionPromises(context(), [], 1 as unknown as boolean), TraitorDecisionError); +}); + +void test('binding copies only the original public decision fields and never reads private native matching or power data', () => { + const ctx = context(); + for (const field of ['card', 'hand', 'traitors', 'kh', 'leaders', 'faction', 'ally', 'nativeContext', 'response']) { + Object.defineProperty(ctx, field, { + enumerable: true, + get() { throw new Error(`Pure Traitor decision read private native ${field}`); }, + }); + } + const bound = bindTraitorDecisionPromise(ctx, [], 'beneGesserit', true); + assert.deepEqual(bound, [promise()]); + assert.deepEqual(Object.keys(bound[0]).sort(), [ + 'answer', 'asker', 'battle', 'beneficiary', 'leader', 'player', 'target', 'turn', + ]); + assert.deepEqual(traitorDecisionChoices(ctx, bound), [true]); + assert.equal(settleTraitorDecisionPromises(ctx, bound, true)[0].fulfilled, true); + assert.equal(reconcileTraitorDecisionPromises(ctx, bound)[0].released, undefined); + const privateReceipt = promise(); + Object.defineProperty(privateReceipt, 'card', { + enumerable: true, get() { throw new Error('Validation inspected private card payload'); }, + }); + assert.equal(validateTraitorDecisionPromise(privateReceipt), false); +}); diff --git a/tests/truth-traitor-decision-controls.test.tsx b/tests/truth-traitor-decision-controls.test.tsx new file mode 100644 index 00000000..5e5762b9 --- /dev/null +++ b/tests/truth-traitor-decision-controls.test.tsx @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { Truthtrance } from '../components/truthtrance'; +import { TraitorDecisionAnswers, TraitorDecisionPromises } from '../components/traitor-decision-promises'; +import { viewGame, type Action } from '../game/engine'; +import { createTruthTraitorDecisionFixture as fixture, actTruthTraitorDecision as act, + askTruthTraitorDecision as ask } from './fixture-truth-traitor-decision'; + +function callbacks(node: unknown): (() => void)[] { + if (Array.isArray(node)) return node.flatMap(callbacks); + if (!node || typeof node !== 'object' || !('props' in node)) return []; + const props = (node as ReactElement<{ children?: unknown; onClick?: () => void }>).props; + return [...(props.onClick ? [props.onClick] : []), ...callbacks(props.children)]; +} + +void test('actual current-decision answer controls and persistent declaration notice explain later prevention', () => { + const f = fixture(); let g = ask(f.game, f.asker, f.truth, f.target); + const html = renderToStaticMarkup(createElement(Truthtrance, + { game: viewGame(g, f.target), busy: false, act() {} })); + assert.match(html, /Answer Yes/); assert.match(html, /Answer No/); + assert.match(html, /later Karama or betrayal response/); + const actions: Action[] = [], props = { game: viewGame(g, f.target), busy: false, act: (action: Action) => { actions.push(action); } }; + callbacks(TraitorDecisionAnswers(props))[1](); + assert.deepEqual(actions, [{ type: 'truthAnswer', answer: 'yes' }]); + callbacks(TraitorDecisionAnswers({ ...props, busy: true }))[1](); assert.equal(actions.length, 1); + g = act(g, f.target, actions[0]); + const notice = renderToStaticMarkup(createElement(TraitorDecisionPromises, { game: viewGame(g, f.target) })); + assert.match(notice, /requires Reveal traitor/); + assert.match(notice, /original declaration fulfills your answer/); + assert.equal(TraitorDecisionPromises({ game: viewGame(g, f.asker) }), null); +}); + +void test('only the current declared battle has the new question authoring choice', () => { + const f = fixture(); let g = act(f.game, f.asker, { type: 'card', card: f.truth }); + for (const p of g.players) if (!g.truthtrance!.passed.includes(p.id)) g = act(g, p.id, { type: 'truthPass' }); + const html = renderToStaticMarkup(createElement(Truthtrance, { game: viewGame(g, f.asker), busy: false, act() {} })); + assert.match(html, /Bind this battle’s Traitor declaration/); + assert.ok(viewGame(g, f.asker).truthTraitorTargets.includes(f.target)); +}); diff --git a/tests/truth-traitor-decision-engine.test.ts b/tests/truth-traitor-decision-engine.test.ts new file mode 100644 index 00000000..4c3bab48 --- /dev/null +++ b/tests/truth-traitor-decision-engine.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { createTruthTraitorDecisionFixture as fixture, actTruthTraitorDecision as act, + truthTraitorDecisionPlayer as player, truthTraitorDecisionInventory as inventory, + askTruthTraitorDecision as ask } from './fixture-truth-traitor-decision'; + +function declare(game: Game, asker: string, card: string) { + game = act(game, asker, { type: 'card', card }); + for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) + game = act(game, p.id, { type: 'truthPass' }); + return game; +} + +for (const answer of ['yes', 'no'] as const) void test(`native revealed-battle Truthtrance ${answer} binds the actual original Traitor declaration`, () => { + const f = fixture(); let g = ask(f.game, f.asker, f.truth, f.target); + assert.deepEqual(viewGame(g, f.target).truthTraitorAnswers, ['no', 'yes']); + assert.equal(viewGame(g, f.asker).truthTraitorAnswers, null); + g = act(g, f.target, { type: 'truthAnswer', answer }); + const promises = g.battle!.traitorDecisionPromises; assert.equal(promises?.length, 1); + assert.deepEqual(viewGame(g, f.target).battle!.traitorCallChoices, [answer === 'yes']); + const before = structuredClone(g); + assert.throws(() => act(g, f.target, { type: 'traitorCall', call: answer !== 'yes' })); + assert.deepEqual(g, before); + g = act(g, f.target, { type: 'traitorCall', call: answer === 'yes' }); + assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true); + assert.equal(g.battle!.traitorCalls[f.target], answer === 'yes'); + g = act(g, f.asker, { type: 'traitorCall', call: false }); + assert.equal(g.battle, null); assert.equal(g.discard.filter(card => card.id === f.truth).length, 1); + assert.deepEqual(inventory(g), f.originalCards); +}); + +void test('an already submitted No cannot be reopened as a future Traitor decision', () => { + const f = fixture(); let g = act(f.game, f.target, { type: 'traitorCall', call: false }); + assert.ok(!viewGame(g, f.asker).truthTraitorTargets.includes(f.target)); + g = declare(g, f.asker, f.truth); + const before = structuredClone(g); + assert.throws(() => act(g, f.asker, { type: 'truthAsk', question: { kind: 'traitorCall', target: f.target, + battle: g.battle!.event, territory: g.battle!.territory } }), /unsubmitted/); + assert.deepEqual(g, before); assert.equal(g.battle!.traitorCalls[f.target], false); + assert.ok(player(g, f.asker).hand.some(card => card.id === f.truth)); +}); + +void test('unrevealed plans cannot be queried as a current Traitor decision', () => { + const f = fixture(), g = declare(f.beforeReveal, f.asker, f.truth), before = structuredClone(g); + assert.throws(() => act(g, f.asker, { type: 'truthAsk', question: { kind: 'traitorCall', target: f.target, + battle: g.battle!.event, territory: g.battle!.territory } }), /revealed battle/); + assert.deepEqual(g, before); +}); + +void test('controlled original Traitor exchange offers only No when the revealed opposing identity is absent', () => { + const f = fixture(), source = structuredClone(f.game), target = player(source, f.target); + // Conserved original-card exchange isolates native inability; not a claimed + // actual exchange action or native source provenance for this changed hand. + const at = source.traitorReserve!.findIndex(id => id !== f.traitorHeld); assert.ok(at >= 0); + const held = target.traitors.indexOf(f.traitorHeld); assert.ok(held >= 0); + [target.traitors[held], source.traitorReserve![at]] = [source.traitorReserve![at], target.traitors[held]]; + const g = ask(source, f.asker, f.truth, f.target); + assert.deepEqual(viewGame(g, f.target).truthTraitorAnswers, ['no']); + assert.throws(() => act(g, f.target, { type: 'truthAnswer', answer: 'yes' })); +}); + +void test('all four minimal policies honor both native Yes and No declaration promises', () => { + const f = fixture(); + for (const answer of ['yes', 'no'] as const) for (const difficulty of DIFFICULTIES) { + let g = ask(f.game, f.asker, f.truth, f.target); + g = act(g, f.target, { type: 'truthAnswer', answer }); + const view = viewGame(g, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty; + const choice = botActions(view)[0]; assert.ok(choice); + assert.deepEqual(choice, { type: 'traitorCall', call: answer === 'yes' }); + g = act(g, f.target, choice); assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true); + } +}); + +void test('all four minimal policies answer the native current-decision question then declare legally', () => { + const f = fixture(); + for (const difficulty of DIFFICULTIES) { + let g = ask(f.game, f.asker, f.truth, f.target); + const answerView = viewGame(g, f.target); answerView.players.find(p => p.id === f.target)!.bot = difficulty; + const answer = botActions(answerView)[0]; assert.equal(answer?.type, 'truthAnswer'); + g = act(g, f.target, answer); + const callView = viewGame(g, f.target); callView.players.find(p => p.id === f.target)!.bot = difficulty; + g = act(g, f.target, botActions(callView)[0]); + assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true); + } +}); diff --git a/tests/truth-traitor-harkonnen-engine.test.ts b/tests/truth-traitor-harkonnen-engine.test.ts new file mode 100644 index 00000000..1db234bd --- /dev/null +++ b/tests/truth-traitor-harkonnen-engine.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { nexusTraitorBotActions, nexusTraitorReturnAction } from '../game/nexus-traitor-options'; +import { createTruthTraitorHarkonnenFixture as fixture, actTruthTraitorHarkonnen as act, + advanceTruthTraitorHarkonnen as advance, truthTraitorHarkonnenClean as clean, + truthTraitorHarkonnenInventory as inventory } from './fixture-truth-traitor-harkonnen'; +import { askTruthTraitorDecision as ask } from './fixture-truth-traitor-decision'; + +for (const canceled of [false, true]) void test(`native allied Harkonnen declaration fulfills Yes before its ${canceled ? 'prevented' : 'allowed'} effect`, () => { + const f = fixture(); let g = ask(f.game, f.asker, f.truth, f.target); + assert.ok(viewGame(g, f.target).truthTraitorAnswers!.includes('yes')); + g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' }); + const promise = g.battle!.traitorDecisionPromises![0]; + assert.equal(promise.beneficiary, f.beneficiary); assert.equal(promise.player, f.target); + g = act(g, f.target, { type: 'traitorCall', call: true }); + assert.equal(g.response?.kind, 'harkonnenTraitor'); + assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true); + if (canceled) g = act(g, f.asker, { type: 'card', card: f.karama, mode: 'cancel' }); + else g = advance(g, clean); + assert.equal(g.battle!.traitorCalls[f.target], !canceled); + assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true); + assert.ok(!g.battle!.traitorDecisionPromises![0].released); + assert.deepEqual(inventory(g), f.originalCards); +}); + +void test('native Cunning return retains a promised matching identity through Truthtrance and permits another original choice', () => { + const f = fixture({ kind: 'cunning' }); + const returnHeld = f.returnHeld; assert.ok(returnHeld); + let g = ask(f.game, f.asker, f.truth, f.target); + g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' }); + const view = viewGame(g, f.target), pending = view.nexusTraitors!.pending!; + assert.ok(pending.choices.find(choice => choice.id === f.traitorHeld)?.blocked); + assert.equal(nexusTraitorReturnAction(view, pending.event, [f.traitorHeld]), null); + const before = structuredClone(g); + assert.throws(() => act(g, f.target, returnHeld)); assert.deepEqual(g, before); + const safe = nexusTraitorBotActions(view)[0]; assert.ok(safe); assert.notDeepEqual(safe, returnHeld); + g = act(g, f.target, safe); + assert.equal(g.nexusTraitorPending, null); + g = act(g, f.target, { type: 'traitorCall', call: true }); + assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true); + assert.deepEqual(inventory(g), f.originalCards); +}); + +void test('native Cunning may return the matching identity after No without evading that declaration choice', () => { + const f = fixture({ kind: 'cunning' }); let g = ask(f.game, f.asker, f.truth, f.target); + assert.ok(f.returnHeld); + g = act(g, f.target, { type: 'truthAnswer', answer: 'no' }); + assert.ok(!viewGame(g, f.target).nexusTraitors!.pending!.choices.find(choice => choice.id === f.traitorHeld)?.blocked); + g = act(g, f.target, f.returnHeld); + g = act(g, f.target, { type: 'traitorCall', call: false }); + assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true); +}); + +void test('native fulfilled declaration survives a later Cunning return of its original matching identity', () => { + const f = fixture({ kind: 'cunning' }); + let g = ask(f.beforeCunning, f.asker, f.truth, f.target); + g = act(g, f.target, { type: 'truthAnswer', answer: 'yes' }); + g = act(g, f.target, { type: 'traitorCall', call: true }); + assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true); + const offer = viewGame(g, f.target).nexusTraitors!.offer!; assert.ok(!offer.blocked); + g = act(g, f.target, { type: 'nexusTraitorDraw', mode: 'cunning', event: offer.event }); + const pending = viewGame(g, f.target).nexusTraitors!.pending!; + assert.ok(!pending.choices.find(choice => choice.id === f.traitorHeld)?.blocked); + const returned = nexusTraitorReturnAction(viewGame(g, f.target), pending.event, [f.traitorHeld]); assert.ok(returned); + g = act(g, f.target, returned); + assert.equal(g.battle!.traitorCalls[f.target], true); + assert.equal(g.battle!.traitorDecisionPromises![0].fulfilled, true); +});