Combine current facts with native Traitor decisions

This commit is contained in:
Quintine committed 2026-10-26 18:52:06 +11:00
1 parent 812f77e348
commit 13398aa76e
22 files changed
+1512 -17

No files matched your search

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