Combine current facts with native battle plans

This commit is contained in:
Quintine committed 2026-10-26 18:07:52 +11:00
1 parent 05330ca7c9
commit 812f77e348
24 files changed
+1615 -42

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
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 { MixedBattleActionClaim } from '../game/mixed-battle-question';
import type { TruthAnswer, TruthFact } from '../game/truthtrance';
import {
createTruthPlanExpressionFixture, actTruthPlanExpression,
passTruthPlanExpressionPriority, truthPlanExpressionClean,
truthPlanExpressionInventory, truthPlanExpressionPlayer,
type TruthPlanExpressionStep,
} from './fixture-truth-plan-expression';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type TruthMixedBattleExpression = MixedActionExpression<MixedBattleActionClaim>;
export type TruthMixedBattleStep = TruthPlanExpressionStep;
export const actTruthMixedBattle = actTruthPlanExpression;
export const truthMixedBattleClean = truthPlanExpressionClean;
export const truthMixedBattlePlayer = truthPlanExpressionPlayer;
export const truthMixedBattleInventory = truthPlanExpressionInventory;
export const truthMixedBattleFact = (fact: TruthFact): TruthMixedBattleExpression => ({ kind: 'fact', fact });
export const truthMixedBattleAction = (action: MixedBattleActionClaim): TruthMixedBattleExpression => ({ kind: 'action', action });
export const truthMixedBattleGroup = (kind: 'and' | 'or', ...terms: TruthMixedBattleExpression[]): TruthMixedBattleExpression =>
({ kind, terms });
/** Thin original Basic base33 adapter. Select an earned source snapshot and its
* exact action prefix, never install a played hand, wallet, battle, plan,
* commitment, answer, frozen fact or residual. The source owns one Truth. */
export function createTruthMixedBattleFixture(options: {
mixed?: TruthMixedBattleExpression; window?: 'plans' | 'preparation';
} = {}) {
const base = createTruthPlanExpressionFixture(), window = options.window ?? 'plans';
const battleAt = base.actions.findLastIndex(step => step.action.type === 'chooseBattle');
assert.ok(battleAt >= 0);
const game = structuredClone(window === 'preparation' ? base.beforePreparation : base.game);
const actions = structuredClone(window === 'preparation' ? base.actions.slice(0, battleAt + 1) : base.actions);
assert.ok(game.battle?.event); const event = game.battle.event;
const absent = base.setup.deck.find(card => card.kind === 'shield'); assert.ok(absent);
const projectileFact: TruthFact = { kind: 'hand', name: base.projectile.name };
const absentFact: TruthFact = { kind: 'hand', name: absent.name };
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthMixedBattlePlayer(game, base.target).spice };
const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
const weaponClaim: MixedBattleActionClaim = { kind: 'weapon', name: base.projectile.name };
const mixed = structuredClone(options.mixed ?? truthMixedBattleGroup('and', truthMixedBattleFact(projectileFact),
truthMixedBattleAction(weaponClaim)));
const question = { kind: 'mixedBattlePlan' as const, target: base.target, battle: event,
territory: base.territory, mixed };
const questionAction = { type: 'truthAsk', question } satisfies Action;
return { ...base, base, planSource: base, game, source: structuredClone(game), window, event, battle: event,
mixed, question, questionAction, askStep: { actor: base.asker, action: questionAction },
weaponClaim, projectileFact, heldFact: projectileFact, absentFact, spiceFact, forecastFact,
sourceActions: actions, actions, steps: actions, sourceActionCount: actions.length,
sourceCounts: { inherited: base.sourceActionCount, beforePlans: base.sourceActionCount,
beforePreparation: battleAt + 1, inheritedReveal: base.base.sourceActionCount },
sourceDisclosure: `Original public Basic Atreides/Emperor base33 source, ${actions.length} actual actions, at earned ${window === 'plans' ? 'beforePlans' : 'beforePreparation'}. Only inherited unopened starting Treachery/ordinary Spice positions and scalar lotteries are selected. Atreides actually holds one original Truthtrance, Emperor its original projectile and one actually paid reserve at Arrakeen10. This adapter changes only the raw question; all declarations, answers, preparation, payments, seals and votes remain actual caller controls.`,
staging: [...base.staging.slice(0, 2),
`Use the inherited actual phase, paid shipment and battle-selection prefix through its earned ${window === 'plans' ? 'beforePlans snapshot, including the original preparation decline' : 'beforePreparation snapshot, before its original preparation decline'}. Omit the later original plan seals; no private plan is installed.`,
'The thin adapter supplies only an original current-fact/future-plan question with the native battle event and territory. It adds no played state. The original source owns one Truthtrance; no second physical copy or Ghola is supplied.'],
};
}
export type TruthMixedBattleFixture = ReturnType<typeof createTruthMixedBattleFixture>;
/** Stop at private seals or public Traitor votes: those remain caller-owned. */
export function nextTruthMixedBattle(game: Game): TruthMixedBattleStep | null {
if (game.truthtrance) return null;
if (game.battle?.revealed && truthMixedBattleClean(game) && viewGame(game, game.host).battle!.traitorVoters
.some(actor => game.battle!.traitorCalls[actor] === undefined)) return null;
return nextStrongholdFactionsNativeStep(game);
}
export const nextTruthMixedBattleNativeStep = nextTruthMixedBattle;
export function advanceTruthMixedBattle(game: Game, until: (state: Game) => boolean,
actions: TruthMixedBattleStep[] = []): Game {
for (let count = 0; count < 800; count++) {
if (until(game)) return game;
const next = nextTruthMixedBattle(game);
assert.ok(next, 'Choose the original Truthtrance, private plan or Traitor vote before continuing');
game = actTruthMixedBattle(game, next.actor, next.action, actions);
}
throw Error('The original mixed battle source did not reach its finite boundary');
}
export function settleTruthMixedBattle(game: Game, actions: TruthMixedBattleStep[] = []): Game {
return advanceTruthMixedBattle(game, truthMixedBattleClean, actions);
}
export function declareTruthMixedBattle(f: TruthMixedBattleFixture, source = f.game) {
const before = structuredClone(source), actions: TruthMixedBattleStep[] = [];
let game = actTruthMixedBattle(before, f.asker, { type: 'card', card: f.truth }, actions);
const opened = structuredClone(game);
game = passTruthPlanExpressionPriority(game, actions);
return { game, before, opened, beforeQuestion: structuredClone(game), actions };
}
export const createTruthMixedBattleAskSource = declareTruthMixedBattle;
export function askTruthMixedBattle(game: Game, asker: string, target: string, mixed: TruthMixedBattleExpression,
actions: TruthMixedBattleStep[] = []): Game {
assert.ok(game.battle);
return actTruthMixedBattle(game, asker, { type: 'truthAsk', question: {
kind: 'mixedBattlePlan', target, battle: game.battle.event ?? null,
territory: game.battle.territory, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthMixedBattle(game: Game, target: string, answer: TruthAnswer,
actions: TruthMixedBattleStep[] = []): Game {
assert.ok(viewGame(game, target).truthMixedBattleAnswers?.includes(answer));
return actTruthMixedBattle(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthMixedBattle(f: TruthMixedBattleFixture, answer: 'yes' | 'no' = 'yes',
mixed = f.mixed, source = f.game) {
const declared = declareTruthMixedBattle(f, source), actions = declared.actions;
const asked = askTruthMixedBattle(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthMixedBattle(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
/** Seal the two actual original plans; solver previews never call this. */
export function revealTruthMixedBattle(game: Game, f: TruthMixedBattleFixture,
plan: Action = f.projectilePlan, actions: TruthMixedBattleStep[] = []) {
game = actTruthMixedBattle(game, f.target, plan, actions);
const afterTargetPlan = structuredClone(game);
game = actTruthMixedBattle(game, f.asker, f.ownPlan, actions);
assert.ok(game.battle?.revealed);
return { game, afterTargetPlan, actions };
}
export const sealTruthMixedBattle = revealTruthMixedBattle;
/** Actually decline original public votes and finish original cleanup. */
export function finishTruthMixedBattle(game: Game, f: TruthMixedBattleFixture,
actions: TruthMixedBattleStep[] = []): Game {
for (let count = 0; count < 100; count++) {
if (!game.battle || game.battle.event !== f.event) return game;
assert.ok(game.battle.revealed, 'Seal both original private plans before battle cleanup');
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'Finish the current native battle control');
game = actTruthMixedBattle(game, next.actor, next.action, actions);
}
throw Error('The original battle cleanup did not finish');
}
/** Read only the respondent's original raw aggregate and terminal status. */
export function truthMixedBattlePromise(game: Game,
target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
const row = viewGame(game, target).mixedBattlePromises.find(row => row.player === target);
assert.ok(row, 'The respondent retains the original mixed question and aggregate status');
return row;
}
+48
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
compileMixedActionExpression,
mixedActionExpressionSize,
mixedActionExpressionText,
MixedActionQuestionError,
parseMixedActionExpression,
@@ -146,6 +147,53 @@ void test('one four-level budget includes nested current-fact AND/OR and support
assert.throws(() => decode(simple, limits), MixedActionQuestionError);
});
void test('shared logical size counts actual fact groups once and measures every group arity', () => {
const embedded: Expression = { kind: 'fact', fact: { kind: 'or', terms: [
{ kind: 'spice', compare: 'gte', value: 5 },
{ kind: 'and', terms: [
{ kind: 'spice', compare: 'gte', value: 10 },
{ kind: 'spice', compare: 'gte', value: 15 },
{ kind: 'spice', compare: 'gte', value: 20 },
] },
] } };
assert.deepEqual(mixedActionExpressionSize(fact()), { leaves: 1, depth: 0, nodes: 1, maxTerms: 0 });
assert.deepEqual(mixedActionExpressionSize(action()), { leaves: 1, depth: 0, nodes: 1, maxTerms: 0 });
assert.deepEqual(mixedActionExpressionSize(embedded), { leaves: 4, depth: 2, nodes: 6, maxTerms: 3 });
assert.deepEqual(mixedActionExpressionSize(group('and', embedded, action())),
{ leaves: 5, depth: 3, nodes: 8, maxTerms: 3 });
});
void test('optional node limits share one full budget across action leaves and embedded fact groups', () => {
const embedded = (length: number): Expression => ({ kind: 'fact', fact: {
kind: 'or', terms: Array.from({ length }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
} });
const atLimit = group('and', embedded(7), ...Array.from({ length: 7 }, () => action()));
assert.deepEqual(mixedActionExpressionSize(atLimit), { leaves: 14, depth: 2, nodes: 16, maxTerms: 8 });
assert.deepEqual(decode(atLimit, { maxNodes: 16 }), atLimit);
const overLimit = group('and', ...Array.from({ length: 7 }, () => action()), embedded(8));
assert.doesNotThrow(() => decode(overLimit));
assert.throws(() => decode(overLimit, { maxNodes: 16 }), MixedActionQuestionError);
assert.throws(() => decode(group('and', fact(), action()), { maxNodes: 2 }), MixedActionQuestionError);
for (const maxNodes of [0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1])
assert.throws(() => decode(atLimit, { maxNodes }), MixedActionQuestionError);
});
void test('optional group limits include late fact subgroups while absent limits preserve wider legacy groups', () => {
const wideOuter = group('or', fact(), ...Array.from({ length: 8 }, () => action()));
const wideFact: Expression = { kind: 'fact', fact: { kind: 'and', terms:
Array.from({ length: 9 }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
} };
const wideEmbedded = group('or', fact(), action(), wideFact);
for (const original of [wideOuter, wideEmbedded]) {
assert.deepEqual(decode(original), original);
assert.throws(() => decode(original, { maxTerms: 8 }), MixedActionQuestionError);
}
const eight = group('and', fact(), ...Array.from({ length: 7 }, () => action()));
assert.deepEqual(decode(eight, { maxTerms: 8 }), eight);
for (const maxTerms of [0, 1, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1])
assert.throws(() => decode(eight, { maxTerms }), MixedActionQuestionError);
});
void test('known facts fold whole AND/OR questions independently of the selected Yes or No answer', () => {
for (const answer of [false, true]) {
assert.deepEqual(compile(group('and', fact(), action()), 'yes', answer), compiledAction());
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PlanClaimError } from '../game/battle-promises';
import {
MIXED_BATTLE_MAX_DEPTH, MIXED_BATTLE_MAX_LEAVES, MIXED_BATTLE_MAX_NODES,
MIXED_BATTLE_MAX_TERMS, parseBattleActionClaim, type MixedBattleActionClaim,
type MixedBattleClaim,
} from '../game/mixed-battle-question';
import {
mixedActionExpressionSize, MixedActionQuestionError, parseMixedActionExpression,
type MixedActionExpression,
} from '../game/mixed-action-question';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import type { TruthFact } from '../game/truthtrance';
type Expression = MixedActionExpression<MixedBattleActionClaim>;
const leaders = ['atreides-paul', 'emperor-burseg'] as const;
const limits = {
maxNodes: MIXED_BATTLE_MAX_NODES,
maxTerms: MIXED_BATTLE_MAX_TERMS,
maxDepth: MIXED_BATTLE_MAX_DEPTH,
maxLeaves: MIXED_BATTLE_MAX_LEAVES,
};
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const action = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'gte', value: 2.5 }): Expression =>
({ kind: 'action', action: claim });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
const parse = (input: unknown): Expression => parseMixedActionExpression(input,
value => structuredClone(value) as TruthFact, value => parseBattleActionClaim(value, leaders), limits);
void test('mixed battle leaves retain exact native types and current-battle metadata stays on the question', () => {
const leaves: MixedBattleActionClaim[] = [
{ kind: 'dial', compare: 'gte', value: 2.5 },
{ kind: 'support', compare: 'lte', value: 3 },
{ kind: 'weapon', name: 'Gom Jabbar' },
{ kind: 'defense', name: 'Shield' },
{ kind: 'leader', leader: leaders[0] },
{ kind: 'kwisatz', use: true },
];
for (const leaf of leaves) {
const parsed = parseBattleActionClaim(leaf, leaders);
assert.deepEqual(parsed, leaf);
assert.notEqual(parsed, leaf);
}
const question: MixedBattleClaim = {
battle: 'battle-event-7', territory: 'arrakeen', mixed: group('and', fact(), ...leaves.map(action)),
};
assert.deepEqual(parse(question.mixed), question.mixed);
assert.deepEqual(leaders, ['atreides-paul', 'emperor-burseg']);
});
void test('mixed battle parsing rejects groups, wrappers, metadata, missing fields and unknown keys as action leaves', () => {
const invalid: unknown[] = [null, [], {}, { kind: 'unknown' },
{ kind: 'and', terms: [{ kind: 'dial', compare: 'gte', value: 2 }, { kind: 'kwisatz', use: false }] },
{ kind: 'or', terms: [] }, { kind: 'constant', value: true },
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } },
action(), { kind: 'dial', value: 2 }, { kind: 'support', compare: 'gte' },
{ kind: 'weapon' }, { kind: 'defense' }, { kind: 'leader' }, { kind: 'kwisatz' },
{ kind: 'dial', compare: 'gte', value: 2, battle: 'battle-event-7' },
{ kind: 'support', compare: 'eq', value: 2, territory: 'arrakeen' },
{ kind: 'weapon', name: 'Gom Jabbar', target: 'player-e' },
{ kind: 'defense', name: 'Shield', answer: true },
{ kind: 'leader', leader: leaders[0], turn: 1 }, { kind: 'kwisatz', use: true, frozen: true },
];
for (const value of invalid) assert.throws(() => parseBattleActionClaim(value, leaders), PlanClaimError);
});
void test('mixed battle leaves reuse native half-dial, whole support, named-card, leader and Kwisatz bounds', () => {
for (const kind of ['dial', 'support'] as const) for (const compare of ['eq', 'gte', 'lte'] as const)
for (const value of kind === 'dial' ? [0, 0.5, 39.5, 40] : [0, 1, 39, 40])
assert.deepEqual(parseBattleActionClaim({ kind, compare, value }, leaders), { kind, compare, value });
for (const kind of ['weapon', 'defense'] as const)
assert.deepEqual(parseBattleActionClaim({ kind, name: null }, leaders), { kind, name: null });
for (const leader of [null, leaders[1], CHEAP_HERO_TRAITOR])
assert.deepEqual(parseBattleActionClaim({ kind: 'leader', leader }, leaders), { kind: 'leader', leader });
assert.deepEqual(parseBattleActionClaim({ kind: 'kwisatz', use: false }, leaders), { kind: 'kwisatz', use: false });
const invalid: unknown[] = [
...[-0.5, 0.25, 40.5, NaN, Infinity, '2'].map(value => ({ kind: 'dial', compare: 'eq', value })),
...[-1, 0.5, 41, NaN, Infinity, '2'].map(value => ({ kind: 'support', compare: 'gte', value })),
{ kind: 'dial', compare: 'gt', value: 2 }, { kind: 'weapon', name: 'Unprinted Weapon' },
{ kind: 'defense', name: 1 }, { kind: 'leader', leader: 'unpublished-leader' },
{ kind: 'leader', leader: 1 }, { kind: 'kwisatz', use: 'yes' },
];
for (const value of invalid) assert.throws(() => parseBattleActionClaim(value, leaders), PlanClaimError);
});
void test('a decisive earlier fact cannot hide an invalid late native battle leaf', () => {
for (const kind of ['and', 'or'] as const) for (const invalid of [
{ kind: 'dial', compare: 'gte', value: 0.25 },
{ kind: 'weapon', name: 'Gom Jabbar', battle: 'forged-event' },
{ kind: 'and', terms: [{ kind: 'leader', leader: null }, { kind: 'kwisatz', use: false }] },
]) {
const input = group(kind, fact(), group('or', action(), { kind: 'action', action: invalid } as Expression));
assert.throws(() => parse(input), PlanClaimError);
}
});
void test('mixed battle shares the native sixteen-node budget across facts, actions and all logical groups', () => {
assert.deepEqual(limits, { maxNodes: 16, maxTerms: 8, maxDepth: 4, maxLeaves: 16 });
const embedded = (length: number): Expression => ({ kind: 'fact', fact: { kind: 'or', terms:
Array.from({ length }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
} });
const atLimit = group('and', embedded(7), ...Array.from({ length: 7 }, () => action()));
assert.deepEqual(mixedActionExpressionSize(atLimit), { nodes: 16, leaves: 14, depth: 2, maxTerms: 8 });
assert.deepEqual(parse(atLimit), atLimit);
const overLimit = group('or', ...Array.from({ length: 7 }, () => action()), embedded(8));
assert.equal(mixedActionExpressionSize(overLimit).leaves, 15);
assert.throws(() => parse(overLimit), MixedActionQuestionError);
let atDepthLimit = group('and', fact(), action());
for (let depth = 1; depth < 4; depth++) atDepthLimit = group('or', action(), atDepthLimit);
assert.deepEqual(parse(atDepthLimit), atDepthLimit);
assert.throws(() => parse(group('and', action(), atDepthLimit)), MixedActionQuestionError);
});
void test('mixed battle applies the native eight-term limit to outer groups and every embedded fact group', () => {
const eight = group('and', fact(), ...Array.from({ length: 7 }, () => action()));
assert.deepEqual(parse(eight), eight);
const wideFact: Expression = { kind: 'fact', fact: { kind: 'or', terms:
Array.from({ length: 9 }, () => ({ kind: 'spice', compare: 'gte', value: 5 })),
} };
for (const input of [group('or', fact(), ...Array.from({ length: 8 }, () => action())),
group('or', fact(), action(), wideFact)]) {
assert(mixedActionExpressionSize(input).nodes < 16);
assert.throws(() => parse(input), MixedActionQuestionError);
}
});
+293
View File
@@ -0,0 +1,293 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { BattlePromises } from '../components/battle-promises';
import { MixedBattleFields, mixedBattleInputError } from '../components/mixed-battle-fields';
import { MixedBattlePromises } from '../components/mixed-battle-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
import { mixedActionExpressionSize, type MixedActionExpression } from '../game/mixed-action-question';
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import type { TruthFact } from '../game/truthtrance';
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
type Expression = MixedActionExpression<MixedBattleActionClaim>;
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 action = (value: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 0 }): Expression => ({ kind: 'action', action: value });
const dial = (value = 0): Expression => action({ kind: 'dial', compare: 'eq', value });
const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [dial(3), action({ kind: 'leader', leader: 'named-leader' })] }] });
const plan = (): Plan => ({ dial: 3, support: 0, leader: 'named-leader', weapon: null, defense: null, kwisatz: 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-battle-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-battle-${path}-${field}`);
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function amount(tree: ReactNode, path: string, valueAsNumber: number) {
const input = find(tree, 'id', `truth-mixed-battle-${path}-amount`);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
}
function editFact(tree: ReactNode, path: string, next: TruthFact) {
const scope = find(tree, 'data-mixed-battle-fact-path', path);
const fields = elements(scope).find(element => element.type === TruthFactFields);
assert.ok(fields, `Current fact fields ${path}`);
(fields.props.onChange as (fact: TruthFact) => void)(next);
}
/** Controlled projections prove fields, private display and callback guards.
* Source-earned questions, plan legality and reveal lifecycle have native tests. */
function projected(): GameView {
const source = createGame('TRUTHMIXEDBATTLECONTROLS', 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.allLeaders = [
{ id: 'named-leader', name: 'Named Commander', faction: 'emperor', strength: 3 },
{ id: 'foreign-leader', name: 'Foreign Commander', faction: 'atreides', strength: 5 },
];
game.mixedBattlePromises = [{ event: 'controlled-mixed-battle-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: false,
submitted: [], plans: {}, truthPromises: [], compliantPlan: plan(), compliantPreparation: null,
} as unknown as NonNullable<GameView['battle']>;
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: () => MixedBattleFields({ value, game, target, disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(MixedBattleFields, { value, game, target, disabled, onChange() {} })),
error: () => mixedBattleInputError(value, game, target),
};
}
function assistance(game = projected(), busy = false) {
const filled: Plan[] = [], sent: Action[] = [];
const props = { game, busy, fill(value: Plan) { filled.push(value); }, act(value: Action) { sent.push(value); } };
return { props, filled, sent, tree: () => BattlePromises(props),
html: () => renderToStaticMarkup(createElement(BattlePromises, props)) };
}
function nativeButton(tree: ReactNode, text: string) {
const button = elements(tree).find(element => typeof element.props.onClick === 'function' && element.props.children === text);
assert.ok(button, text); return button;
}
const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedBattlePromises, { game }));
void test('controlled mixed battle editor shares the raw sixteen-node and eight-term budgets across fact and plan groups', () => {
const full: Expression = { kind: 'and', terms: [fact({ kind: 'or', terms: Array.from({ length: 8 }, (): TruthFact => ({ kind: 'hand', name: 'Shield' })) }),
{ kind: 'or', terms: Array.from({ length: 5 }, (_, index) => dial(index)) }] };
const e = editor(full);
assert.deepEqual(mixedActionExpressionSize(e.value), { leaves: 13, nodes: 16, depth: 2, maxTerms: 8 });
assert.equal(e.error(), null); assert.match(e.html(), /16 \/ 16 total clauses/);
assert.match(e.html(), /8 conditions per group/);
for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'and-1.2.1', 'fact-or-1.1.1', 'fact-add-1.1']) {
assert.equal(control(e.tree(), key).props.disabled, true, key); click(control(e.tree(), key));
}
assert.equal(e.changes, 0); assert.deepEqual(e.value, full);
amount(e.tree(), '1.2.5', 7.5); assert.equal(e.error(), null);
const ids = [...e.html().matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
assert.equal(new Set(ids).size, ids.length);
const wide = editor({ kind: 'and', terms: [fact(), ...Array.from({ length: 7 }, () => dial())] });
assert.equal(wide.error(), null); assert.equal(mixedActionExpressionSize(wide.value).nodes, 9);
for (const key of ['add-fact-1', 'add-action-1', 'nested-or-1']) {
assert.equal(control(wide.tree(), key).props.disabled, true); click(control(wide.tree(), key));
}
assert.equal(wide.changes, 0);
assert.ok(mixedBattleInputError({ kind: 'and', terms: [fact(), ...Array.from({ length: 8 }, () => dial())] }, projected(), 'p'));
assert.ok(mixedBattleInputError({ kind: 'and', terms: [full, dial()] }, projected(), 'p'));
});
void test('controlled mixed and embedded fact edits preserve branches, collapse removals and stop a fifth grouping level', () => {
const e = editor({ kind: 'and', terms: [fact(), dial(3)] });
click(control(e.tree(), 'fact-or-1.1')); editFact(e.tree(), '1.1.2', { kind: 'spice', compare: 'gte', value: 7 });
select(e.tree(), '1.1', 'fact-meaning', 'and');
click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and');
select(e.tree(), '1.2.1', 'kind', 'weapon'); select(e.tree(), '1.2.1', 'card', 'Shield');
assert.equal(e.error(), null);
click(control(e.tree(), 'remove-1.2.2')); click(control(e.tree(), 'fact-remove-1.1.2'));
assert.deepEqual(e.value, { kind: 'and', terms: [fact(), action({ kind: 'weapon', name: 'Shield' })] });
select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
select(e.tree(), '1.2', 'timing', 'action'); assert.equal(e.error(), null);
let nested = fact();
for (let depth = 0; depth < 4; depth++) nested = { kind: 'and', terms: [nested, dial()] };
const deep = editor(nested); assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
for (const key of ['or-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);
editFact(deep.tree(), '1.1.1.1.1', { kind: 'hand', name: 'Poison Blade' }); assert.equal(deep.changes, 1);
assert.ok(mixedBattleInputError({ kind: 'and', terms: [nested, dial()] }, projected(), 'p'));
});
void test('controlled scalar battle drafts remain repairable and reject metadata, grouped actions and invalid native values', () => {
for (const invalid of [NaN, -1, 0.25, 40.5, Infinity]) {
const e = editor({ kind: 'and', terms: [fact(), dial()] });
amount(e.tree(), '1.2', invalid); assert.ok(e.error()); assert.match(e.html(), /role="alert"/);
click(control(e.tree(), 'or-1.2'));
assert.deepEqual(e.value, { kind: 'and', terms: [fact(), { kind: 'or', terms: [dial(invalid), fact()] }] });
amount(e.tree(), '1.2.1', 0.5); assert.equal(e.error(), null);
select(e.tree(), '1.2.1', 'kind', 'support'); amount(e.tree(), '1.2.1', 0.5); assert.ok(e.error());
amount(e.tree(), '1.2.1', 1); assert.equal(e.error(), null);
}
for (const invalid of [{ kind: 'and', terms: [{ kind: 'dial', compare: 'eq', value: 1 }] },
{ kind: 'dial', compare: 'eq', value: 1, battle: 'other' }, { kind: 'leader', leader: 'unknown' },
{ kind: 'weapon', name: 'Unknown card' }, { kind: 'kwisatz', use: 'yes' }])
assert.ok(mixedBattleInputError({ kind: 'and', terms: [fact(), { kind: 'action', action: invalid }] }, projected(), 'p'));
const frozen = editor({ kind: 'and', terms: [fact(), dial()] }, true), before = structuredClone(frozen.value);
for (const key of ['and-1', 'nested-or-1', 'remove-1.2', 'fact-or-1.1']) click(control(frozen.tree(), key));
amount(frozen.tree(), '1.2', 3); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.2', 'kind', 'leader');
editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
});
void test('controlled plan choices use public names and leaders without consulting either hand, affordability or a fixed plan', () => {
const game = projected();
for (const player of game.players) for (const key of ['hand', 'leaders', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'tanks', 'elites', 'kwisatz'])
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
for (const key of ['battle', 'deck', 'discard', 'truthMixedBattleAnswers']) Object.defineProperty(game, key, {
get() { throw Error(`No ${key} inspection`); }, configurable: true,
});
const e = editor({ kind: 'and', terms: [fact(), action({ kind: 'leader', leader: 'foreign-leader' })] }, false, game);
assert.equal(e.error(), null); const html = e.html();
assert.match(html, /Named Commander/); assert.match(html, /Foreign Commander/); assert.match(html, /Cheap Hero \/ Heroine/);
assert.match(html, /No leader or hero/); assert.match(html, /actual plan in this battle/);
assert.match(html, /already fixed, sealed or revealed/); assert.match(html, /No alternate route is forced/);
select(e.tree(), '1.2', 'leader', CHEAP_HERO_TRAITOR); assert.equal(e.error(), null);
select(e.tree(), '1.2', 'leader', ''); assert.equal(e.error(), null);
select(e.tree(), '1.2', 'kind', 'weapon');
const card = find(e.tree(), 'id', 'truth-mixed-battle-1.2-card');
assert.deepEqual(elements(card).filter(element => element.type === 'option').map(element => element.props.children), ['Empty slot', ...TRUTH_CARD_NAMES]);
select(e.tree(), '1.2', 'card', 'Shield'); assert.equal(e.error(), null);
select(e.tree(), '1.2', 'kind', 'kwisatz'); select(e.tree(), '1.2', 'kwisatz', 'no');
assert.deepEqual(e.value, { kind: 'and', terms: [fact(), action({ kind: 'kwisatz', use: false })] });
});
void test('controlled mixed battle history shows only own original aggregate answers and remains after the battle', () => {
const game = projected();
game.mixedBattlePromises.push(
{ ...game.mixedBattlePromises[0], event: 'no-question', answer: false },
{ ...game.mixedBattlePromises[0], event: 'complete-question', fulfilled: true },
{ ...game.mixedBattlePromises[0], event: 'released-question', released: true },
{ ...game.mixedBattlePromises[0], event: 'foreign-question', player: 'a', mixed: { kind: 'or', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), dial(40)] } },
);
for (const promise of game.mixedBattlePromises) for (const key of ['binding', 'frozen', 'residual', 'factAnswers', 'compiled'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
game.battle = null;
const html = markup(game);
assert.match(html, /You answered Yes/); assert.match(html, /You answered No/);
assert.match(html, /you hold Shield/); assert.match(html, / OR /); assert.match(html, / AND /); assert.match(html, /Named Commander/);
assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
assert.match(html, /Only the aggregate answer is shared/); assert.match(html, /No negates the whole expression/);
assert.match(html, /An open commitment completes when both plans are revealed/);
assert.match(html, /sealing your own plan does not finish it/); assert.match(html, /No alternate route is forced/);
assert.doesNotMatch(html, /Poison Blade|controlled-native-battle|controlled-mixed-battle-question|Fill these plan choices|Complete next preparation/);
assert.equal(MixedBattlePromises({ game: { ...game, me: 'missing' } }), null);
});
void test('controlled mixed-only immediate completion uses the single native Fill control and keeps pure behavior', () => {
const guide = assistance(), original = guide.props.game.battle!.compliantPlan!;
const button = nativeButton(guide.tree(), 'Fill these plan choices'); assert.equal(button.props.disabled, false);
click(button); assert.equal(guide.filled[0], original); assert.equal(guide.sent.length, 0);
assert.match(guide.html(), /Review a compliant plan/); assert.doesNotMatch(guide.html(), /you hold Shield/);
guide.props.game.battle!.preparation = { kind: 'voice', owner: 'a', beneficiary: 'p' };
click(button); assert.equal(guide.filled.length, 2);
const pure = assistance(); pure.props.game.mixedBattlePromises = [];
pure.props.game.battle!.truthPromises = [{ player: 'p', asker: 'a', answer: true, claim: { kind: 'dial', compare: 'gte', value: 3 } }];
click(nativeButton(pure.tree(), 'Fill these plan choices'));
assert.equal(pure.filled[0], pure.props.game.battle!.compliantPlan); assert.match(pure.html(), /Truthtrance commitments/);
const noGuide = projected(); noGuide.battle!.compliantPlan = null;
assert.equal(BattlePromises({ game: noGuide, busy: false, fill() {}, act() {} }), null);
for (const state of ['sealed', 'revealed', 'fulfilled'] as const) {
const current = assistance();
if (state === 'sealed') current.props.game.battle!.submitted.push('p');
else if (state === 'revealed') current.props.game.battle!.revealed = true;
else current.props.game.mixedBattlePromises[0].fulfilled = true;
assert.doesNotMatch(current.html(), /Fill these plan choices/);
}
});
void test('controlled saved Fill callbacks reject stale owner, source, battle, turn, status, guide and quiet-window state', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn++; }, game => { game.phase = 5; }, game => { game.status = 'setup'; },
game => { game.battle!.event = 'later-battle'; }, game => { game.battle!.territory = 'carthag'; },
game => { game.battle = { ...game.battle! }; }, game => { game.battle!.revealed = true; },
game => { game.battle!.submitted.push('p'); }, game => { game.mixedBattlePromises[0].fulfilled = true; },
game => { game.mixedBattlePromises[0].released = true; }, game => { game.mixedBattlePromises[0].player = 'a'; },
game => { game.mixedBattlePromises[0].event = 'later-question'; }, game => { game.mixedBattlePromises[0].answer = false; },
game => { game.mixedBattlePromises[0].asker = 'p'; }, game => { game.mixedBattlePromises[0].mixed = claim(); },
game => { game.mixedBattlePromises.push({ ...game.mixedBattlePromises[0], event: 'another-question' }); },
game => { game.mixedBattlePromises[0].battle = 'later-battle'; }, game => { game.mixedBattlePromises = []; },
game => { game.battle!.compliantPlan = plan(); }, game => { game.battle!.compliantPlan!.dial = 4; },
game => { game.battle!.compliantPreparation = { plan: plan(), actions: [], waitingForIncome: true }; },
game => { game.players.find(player => player.id === 'p')!.autopilot = 'Easy'; },
game => { game.truthtrance = { stage: 'ask', queue: [], passed: [], question: null }; },
game => { game.response = {} as NonNullable<GameView['response']>; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.phaseOpening = {} as NonNullable<GameView['phaseOpening']>; },
game => { game.automaticContinuationPending = true; },
game => { game.roomControl = { paused: true } as NonNullable<GameView['roomControl']>; },
game => { game.roomControl = { closed: true } as NonNullable<GameView['roomControl']>; },
];
for (const change of changes) {
const guide = assistance(), button = nativeButton(guide.tree(), 'Fill these plan choices');
change(guide.props.game); click(button); assert.equal(guide.filled.length, 0, change.toString());
}
for (const change of [
(guide: ReturnType<typeof assistance>) => { guide.props.busy = true; },
(guide: ReturnType<typeof assistance>) => { guide.props.game = projected(); },
(guide: ReturnType<typeof assistance>) => { guide.props.fill = () => assert.fail('Do not use a replaced Fill callback'); },
]) {
const guide = assistance(), button = nativeButton(guide.tree(), 'Fill these plan choices');
change(guide); click(button); assert.equal(guide.filled.length, 0);
}
});
void test('controlled native preparation forwards the exact first card action and guards current preparation without auto-sealing', () => {
function preparationGuide() {
const guide = assistance(), battle = guide.props.game.battle!;
battle.compliantPlan = null;
battle.compliantPreparation = { plan: plan(), actions: [{ type: 'card', card: 'controlled-ghola', leader: 'named-leader' }], waitingForIncome: false };
return guide;
}
const guide = preparationGuide(), original = guide.props.game.battle!.compliantPreparation!.actions[0];
const button = nativeButton(guide.tree(), 'Complete next preparation'); assert.equal(button.props.disabled, false);
click(button); assert.equal(guide.sent[0], original); assert.equal(guide.filled.length, 0);
assert.doesNotMatch(guide.html(), /Fill these plan choices/); assert.match(guide.html(), /Play Ghola to revive Named Commander/);
for (const change of [
(game: GameView) => { game.battle!.compliantPreparation = { ...game.battle!.compliantPreparation! }; },
(game: GameView) => { game.battle!.compliantPreparation!.actions[0] = { ...original }; },
(game: GameView) => { game.battle!.compliantPreparation!.actions[0].card = 'other-card'; },
(game: GameView) => { game.battle!.submitted.push('p'); },
(game: GameView) => { game.mixedBattlePromises[0].fulfilled = true; },
]) {
const fresh = preparationGuide(), saved = nativeButton(fresh.tree(), 'Complete next preparation');
change(fresh.props.game); click(saved); assert.equal(fresh.sent.length, 0);
}
const waiting = preparationGuide(); waiting.props.game.battle!.compliantPreparation = { plan: plan(), actions: [], waitingForIncome: true };
assert.match(waiting.html(), /Revival income must resolve/);
assert.doesNotMatch(waiting.html(), /Complete next preparation|Fill these plan choices/);
});
void test('controlled original private preparation remains usable without pure or mixed Truthtrance records', () => {
const game = projected(); game.mixedBattlePromises = [];
const battle = game.battle!; battle.truthPromises = []; battle.compliantPlan = null;
const next: Action = { type: 'card', card: 'controlled-original-ghola', leader: 'named-leader' };
battle.compliantPreparation = { plan: plan(), actions: [next], waitingForIncome: false };
const guide = assistance(game), button = nativeButton(guide.tree(), 'Complete next preparation');
assert.equal(button.props.disabled, false); click(button);
assert.equal(guide.sent[0], next); assert.equal(guide.filled.length, 0);
assert.match(guide.html(), /Preparation · private/); assert.doesNotMatch(guide.html(), /Fill these plan choices/);
});
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { truthFactAnswer } from '../game/truthtrance';
import {
actTruthMixedBattle as act, bindTruthMixedBattle as bind,
createTruthMixedBattleFixture as fixture, finishTruthMixedBattle as finish,
revealTruthMixedBattle as reveal, truthMixedBattleAction as action,
truthMixedBattleFact as fact, truthMixedBattleGroup as group,
truthMixedBattleInventory as inventory, truthMixedBattlePlayer as player,
truthMixedBattlePromise as promise,
} from './fixture-truth-mixed-battle';
void test('native mixed battle snapshots original spice before an actual bribe debit and still requires its promised projectile', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), action(f.weaponClaim));
const originalSpice = player(f.game, f.target).spice, originalBribes = player(f.game, f.asker).bribes;
const defeated = player(f.game, f.asker).leaders.find(leader => leader.id === f.ownPlan.leader);
assert.ok(defeated); assert.equal(defeated.strength, 1);
assert.ok(originalSpice >= 3); assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
let game = bind(f, 'yes', mixed).game; assert.equal(promise(game).fulfilled, false);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
assert.equal(player(game, f.target).spice, originalSpice - 3); assert.equal(player(game, f.asker).bribes, originalBribes + 3);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, false);
const before = structuredClone(game); assert.throws(() => act(game, f.target, f.targetPlan)); assert.deepEqual(game, before);
game = reveal(game, f).game; assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.deepEqual(promise(game).mixed, mixed); assert.equal(game.battle!.plans[f.target].weapon, f.ordinary);
game = finish(game, f); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.asker).leaders.find(leader => leader.id === defeated.id)!.dead, true);
assert.equal(player(game, f.target).spice, originalSpice - 3 + defeated.strength);
assert.equal(player(game, f.asker).bribes, originalBribes + 3);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native constant mixed battle Yes stays terminal after a real bribe changes its snapshotted spice fact', () => {
const f = fixture(), mixed = group('or', fact(f.spiceFact), action(f.weaponClaim));
const originalSpice = player(f.game, f.target).spice;
let game = bind(f, 'yes', mixed).game; assert.equal(promise(game).fulfilled, true);
assert.equal(game.battle!.truthPromises?.length ?? 0, 0);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
assert.equal(player(game, f.target).spice, originalSpice - 3);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, true);
game = reveal(game, f, f.targetPlan).game; assert.equal(game.battle!.plans[f.target].weapon, null);
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
assert.equal(promise(finish(game, f)).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
});
+255
View File
@@ -0,0 +1,255 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
import {
actTruthMixedBattle as act, advanceTruthMixedBattle as advance,
answerTruthMixedBattle as answer, askTruthMixedBattle as ask,
bindTruthMixedBattle as bind, createTruthMixedBattleFixture as fixture,
declareTruthMixedBattle as declare, finishTruthMixedBattle as finish,
revealTruthMixedBattle as reveal, settleTruthMixedBattle as settle,
truthMixedBattleAction as action, truthMixedBattleClean as clean,
truthMixedBattleFact as fact, truthMixedBattleGroup as group,
truthMixedBattleInventory as inventory, truthMixedBattlePlayer as player,
truthMixedBattlePromise as promise,
} from './fixture-truth-mixed-battle';
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 battle uses the exact original base33 paid-shipment prefix and all six native plan conditions', () => {
const f = fixture(); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.setup.deck.length, 33); assert.ok(f.setup.players.every(seat => seat.hand.length === 0));
assert.equal(f.game.advanced, false); assert.deepEqual(f.game.expansions, []);
assert.equal(f.sourceActionCount, f.planSource.sourceActionCount); assert.equal(f.sourceActionCount, f.actions.length);
assert.equal(f.sourceActionCount, f.planSource.base.sourceActionCount - 2);
assert.deepEqual(f.game, f.planSource.base.beforePlans); assert.equal(f.game.battle!.event, f.event);
assert.equal(f.game.battle!.revealed, false); assert.deepEqual(f.game.battle!.plans, {});
assert.deepEqual(player(f.game, f.target).hand.map(card => card.id), [f.ordinary]);
assert.deepEqual(player(f.game, f.asker).hand.filter(card => card.effect === 'truthtrance').map(card => card.id), [f.truth]);
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); assert.equal(player(f.game, f.target).reserves, 19);
assert.equal(truthFactAnswer(player(f.game, f.target), f.heldFact), 'yes');
const mixed = group('and', fact(f.heldFact), action({ kind: 'dial', compare: 'eq', value: 0 }),
action({ kind: 'support', compare: 'eq', value: 0 }), action(f.weaponClaim),
action({ kind: 'defense', name: null }), action({ kind: 'leader', leader: f.base.base.leaders.emperor }),
action({ kind: 'kwisatz', use: false }));
const before = structuredClone(f.game), bound = bind(f, 'yes', mixed); let game = bound.game;
assert.deepEqual(f.game, before); assert.equal(bound.actionCount, 4); assert.ok(clean(game));
assert.deepEqual(promise(game).mixed, mixed); assert.equal(promise(game).fulfilled, false);
rejected(game, f.target, f.targetPlan); game = reveal(game, f).game;
assert.equal(game.battle!.plans[f.target].weapon, f.ordinary); assert.equal(promise(game).fulfilled, true);
assert.equal(promise(game).released, false); truthOnce(game, f.truth);
game = finish(game, f); assert.equal(game.battle, null); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native true current projectile OR future weapon answers Yes without binding a later unarmed plan', () => {
const f = fixture(), mixed = group('or', fact(f.heldFact), action(f.weaponClaim));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBattleAnswers, ['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!.truthPromises?.length ?? 0, 0); assert.equal(viewGame(game, f.target).battle!.compliantPlan, null);
game = reveal(game, f, f.targetPlan).game; assert.equal(game.battle!.plans[f.target].weapon, null);
assert.equal(promise(finish(game, f)).fulfilled, true); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false current Shield AND future weapon answers No without forbidding the later real projectile', () => {
const f = fixture(), mixed = group('and', fact(f.absentFact), action(f.weaponClaim));
assert.equal(truthFactAnswer(player(f.game, f.target), f.absentFact), 'no');
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBattleAnswers, ['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!.truthPromises?.length ?? 0, 0); game = reveal(game, f).game;
assert.equal(game.battle!.plans[f.target].weapon, f.ordinary); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(finish(game, f)), f.originalCards);
});
void test('native false current Shield OR future weapon Yes still requires the original projectile plan', () => {
const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.absentFact), action(f.weaponClaim))).game;
rejected(game, f.target, f.targetPlan); assert.equal(promise(game).fulfilled, false);
game = reveal(game, f).game; assert.equal(promise(game).fulfilled, true);
assert.equal(game.battle!.plans[f.target].weapon, f.ordinary); assert.deepEqual(inventory(finish(game, f)), f.originalCards);
});
void test('native true current projectile AND future weapon No forbids only the promised whole result', () => {
const f = fixture(); let game = bind(f, 'no').game;
rejected(game, f.target, f.projectilePlan); assert.equal(promise(game).fulfilled, false);
game = reveal(game, f, f.targetPlan).game; assert.equal(game.battle!.plans[f.target].weapon, null);
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).answer, false);
assert.deepEqual(inventory(finish(game, f)), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native unknown non-Fremen forecast requires a conservative whole mixed battle ${answerValue}`, () => {
const f = fixture(), p = player(f.game, f.target); assert.equal(truthKnowledgeOf(f.game, p).stormForecast, null);
assert.equal(truthFactAnswer(p, f.forecastFact, truthKnowledgeOf(f.game, p)), 'unknown');
const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), action(f.weaponClaim));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBattleAnswers, [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, answerValue === 'yes' ? f.targetPlan : f.projectilePlan);
game = reveal(game, f, answerValue === 'yes' ? f.projectilePlan : f.targetPlan).game;
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game, f)), f.originalCards);
});
void test('native nested whole No preserves both actual alternative plan completions', () => {
const f = fixture(), mixed = group('and', fact(f.heldFact), group('or',
group('and', action({ kind: 'dial', compare: 'eq', value: 1 }), action(f.weaponClaim)),
group('and', action({ kind: 'dial', compare: 'eq', value: 0 }), action({ kind: 'weapon', name: null }))));
const alternatives: Action[] = [{ ...f.projectilePlan, dial: 0 }, { ...f.targetPlan, dial: 1 }];
for (const manual of alternatives) {
let game = bind(f, 'no', mixed).game;
rejected(game, f.target, f.targetPlan); rejected(game, f.target, { ...f.projectilePlan, dial: 1 });
game = reveal(game, f, manual).game; assert.equal(promise(game).fulfilled, true);
assert.equal(game.battle!.plans[f.target].dial, manual.dial); assert.equal(game.battle!.plans[f.target].weapon, manual.weapon);
assert.deepEqual(promise(game).mixed, mixed); assert.deepEqual(inventory(finish(game, f)), f.originalCards);
}
});
void test('native solver and Fill previews and the respondents own seal leave a mixed aggregate pending until actual reveal', () => {
const f = fixture(), bound = bind(f), source = structuredClone(bound.game); let game = bound.game;
for (let count = 0; count < 3; count++) {
const own = viewGame(game, f.target), other = viewGame(game, f.asker);
assert.ok(own.battle!.compliantPlan); assert.equal(other.battle!.compliantPlan, null);
assert.equal(promise(game).fulfilled, false);
const filled: Action = { type: 'battlePlan', ...own.battle!.compliantPlan };
assert.equal(filled.weapon, f.ordinary, 'The private native completion still contains the hidden residual');
}
assert.deepEqual(game, source, 'Projection and candidate search never fulfill or otherwise mutate the authoritative game');
game = act(game, f.target, f.projectilePlan); assert.equal(game.battle!.revealed, false);
assert.equal(promise(game).fulfilled, false); assert.equal(promise(game).released, false);
game = act(game, f.asker, f.ownPlan); assert.equal(game.battle!.revealed, true); assert.equal(promise(game).fulfilled, true);
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);
});
void test('native already sealed and already revealed respondents keep their fixed answer without new residual binding', () => {
const f = fixture();
for (const revealed of [false, true]) {
const actions: { actor: string; action: Action }[] = [];
let source = act(f.game, f.target, f.projectilePlan, actions);
if (revealed) source = act(source, f.asker, f.ownPlan, actions);
const declared = declare(f, source); assert.ok(viewGame(declared.game, f.asker).truthMixedBattleTargets.includes(f.target));
const asked = ask(declared.game, f.asker, f.target, f.mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBattleAnswers, ['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!.truthPromises?.length ?? 0, 0);
assert.equal(game.battle!.plans[f.target].weapon, f.ordinary); assert.equal(game.battle!.revealed, revealed);
if (!revealed) game = act(game, f.asker, f.ownPlan);
assert.equal(promise(finish(game, f)).fulfilled, true); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('native mixed battle Ask retains exact event territory owner answer and original JSON wire boundaries', () => {
const f = fixture(), declared = declare(f), game = declared.game;
assert.ok(viewGame(game, f.asker).truthMixedBattleTargets.includes(f.target));
rejected(game, f.target, f.questionAction);
for (const replacement of [{ battle: 'other-battle' }, { battle: null }, { territory: 'carthag' },
{ target: 'foreign-seat' }, { binding: { kind: 'constant', value: true } }])
rejected(game, f.asker, { type: 'truthAsk', question: { ...f.question, ...replacement } });
const wire: Action = JSON.parse(JSON.stringify(f.questionAction));
const asked = act(game, f.asker, wire);
assert.deepEqual(asked.truthtrance!.question, f.question);
assert.deepEqual(viewGame(asked, f.target).truthMixedBattleAnswers, ['yes', 'no']);
assert.equal(viewGame(asked, f.asker).truthMixedBattleAnswers, null); assert.equal(viewGame(asked, f.target).truthBattleAnswers, 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);
let ended = reveal(f.game, f).game; ended = settle(finish(ended, f)); ended = declare(f, ended).game;
assert.deepEqual(viewGame(ended, f.asker).truthMixedBattleTargets, []); rejected(ended, f.asker, wire);
assert.deepEqual(inventory(done), f.originalCards);
});
void test('native mixed battle projection retains only the owned raw aggregate and hides residuals from every seat', () => {
const f = fixture(), game = bind(f).game;
for (const actor of [f.target, f.asker]) {
const view = viewGame(game, actor); assert.ok(!('mixedBattleQuestions' in view));
assert.deepEqual(view.battle!.truthPromises, []); assert.equal(view.truthMixedBattleAnswers, 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.mixedBattlePromises, []);
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.ok(view.battle!.compliantPlan); assert.equal(view.battle!.compliantPlan.weapon, f.ordinary);
}
}
assert.equal(game.mixedBattleQuestions?.length, 1); assert.equal(game.battle!.truthPromises?.length, 1);
assert.equal(game.battle!.truthPromises![0].event, promise(game).event);
const revealed = reveal(game, f).game;
for (const actor of [f.target, f.asker]) assert.deepEqual(viewGame(revealed, actor).battle!.truthPromises, []);
});
void test('native earlier mixed Yes constrains the original ordinary Prescience answer before actual sealing', () => {
const f = fixture({ window: 'preparation' });
assert.deepEqual(f.game, f.planSource.beforePreparation); assert.equal(f.actions.at(-1)!.action.type, 'chooseBattle');
assert.equal(f.sourceActionCount, f.sourceCounts.beforePreparation); assert.equal(f.game.battle!.preparation?.kind, 'prescience');
let game = bind(f).game; assert.equal(promise(game).fulfilled, false);
game = act(game, f.asker, { type: 'prescience', field: 'weapon' }); game = settle(game);
assert.equal(game.battle!.preparation?.kind, 'prescienceAnswer');
rejected(game, f.target, { type: 'prescienceAnswer', value: null });
game = act(game, f.target, { type: 'prescienceAnswer', value: f.ordinary }); game = settle(game);
assert.equal(game.battle!.prescience?.value, f.ordinary); assert.equal(promise(game).fulfilled, false);
game = advance(game, state => clean(state) && !state.battle?.preparation);
game = reveal(game, f).game; assert.equal(promise(game).fulfilled, true);
assert.deepEqual(inventory(finish(game, f)), f.originalCards);
});
void test('controlled legacy eventless metadata stays unchanged on read and gains one identity only during the real mixed Ask', () => {
const f = fixture(), legacy = structuredClone(f.game);
// Controlled compatibility case: omit only event metadata, never claim this
// historical eventless representation was emitted by the original source.
delete legacy.battle!.event;
const before = structuredClone(legacy);
for (const actor of [f.target, f.asker]) assert.equal(viewGame(legacy, actor).battle!.event, null);
assert.deepEqual(legacy, before);
const declared = declare(f, legacy); assert.equal(declared.game.battle!.event, undefined);
rejected(declared.game, f.asker, f.questionAction);
const eventless: Action = { type: 'truthAsk', question: { ...f.question, battle: null } };
const asked = act(declared.game, f.asker, JSON.parse(JSON.stringify(eventless)) as Action);
const assigned = asked.battle!.event; assert.equal(typeof assigned, 'string'); assert.ok(assigned);
assert.deepEqual(asked.truthtrance!.question, { ...f.question, battle: assigned });
assert.equal(viewGame(asked, f.target).battle!.event, assigned);
let game = answer(asked, f.target, 'yes'); assert.equal(promise(game).battle, assigned);
game = reveal(game, f).game; assert.equal(game.battle!.event, assigned); assert.equal(promise(game).fulfilled, true);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal profiles answer a native mixed battle and reach a legal seal through the private native fallback', () => {
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.truthMixedBattleAnswers?.includes(answerAction.answer as 'yes' | 'no' | 'unknown'));
game = act(game, f.target, answerAction); const planning = viewGame(game, f.target);
planning.players.find(seat => seat.id === f.target)!.bot = difficulty;
assert.deepEqual(planning.battle!.truthPromises, []); assert.ok(planning.battle!.compliantPlan);
const choices = botActions(planning).filter(candidate => candidate.type === 'battlePlan');
assert.ok(choices.some(candidate => candidate.weapon === planning.battle!.compliantPlan!.weapon), difficulty);
let accepted = false;
for (const candidate of choices) {
const before = structuredClone(game);
try { game = act(game, f.target, candidate); accepted = true; break; }
catch { assert.deepEqual(game, before); }
}
assert.equal(accepted, true, `${difficulty} reaches an accepted original battle plan`);
assert.equal(game.battle!.plans[f.target].weapon, answerAction.answer === 'yes' ? f.ordinary : null);
assert.equal(promise(game).fulfilled, false); game = act(game, f.asker, f.ownPlan);
assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game, f)), f.originalCards);
}
});